feat(webauthn): connexion par clé sur la page de loginUnverified
Bouton « Se connecter avec une clé » (révélé par JS, WEBAUTHN_ORIGIN posée) + autofill conditional UI sur le champ identifiant, un AbortController partage les deux chemins. Erreur dédiée ?err=cle.
4f5b01c parent: 19eed06 modified
src/routes/login.rs +17 -4 | @@ -16,30 +16,43 @@ use crate::state::AppState; | ||
| 16 | 16 | #[derive(Template)] |
| 17 | 17 | #[template(path = "login.html")] |
| 18 | 18 | pub struct LoginPage { |
| 19 | - pub error: bool, | |
| 19 | + pub error: Option<&'static str>, | |
| 20 | 20 | /// Page anonyme : langue par défaut de l'instance (DEFAULT_LANG). |
| 21 | 21 | pub tr: &'static crate::i18n::Tr, |
| 22 | 22 | pub lang_code: &'static str, |
| 23 | 23 | /// SIGNUP_KEY posée : on affiche le lien « Créer un compte ». |
| 24 | 24 | pub signup: bool, |
| 25 | + /// WEBAUTHN_ORIGIN posée : bouton « Se connecter avec une clé » | |
| 26 | + /// (révélé par le JS — WebAuthn n'existe pas sans lui). | |
| 27 | + pub webauthn: bool, | |
| 25 | 28 | } |
| 26 | 29 | |
| 27 | 30 | #[derive(Deserialize)] |
| 28 | 31 | pub struct LoginQuery { |
| 29 | 32 | #[serde(default)] |
| 30 | - err: Option<u8>, | |
| 33 | + err: Option<String>, | |
| 31 | 34 | } |
| 32 | 35 | |
| 33 | -pub async fn login_page(user: Option<AuthUser>, Query(q): Query<LoginQuery>) -> Response { | |
| 36 | +pub async fn login_page( | |
| 37 | + State(state): State<AppState>, | |
| 38 | + user: Option<AuthUser>, | |
| 39 | + Query(q): Query<LoginQuery>, | |
| 40 | +) -> Response { | |
| 34 | 41 | if user.is_some() { |
| 35 | 42 | return Redirect::to("/").into_response(); |
| 36 | 43 | } |
| 37 | 44 | let lang = crate::config::default_lang(); |
| 45 | + let error = match q.err.as_deref() { | |
| 46 | + Some("cle") => Some(lang.tr().err_passkey_login), | |
| 47 | + Some(_) => Some(lang.tr().bad_credentials), | |
| 48 | + None => None, | |
| 49 | + }; | |
| 38 | 50 | HtmlTemplate(LoginPage { |
| 39 | - error: q.err.is_some(), | |
| 51 | + error, | |
| 40 | 52 | tr: lang.tr(), |
| 41 | 53 | lang_code: lang.code(), |
| 42 | 54 | signup: crate::config::signup_key().is_some(), |
| 55 | + webauthn: state.webauthn.is_some(), | |
| 43 | 56 | }) |
| 44 | 57 | .into_response() |
| 45 | 58 | } |
| @@ -16,30 +16,43 @@ use crate::state::AppState; | |||
| 16 | #[derive(Template)] | 16 | #[derive(Template)] |
| 17 | #[template(path = "login.html")] | 17 | #[template(path = "login.html")] |
| 18 | pub struct LoginPage { | 18 | pub struct LoginPage { |
| 19 | - pub error: bool, | 19 | + pub error: Option<&'static str>, |
| 20 | /// Page anonyme : langue par défaut de l'instance (DEFAULT_LANG). | 20 | /// Page anonyme : langue par défaut de l'instance (DEFAULT_LANG). |
| 21 | pub tr: &'static crate::i18n::Tr, | 21 | pub tr: &'static crate::i18n::Tr, |
| 22 | pub lang_code: &'static str, | 22 | pub lang_code: &'static str, |
| 23 | /// SIGNUP_KEY posée : on affiche le lien « Créer un compte ». | 23 | /// SIGNUP_KEY posée : on affiche le lien « Créer un compte ». |
| 24 | pub signup: bool, | 24 | pub signup: bool, |
| 25 | + /// WEBAUTHN_ORIGIN posée : bouton « Se connecter avec une clé » | ||
| 26 | + /// (révélé par le JS — WebAuthn n'existe pas sans lui). | ||
| 27 | + pub webauthn: bool, | ||
| 25 | } | 28 | } |
| 26 | 29 | ||
| 27 | #[derive(Deserialize)] | 30 | #[derive(Deserialize)] |
| 28 | pub struct LoginQuery { | 31 | pub struct LoginQuery { |
| 29 | #[serde(default)] | 32 | #[serde(default)] |
| 30 | - err: Option<u8>, | 33 | + err: Option<String>, |
| 31 | } | 34 | } |
| 32 | 35 | ||
| 33 | -pub async fn login_page(user: Option<AuthUser>, Query(q): Query<LoginQuery>) -> Response { | 36 | +pub async fn login_page( |
| 37 | + State(state): State<AppState>, | ||
| 38 | + user: Option<AuthUser>, | ||
| 39 | + Query(q): Query<LoginQuery>, | ||
| 40 | +) -> Response { | ||
| 34 | if user.is_some() { | 41 | if user.is_some() { |
| 35 | return Redirect::to("/").into_response(); | 42 | return Redirect::to("/").into_response(); |
| 36 | } | 43 | } |
| 37 | let lang = crate::config::default_lang(); | 44 | let lang = crate::config::default_lang(); |
| 45 | + let error = match q.err.as_deref() { | ||
| 46 | + Some("cle") => Some(lang.tr().err_passkey_login), | ||
| 47 | + Some(_) => Some(lang.tr().bad_credentials), | ||
| 48 | + None => None, | ||
| 49 | + }; | ||
| 38 | HtmlTemplate(LoginPage { | 50 | HtmlTemplate(LoginPage { |
| 39 | - error: q.err.is_some(), | 51 | + error, |
| 40 | tr: lang.tr(), | 52 | tr: lang.tr(), |
| 41 | lang_code: lang.code(), | 53 | lang_code: lang.code(), |
| 42 | signup: crate::config::signup_key().is_some(), | 54 | signup: crate::config::signup_key().is_some(), |
| 55 | + webauthn: state.webauthn.is_some(), | ||
| 43 | }) | 56 | }) |
| 44 | .into_response() | 57 | .into_response() |
| 45 | } | 58 | } |
modified
templates/login.html +70 -2 | @@ -21,7 +21,9 @@ | ||
| 21 | 21 | <form method="post" action="/login"> |
| 22 | 22 | <div class="field" style="margin-bottom:var(--space-3)"> |
| 23 | 23 | <label for="username" style="font-size:18px">{{ tr.username_label }}</label> |
| 24 | - <input class="input" id="username" name="username" autocomplete="username" | |
| 24 | + {# « webauthn » dans autocomplete : le navigateur/1Password propose la | |
| 25 | + passkey directement dans le champ (conditional UI). #} | |
| 26 | + <input class="input" id="username" name="username" autocomplete="username webauthn" | |
| 25 | 27 | autocapitalize="none" autocorrect="off" required autofocus |
| 26 | 28 | style="font-size: 18px"> |
| 27 | 29 | </div> |
| @@ -30,12 +32,78 @@ | ||
| 30 | 32 | <input class="input" id="password" name="password" type="password" autocomplete="current-password" required |
| 31 | 33 | style="font-size: 18px"> |
| 32 | 34 | </div> |
| 33 | - {% if error %}<p class="form-error">{{ tr.bad_credentials }}</p>{% endif %} | |
| 35 | + {% if let Some(e) = error %}<p class="form-error">{{ e }}</p>{% endif %} | |
| 34 | 36 | <button type="submit" class="btn btn-primary btn-block" |
| 35 | 37 | style="min-height:56px;font-size:20px">{{ tr.sign_in }}</button> |
| 36 | 38 | </form> |
| 39 | + {% if webauthn %} | |
| 40 | + <button type="button" class="btn btn-secondary btn-block" id="passkey-login" hidden | |
| 41 | + style="margin-top:var(--space-3);min-height:48px">{{ tr.passkey_sign_in }}</button> | |
| 42 | + {% endif %} | |
| 37 | 43 | {% if signup %} |
| 38 | 44 | <a class="btn btn-ghost" style="margin-top:var(--space-4)" href="/inscription">{{ tr.signup_link }}</a> |
| 39 | 45 | {% endif %} |
| 40 | 46 | </main> |
| 47 | +{% if webauthn %} | |
| 48 | +<script> | |
| 49 | + // Connexion par clé d'accès (usernameless). Deux chemins qui partagent le | |
| 50 | + // même flux : l'autofill du champ identifiant (mediation conditional) et le | |
| 51 | + // bouton (modal). Un seul AbortController : le clic sur le bouton annule | |
| 52 | + // l'autofill en attente, une annulation du modal le réarme. Sans JS ni | |
| 53 | + // WebAuthn, le bouton reste hidden — le mot de passe suffit toujours. | |
| 54 | + (function () { | |
| 55 | + var btn = document.getElementById('passkey-login'); | |
| 56 | + if (!btn || !window.PublicKeyCredential || !PublicKeyCredential.parseRequestOptionsFromJSON) return; | |
| 57 | + btn.hidden = false; | |
| 58 | + var ctrl = null; | |
| 59 | + function login(mediation, signal) { | |
| 60 | + return fetch('/webauthn/connexion/debut', { method: 'POST' }) | |
| 61 | + .then(function (r) { | |
| 62 | + return r.json().then(function (j) { | |
| 63 | + if (!r.ok) throw j; | |
| 64 | + // webauthn-rs enveloppe les options dans {publicKey: …}. | |
| 65 | + return navigator.credentials.get({ | |
| 66 | + publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(j.publicKey), | |
| 67 | + mediation: mediation, | |
| 68 | + signal: signal | |
| 69 | + }); | |
| 70 | + }); | |
| 71 | + }) | |
| 72 | + .then(function (cred) { | |
| 73 | + return fetch('/webauthn/connexion/fin', { | |
| 74 | + method: 'POST', | |
| 75 | + headers: { 'Content-Type': 'application/json' }, | |
| 76 | + body: JSON.stringify(cred.toJSON()) | |
| 77 | + }); | |
| 78 | + }) | |
| 79 | + .then(function (r) { | |
| 80 | + return r.json().then(function (j) { | |
| 81 | + if (!r.ok) throw j; | |
| 82 | + location = j.redirect; | |
| 83 | + }); | |
| 84 | + }); | |
| 85 | + } | |
| 86 | + function armConditional() { | |
| 87 | + if (!PublicKeyCredential.isConditionalMediationAvailable) return; | |
| 88 | + PublicKeyCredential.isConditionalMediationAvailable().then(function (ok) { | |
| 89 | + if (!ok) return; | |
| 90 | + ctrl = new AbortController(); | |
| 91 | + login('conditional', ctrl.signal).catch(function (e) { | |
| 92 | + if (e && e.name === 'AbortError') return; // remplacé par le flux bouton | |
| 93 | + location = '/login?err=cle'; | |
| 94 | + }); | |
| 95 | + }); | |
| 96 | + } | |
| 97 | + btn.addEventListener('click', function () { | |
| 98 | + if (ctrl) ctrl.abort(); | |
| 99 | + login(undefined, undefined).catch(function (e) { | |
| 100 | + // Annulation du modal : pas une erreur, on réarme juste l'autofill. | |
| 101 | + if (e && e.name === 'NotAllowedError') { armConditional(); return; } | |
| 102 | + location = '/login?err=cle'; | |
| 103 | + }); | |
| 104 | + }); | |
| 105 | + armConditional(); | |
| 106 | + })(); | |
| 107 | +</script> | |
| 108 | +{% endif %} | |
| 41 | 109 | {% endblock %} |
| @@ -21,7 +21,9 @@ | |||
| 21 | <form method="post" action="/login"> | 21 | <form method="post" action="/login"> |
| 22 | <div class="field" style="margin-bottom:var(--space-3)"> | 22 | <div class="field" style="margin-bottom:var(--space-3)"> |
| 23 | <label for="username" style="font-size:18px">{{ tr.username_label }}</label> | 23 | <label for="username" style="font-size:18px">{{ tr.username_label }}</label> |
| 24 | - <input class="input" id="username" name="username" autocomplete="username" | 24 | + {# « webauthn » dans autocomplete : le navigateur/1Password propose la |
| 25 | + passkey directement dans le champ (conditional UI). #} | ||
| 26 | + <input class="input" id="username" name="username" autocomplete="username webauthn" | ||
| 25 | autocapitalize="none" autocorrect="off" required autofocus | 27 | autocapitalize="none" autocorrect="off" required autofocus |
| 26 | style="font-size: 18px"> | 28 | style="font-size: 18px"> |
| 27 | </div> | 29 | </div> |
| @@ -30,12 +32,78 @@ | |||
| 30 | <input class="input" id="password" name="password" type="password" autocomplete="current-password" required | 32 | <input class="input" id="password" name="password" type="password" autocomplete="current-password" required |
| 31 | style="font-size: 18px"> | 33 | style="font-size: 18px"> |
| 32 | </div> | 34 | </div> |
| 33 | - {% if error %}<p class="form-error">{{ tr.bad_credentials }}</p>{% endif %} | 35 | + {% if let Some(e) = error %}<p class="form-error">{{ e }}</p>{% endif %} |
| 34 | <button type="submit" class="btn btn-primary btn-block" | 36 | <button type="submit" class="btn btn-primary btn-block" |
| 35 | style="min-height:56px;font-size:20px">{{ tr.sign_in }}</button> | 37 | style="min-height:56px;font-size:20px">{{ tr.sign_in }}</button> |
| 36 | </form> | 38 | </form> |
| 39 | + {% if webauthn %} | ||
| 40 | + <button type="button" class="btn btn-secondary btn-block" id="passkey-login" hidden | ||
| 41 | + style="margin-top:var(--space-3);min-height:48px">{{ tr.passkey_sign_in }}</button> | ||
| 42 | + {% endif %} | ||
| 37 | {% if signup %} | 43 | {% if signup %} |
| 38 | <a class="btn btn-ghost" style="margin-top:var(--space-4)" href="/inscription">{{ tr.signup_link }}</a> | 44 | <a class="btn btn-ghost" style="margin-top:var(--space-4)" href="/inscription">{{ tr.signup_link }}</a> |
| 39 | {% endif %} | 45 | {% endif %} |
| 40 | </main> | 46 | </main> |
| 47 | +{% if webauthn %} | ||
| 48 | +<script> | ||
| 49 | + // Connexion par clé d'accès (usernameless). Deux chemins qui partagent le | ||
| 50 | + // même flux : l'autofill du champ identifiant (mediation conditional) et le | ||
| 51 | + // bouton (modal). Un seul AbortController : le clic sur le bouton annule | ||
| 52 | + // l'autofill en attente, une annulation du modal le réarme. Sans JS ni | ||
| 53 | + // WebAuthn, le bouton reste hidden — le mot de passe suffit toujours. | ||
| 54 | + (function () { | ||
| 55 | + var btn = document.getElementById('passkey-login'); | ||
| 56 | + if (!btn || !window.PublicKeyCredential || !PublicKeyCredential.parseRequestOptionsFromJSON) return; | ||
| 57 | + btn.hidden = false; | ||
| 58 | + var ctrl = null; | ||
| 59 | + function login(mediation, signal) { | ||
| 60 | + return fetch('/webauthn/connexion/debut', { method: 'POST' }) | ||
| 61 | + .then(function (r) { | ||
| 62 | + return r.json().then(function (j) { | ||
| 63 | + if (!r.ok) throw j; | ||
| 64 | + // webauthn-rs enveloppe les options dans {publicKey: …}. | ||
| 65 | + return navigator.credentials.get({ | ||
| 66 | + publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(j.publicKey), | ||
| 67 | + mediation: mediation, | ||
| 68 | + signal: signal | ||
| 69 | + }); | ||
| 70 | + }); | ||
| 71 | + }) | ||
| 72 | + .then(function (cred) { | ||
| 73 | + return fetch('/webauthn/connexion/fin', { | ||
| 74 | + method: 'POST', | ||
| 75 | + headers: { 'Content-Type': 'application/json' }, | ||
| 76 | + body: JSON.stringify(cred.toJSON()) | ||
| 77 | + }); | ||
| 78 | + }) | ||
| 79 | + .then(function (r) { | ||
| 80 | + return r.json().then(function (j) { | ||
| 81 | + if (!r.ok) throw j; | ||
| 82 | + location = j.redirect; | ||
| 83 | + }); | ||
| 84 | + }); | ||
| 85 | + } | ||
| 86 | + function armConditional() { | ||
| 87 | + if (!PublicKeyCredential.isConditionalMediationAvailable) return; | ||
| 88 | + PublicKeyCredential.isConditionalMediationAvailable().then(function (ok) { | ||
| 89 | + if (!ok) return; | ||
| 90 | + ctrl = new AbortController(); | ||
| 91 | + login('conditional', ctrl.signal).catch(function (e) { | ||
| 92 | + if (e && e.name === 'AbortError') return; // remplacé par le flux bouton | ||
| 93 | + location = '/login?err=cle'; | ||
| 94 | + }); | ||
| 95 | + }); | ||
| 96 | + } | ||
| 97 | + btn.addEventListener('click', function () { | ||
| 98 | + if (ctrl) ctrl.abort(); | ||
| 99 | + login(undefined, undefined).catch(function (e) { | ||
| 100 | + // Annulation du modal : pas une erreur, on réarme juste l'autofill. | ||
| 101 | + if (e && e.name === 'NotAllowedError') { armConditional(); return; } | ||
| 102 | + location = '/login?err=cle'; | ||
| 103 | + }); | ||
| 104 | + }); | ||
| 105 | + armConditional(); | ||
| 106 | + })(); | ||
| 107 | +</script> | ||
| 108 | +{% endif %} | ||
| 41 | {% endblock %} | 109 | {% endblock %} |