judu/ex-egopublic Fork
⑂ 4f5b01c
Commits
⬇ Clone ▾
git clone https://git.rickub.com/judu/ex-ego.git
git clone ssh://git@rickub.com/judu/ex-ego.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

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.
Julien Durillon committed 2026-08-26T14:12:36+02:00 Browse files
4f5b01c parent: 19eed06
modified src/routes/login.rs +17 -4
@@ -16,30 +16,43 @@ use crate::state::AppState;
1616 #[derive(Template)]
1717 #[template(path = "login.html")]
1818 pub struct LoginPage {
19- pub error: bool,
19+ pub error: Option<&'static str>,
2020 /// Page anonyme : langue par défaut de l'instance (DEFAULT_LANG).
2121 pub tr: &'static crate::i18n::Tr,
2222 pub lang_code: &'static str,
2323 /// SIGNUP_KEY posée : on affiche le lien « Créer un compte ».
2424 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,
2528 }
2629
2730 #[derive(Deserialize)]
2831 pub struct LoginQuery {
2932 #[serde(default)]
30- err: Option<u8>,
33+ err: Option<String>,
3134 }
3235
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 {
3441 if user.is_some() {
3542 return Redirect::to("/").into_response();
3643 }
3744 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+ };
3850 HtmlTemplate(LoginPage {
39- error: q.err.is_some(),
51+ error,
4052 tr: lang.tr(),
4153 lang_code: lang.code(),
4254 signup: crate::config::signup_key().is_some(),
55+ webauthn: state.webauthn.is_some(),
4356 })
4457 .into_response()
4558 }
@@ -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 @@
2121 <form method="post" action="/login">
2222 <div class="field" style="margin-bottom:var(--space-3)">
2323 <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"
2527 autocapitalize="none" autocorrect="off" required autofocus
2628 style="font-size: 18px">
2729 </div>
@@ -30,12 +32,78 @@
3032 <input class="input" id="password" name="password" type="password" autocomplete="current-password" required
3133 style="font-size: 18px">
3234 </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 %}
3436 <button type="submit" class="btn btn-primary btn-block"
3537 style="min-height:56px;font-size:20px">{{ tr.sign_in }}</button>
3638 </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 %}
3743 {% if signup %}
3844 <a class="btn btn-ghost" style="margin-top:var(--space-4)" href="/inscription">{{ tr.signup_link }}</a>
3945 {% endif %}
4046 </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 %}
41109 {% 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 autofocus27 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" required32 <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 %}