judu/ex-egopublic Fork
⑂ 19eed06
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): cérémonies d'enregistrement et de connexion usernamelessUnverified

4 endpoints JSON (/webauthn/…, 404 sans WEBAUTHN_ORIGIN) : re-auth mdp avant
challenge, clé résidente forcée (condition du login sans identifiant sur
Yubikey), UV requise, quota 10 échecs/h/IP au finish, compteur persisté,
même session_cookie que le login mdp. IIFE d'ajout sur la page Sécurité.
Julien Durillon committed 2026-08-26T14:10:47+02:00 Browse files
19eed06 parent: 83a5aa4
modified Cargo.lock +1 -0
@@ -864,6 +864,7 @@ dependencies = [
864864 "tracing-subscriber",
865865 "uuid",
866866 "webauthn-rs",
867+ "webauthn-rs-proto",
867868 ]
868869
869870 [[package]]
@@ -864,6 +864,7 @@ dependencies = [
864 "tracing-subscriber",864 "tracing-subscriber",
865 "uuid",865 "uuid",
866 "webauthn-rs",866 "webauthn-rs",
867+ "webauthn-rs-proto",
867 ]868 ]
868 869
869 [[package]]870 [[package]]
modified Cargo.toml +3 -0
@@ -15,6 +15,9 @@ uuid = { version = "1", features = ["v7", "serde"] }
1515 # danger-allow-state-serialisation : l'état de cérémonie part dans un cookie
1616 # signé éphémère (l'app est stateless), pas dans une table.
1717 webauthn-rs = { version = "0.5", features = ["conditional-ui", "danger-allow-state-serialisation"] }
18+# Uniquement pour ResidentKeyRequirement (pas dans le prelude de webauthn-rs) :
19+# l'enregistrement force la clé résidente, condition du login usernameless.
20+webauthn-rs-proto = "0.5"
1821 # NOTE : tire biscuit-quote → proc-macro-error2 (non maintenue, future-incompat
1922 # E0365) via la feature par défaut datalog-macro. On n'utilise pas ces macros,
2023 # mais biscuit-auth 6.0.0 ne compile pas en default-features = false (bug de
@@ -15,6 +15,9 @@ uuid = { version = "1", features = ["v7", "serde"] }
15 # danger-allow-state-serialisation : l'état de cérémonie part dans un cookie15 # danger-allow-state-serialisation : l'état de cérémonie part dans un cookie
16 # signé éphémère (l'app est stateless), pas dans une table.16 # signé éphémère (l'app est stateless), pas dans une table.
17 webauthn-rs = { version = "0.5", features = ["conditional-ui", "danger-allow-state-serialisation"] }17 webauthn-rs = { version = "0.5", features = ["conditional-ui", "danger-allow-state-serialisation"] }
18+# Uniquement pour ResidentKeyRequirement (pas dans le prelude de webauthn-rs) :
19+# l'enregistrement force la clé résidente, condition du login usernameless.
20+webauthn-rs-proto = "0.5"
18 # NOTE : tire biscuit-quote → proc-macro-error2 (non maintenue, future-incompat21 # NOTE : tire biscuit-quote → proc-macro-error2 (non maintenue, future-incompat
19 # E0365) via la feature par défaut datalog-macro. On n'utilise pas ces macros,22 # E0365) via la feature par défaut datalog-macro. On n'utilise pas ces macros,
20 # mais biscuit-auth 6.0.0 ne compile pas en default-features = false (bug de23 # mais biscuit-auth 6.0.0 ne compile pas en default-features = false (bug de
modified src/routes/mod.rs +7 -0
@@ -2,6 +2,7 @@ mod entries;
22 pub(crate) mod login;
33 mod security;
44 mod settings;
5+mod webauthn;
56 mod signup;
67 mod statics;
78 mod stats;
@@ -68,6 +69,12 @@ pub fn router(state: AppState) -> Router {
6869 "/parametres/mot-de-passe",
6970 post(security::password_change),
7071 )
72+ // Cérémonies WebAuthn (fetch JSON) — 404 tant que WEBAUTHN_ORIGIN
73+ // n'est pas posée, comme /inscription sans SIGNUP_KEY.
74+ .route("/webauthn/enregistrement/debut", post(webauthn::reg_start))
75+ .route("/webauthn/enregistrement/fin", post(webauthn::reg_finish))
76+ .route("/webauthn/connexion/debut", post(webauthn::auth_start))
77+ .route("/webauthn/connexion/fin", post(webauthn::auth_finish))
7178 .route("/parametres/langue", post(settings::lang_change))
7279 .route("/parametres/affichage", post(settings::theme_change))
7380 .route("/parametres/graphiques", post(settings::chart_style_change))
@@ -2,6 +2,7 @@ mod entries;
2 pub(crate) mod login;2 pub(crate) mod login;
3 mod security;3 mod security;
4 mod settings;4 mod settings;
5+mod webauthn;
5 mod signup;6 mod signup;
6 mod statics;7 mod statics;
7 mod stats;8 mod stats;
@@ -68,6 +69,12 @@ pub fn router(state: AppState) -> Router {
68 "/parametres/mot-de-passe",69 "/parametres/mot-de-passe",
69 post(security::password_change),70 post(security::password_change),
70 )71 )
72+ // Cérémonies WebAuthn (fetch JSON) — 404 tant que WEBAUTHN_ORIGIN
73+ // n'est pas posée, comme /inscription sans SIGNUP_KEY.
74+ .route("/webauthn/enregistrement/debut", post(webauthn::reg_start))
75+ .route("/webauthn/enregistrement/fin", post(webauthn::reg_finish))
76+ .route("/webauthn/connexion/debut", post(webauthn::auth_start))
77+ .route("/webauthn/connexion/fin", post(webauthn::auth_finish))
71 .route("/parametres/langue", post(settings::lang_change))78 .route("/parametres/langue", post(settings::lang_change))
72 .route("/parametres/affichage", post(settings::theme_change))79 .route("/parametres/affichage", post(settings::theme_change))
73 .route("/parametres/graphiques", post(settings::chart_style_change))80 .route("/parametres/graphiques", post(settings::chart_style_change))
added src/routes/webauthn.rs +270 -0
new file mode 100644
@@ -0,0 +1,270 @@
1+//! Cérémonies WebAuthn (fetch JSON) : enregistrement d'une clé (connecté) et
2+//! connexion usernameless par credential découvrable (anonyme).
3+//!
4+//! Tous les endpoints rendent 404 tant que `WEBAUTHN_ORIGIN` n'est pas posée
5+//! (motif `key_or_404()` de l'inscription). Les erreurs métier partent en
6+//! `{"error": code}` : le JS recharge la page avec `?err=code`, c'est le
7+//! serveur qui traduit — aucun texte côté client.
8+//!
9+//! L'état entre début et fin voyage dans un cookie scellé (`auth::webauthn`),
10+//! `SameSite=Strict` : il n'est consommé que par des fetch same-origin. Les
11+//! endpoints de connexion n'ont pas de CSRF — rien à forger : la réponse est
12+//! la signature d'un challenge que l'attaquant ne choisit pas, liée par le
13+//! cookie d'état.
14+
15+use std::net::SocketAddr;
16+
17+use axum::Json;
18+use axum::extract::{ConnectInfo, State};
19+use axum::http::{HeaderMap, StatusCode};
20+use axum::response::{IntoResponse, Response};
21+use axum_extra::extract::CookieJar;
22+use axum_extra::extract::cookie::{Cookie, SameSite};
23+use serde::Deserialize;
24+use webauthn_rs::prelude::{
25+ CredentialID, DiscoverableKey, PublicKeyCredential, RegisterPublicKeyCredential,
26+};
27+use webauthn_rs_proto::ResidentKeyRequirement;
28+
29+use crate::auth::webauthn::{AUTH_COOKIE, CEREMONY_SECONDS, REG_COOKIE, RegCeremony};
30+use crate::auth::{password, webauthn as seal};
31+use crate::db::{passkeys, users};
32+use crate::error::AppError;
33+use crate::ratelimit::client_ip;
34+use crate::routes::login::session_cookie;
35+use crate::state::AppState;
36+use crate::team::ActiveTeam;
37+
38+fn json_err(status: StatusCode, code: &'static str) -> Response {
39+ (status, Json(serde_json::json!({ "error": code }))).into_response()
40+}
41+
42+fn json_redirect(to: &str) -> Json<serde_json::Value> {
43+ Json(serde_json::json!({ "redirect": to }))
44+}
45+
46+fn ceremony_cookie(name: &'static str, value: String) -> Cookie<'static> {
47+ Cookie::build((name, value))
48+ .path("/webauthn")
49+ .http_only(true)
50+ .secure(true)
51+ // Strict, contrairement à la session (Lax pour la PWA) : ces cookies
52+ // ne servent qu'à des fetch same-origin, jamais à une navigation.
53+ .same_site(SameSite::Strict)
54+ .max_age(time::Duration::seconds(CEREMONY_SECONDS))
55+ .build()
56+}
57+
58+/// Le retrait doit porter le même Path que la pose, sinon le navigateur
59+/// garde l'original.
60+fn expired_cookie(name: &'static str) -> Cookie<'static> {
61+ let mut c = ceremony_cookie(name, String::new());
62+ c.set_max_age(time::Duration::ZERO);
63+ c
64+}
65+
66+#[derive(Deserialize)]
67+pub struct RegStartBody {
68+ csrf: String,
69+ mdp: String,
70+ nom: String,
71+}
72+
73+/// Début d'enregistrement : mot de passe vérifié AVANT d'émettre le moindre
74+/// challenge (re-auth — la session dure six mois), libellé validé, clés déjà
75+/// enregistrées exclues.
76+pub async fn reg_start(
77+ State(state): State<AppState>,
78+ ctx: ActiveTeam,
79+ jar: CookieJar,
80+ Json(body): Json<RegStartBody>,
81+) -> Result<Response, AppError> {
82+ let Some(webauthn) = state.webauthn.clone() else {
83+ return Err(AppError::NotFound);
84+ };
85+ if !ctx.user.check_csrf(&state, &body.csrf) {
86+ return Err(AppError::Csrf);
87+ }
88+ let current = users::password_hash(&state.pool, ctx.user.id)
89+ .await?
90+ .ok_or(AppError::NotFound)?;
91+ if !password::verify(&body.mdp, &current) {
92+ return Ok(json_err(StatusCode::FORBIDDEN, "cle-mdp"));
93+ }
94+ let nom = body.nom.trim();
95+ if nom.is_empty() {
96+ return Ok(json_err(StatusCode::BAD_REQUEST, "cle-nom"));
97+ }
98+
99+ let exclude: Vec<CredentialID> = passkeys::credential_ids(&state.pool, ctx.user.id)
100+ .await?
101+ .into_iter()
102+ .map(CredentialID::from)
103+ .collect();
104+ let (mut ccr, reg_state) = webauthn
105+ .start_passkey_registration(
106+ ctx.user.id,
107+ &ctx.username,
108+ &ctx.username,
109+ (!exclude.is_empty()).then_some(exclude),
110+ )
111+ .map_err(|e| AppError::Internal(e.into()))?;
112+ // webauthn-rs (0.5.5) exige déjà la vérification utilisateur (PIN /
113+ // biométrie), mais émet `require_resident_key(false)` : une Yubikey
114+ // créerait alors un credential NON découvrable, invisible du login
115+ // usernameless — la seule porte d'entrée par clé de l'app. On force donc
116+ // la clé résidente. Revers assumé : les vieilles CTAP 2.0 n'ont que
117+ // ~25 emplacements. Le finish n'a pas à vérifier la résidence (le client
118+ // ne la rapporte pas de façon fiable) — un authenticator qui l'ignore
119+ // donnera une clé enregistrable mais inutilisable au login, listée et
120+ // supprimable depuis la page Sécurité.
121+ if let Some(sel) = ccr.public_key.authenticator_selection.as_mut() {
122+ sel.resident_key = Some(ResidentKeyRequirement::Required);
123+ sel.require_resident_key = true;
124+ }
125+
126+ let sealed = seal::seal_reg(
127+ &state.webauthn_key,
128+ &RegCeremony {
129+ user_id: ctx.user.id,
130+ label: nom.to_string(),
131+ state: reg_state,
132+ },
133+ )
134+ .map_err(AppError::Internal)?;
135+ Ok((jar.add(ceremony_cookie(REG_COOKIE, sealed)), Json(ccr)).into_response())
136+}
137+
138+#[derive(Deserialize)]
139+pub struct RegFinishBody {
140+ csrf: String,
141+ credential: RegisterPublicKeyCredential,
142+}
143+
144+pub async fn reg_finish(
145+ State(state): State<AppState>,
146+ ctx: ActiveTeam,
147+ jar: CookieJar,
148+ Json(body): Json<RegFinishBody>,
149+) -> Result<Response, AppError> {
150+ let Some(webauthn) = state.webauthn.clone() else {
151+ return Err(AppError::NotFound);
152+ };
153+ if !ctx.user.check_csrf(&state, &body.csrf) {
154+ return Err(AppError::Csrf);
155+ }
156+ let Some(cookie) = jar.get(REG_COOKIE) else {
157+ return Ok(json_err(StatusCode::BAD_REQUEST, "cle"));
158+ };
159+ let Ok(ceremony) = seal::unseal_reg(&state.webauthn_key, cookie.value()) else {
160+ return Ok(json_err(StatusCode::BAD_REQUEST, "cle"));
161+ };
162+ // La session a pu changer entre début et fin : la cérémonie est au compte.
163+ if ceremony.user_id != ctx.user.id {
164+ return Ok(json_err(StatusCode::BAD_REQUEST, "cle"));
165+ }
166+ let Ok(passkey) = webauthn.finish_passkey_registration(&body.credential, &ceremony.state)
167+ else {
168+ return Ok(json_err(StatusCode::BAD_REQUEST, "cle"));
169+ };
170+
171+ match passkeys::insert(&state.pool, ctx.user.id, &ceremony.label, &passkey).await {
172+ Ok(()) => {}
173+ // Déjà enregistrée — y compris sur un AUTRE compte (unicité globale).
174+ Err(e) if is_unique_violation(&e) => {
175+ return Ok(json_err(StatusCode::BAD_REQUEST, "cle-existe"));
176+ }
177+ Err(e) => return Err(e.into()),
178+ }
179+ Ok((
180+ jar.remove(expired_cookie(REG_COOKIE)),
181+ json_redirect("/parametres/securite?ok=cle-ajoutee"),
182+ )
183+ .into_response())
184+}
185+
186+fn is_unique_violation(e: &sqlx::Error) -> bool {
187+ matches!(e, sqlx::Error::Database(db) if db.is_unique_violation())
188+}
189+
190+/// Début de connexion usernameless : aucun corps, aucun compte encore — le
191+/// challenge est le même quel que soit l'appelant, rien à protéger ni limiter.
192+pub async fn auth_start(
193+ State(state): State<AppState>,
194+ jar: CookieJar,
195+) -> Result<Response, AppError> {
196+ let Some(webauthn) = state.webauthn.clone() else {
197+ return Err(AppError::NotFound);
198+ };
199+ let (rcr, auth_state) = webauthn
200+ .start_discoverable_authentication()
201+ .map_err(|e| AppError::Internal(e.into()))?;
202+ let sealed = seal::seal_auth(&state.webauthn_key, &auth_state).map_err(AppError::Internal)?;
203+ Ok((jar.add(ceremony_cookie(AUTH_COOKIE, sealed)), Json(rcr)).into_response())
204+}
205+
206+pub async fn auth_finish(
207+ State(state): State<AppState>,
208+ headers: HeaderMap,
209+ ConnectInfo(peer): ConnectInfo<SocketAddr>,
210+ jar: CookieJar,
211+ Json(cred): Json<PublicKeyCredential>,
212+) -> Result<Response, AppError> {
213+ let Some(webauthn) = state.webauthn.clone() else {
214+ return Err(AppError::NotFound);
215+ };
216+ // Quota consulté avant tout travail (motif signup) ; un même code
217+ // d'erreur « cle » partout ailleurs — ne rien apprendre à l'extérieur.
218+ let ip = client_ip(&headers, Some(peer));
219+ if !state.webauthn_fails.allows(&ip) {
220+ return Ok(json_err(StatusCode::TOO_MANY_REQUESTS, "essais"));
221+ }
222+ let fail = |state: &AppState| {
223+ state.webauthn_fails.record(&ip);
224+ json_err(StatusCode::UNAUTHORIZED, "cle")
225+ };
226+
227+ let Some(cookie) = jar.get(AUTH_COOKIE) else {
228+ return Ok(fail(&state));
229+ };
230+ let Ok(auth_state) = seal::unseal_auth(&state.webauthn_key, cookie.value()) else {
231+ return Ok(fail(&state));
232+ };
233+ let Ok((user_id, _)) = webauthn.identify_discoverable_authentication(&cred) else {
234+ return Ok(fail(&state));
235+ };
236+ // FK ON DELETE CASCADE : une ligne implique un compte vivant.
237+ let mut stored = passkeys::passkeys_of(&state.pool, user_id).await?;
238+ if stored.is_empty() {
239+ return Ok(fail(&state));
240+ }
241+ let discoverable: Vec<DiscoverableKey> = stored.iter().map(DiscoverableKey::from).collect();
242+ let Ok(result) = webauthn.finish_discoverable_authentication(&cred, auth_state, &discoverable)
243+ else {
244+ return Ok(fail(&state));
245+ };
246+ // Vérification utilisateur REQUISE : la clé est un facteur unique, le
247+ // PIN/la biométrie est ce qui empêche une clé volée de suffire.
248+ if !result.user_verified() {
249+ return Ok(fail(&state));
250+ }
251+
252+ // Compteur de signature : webauthn-rs tolère lui-même le 0 permanent des
253+ // passkeys synchronisées (1Password) et rejette les régressions — on ne
254+ // fait que persister ce qu'il a avancé.
255+ if result.needs_update() {
256+ for pk in stored.iter_mut() {
257+ if pk.cred_id() == result.cred_id() && pk.update_credential(&result) == Some(true) {
258+ passkeys::update(&state.pool, result.cred_id().as_ref(), pk).await?;
259+ }
260+ }
261+ }
262+
263+ // Même session que le login par mot de passe : juste une autre porte.
264+ let session = session_cookie(&state, user_id)?;
265+ Ok((
266+ jar.add(session).remove(expired_cookie(AUTH_COOKIE)),
267+ json_redirect("/"),
268+ )
269+ .into_response())
270+}
new file mode 100644
@@ -0,0 +1,270 @@
1+//! Cérémonies WebAuthn (fetch JSON) : enregistrement d'une clé (connecté) et
2+//! connexion usernameless par credential découvrable (anonyme).
3+//!
4+//! Tous les endpoints rendent 404 tant que `WEBAUTHN_ORIGIN` n'est pas posée
5+//! (motif `key_or_404()` de l'inscription). Les erreurs métier partent en
6+//! `{"error": code}` : le JS recharge la page avec `?err=code`, c'est le
7+//! serveur qui traduit — aucun texte côté client.
8+//!
9+//! L'état entre début et fin voyage dans un cookie scellé (`auth::webauthn`),
10+//! `SameSite=Strict` : il n'est consommé que par des fetch same-origin. Les
11+//! endpoints de connexion n'ont pas de CSRF — rien à forger : la réponse est
12+//! la signature d'un challenge que l'attaquant ne choisit pas, liée par le
13+//! cookie d'état.
14+
15+use std::net::SocketAddr;
16+
17+use axum::Json;
18+use axum::extract::{ConnectInfo, State};
19+use axum::http::{HeaderMap, StatusCode};
20+use axum::response::{IntoResponse, Response};
21+use axum_extra::extract::CookieJar;
22+use axum_extra::extract::cookie::{Cookie, SameSite};
23+use serde::Deserialize;
24+use webauthn_rs::prelude::{
25+ CredentialID, DiscoverableKey, PublicKeyCredential, RegisterPublicKeyCredential,
26+};
27+use webauthn_rs_proto::ResidentKeyRequirement;
28+
29+use crate::auth::webauthn::{AUTH_COOKIE, CEREMONY_SECONDS, REG_COOKIE, RegCeremony};
30+use crate::auth::{password, webauthn as seal};
31+use crate::db::{passkeys, users};
32+use crate::error::AppError;
33+use crate::ratelimit::client_ip;
34+use crate::routes::login::session_cookie;
35+use crate::state::AppState;
36+use crate::team::ActiveTeam;
37+
38+fn json_err(status: StatusCode, code: &'static str) -> Response {
39+ (status, Json(serde_json::json!({ "error": code }))).into_response()
40+}
41+
42+fn json_redirect(to: &str) -> Json<serde_json::Value> {
43+ Json(serde_json::json!({ "redirect": to }))
44+}
45+
46+fn ceremony_cookie(name: &'static str, value: String) -> Cookie<'static> {
47+ Cookie::build((name, value))
48+ .path("/webauthn")
49+ .http_only(true)
50+ .secure(true)
51+ // Strict, contrairement à la session (Lax pour la PWA) : ces cookies
52+ // ne servent qu'à des fetch same-origin, jamais à une navigation.
53+ .same_site(SameSite::Strict)
54+ .max_age(time::Duration::seconds(CEREMONY_SECONDS))
55+ .build()
56+}
57+
58+/// Le retrait doit porter le même Path que la pose, sinon le navigateur
59+/// garde l'original.
60+fn expired_cookie(name: &'static str) -> Cookie<'static> {
61+ let mut c = ceremony_cookie(name, String::new());
62+ c.set_max_age(time::Duration::ZERO);
63+ c
64+}
65+
66+#[derive(Deserialize)]
67+pub struct RegStartBody {
68+ csrf: String,
69+ mdp: String,
70+ nom: String,
71+}
72+
73+/// Début d'enregistrement : mot de passe vérifié AVANT d'émettre le moindre
74+/// challenge (re-auth — la session dure six mois), libellé validé, clés déjà
75+/// enregistrées exclues.
76+pub async fn reg_start(
77+ State(state): State<AppState>,
78+ ctx: ActiveTeam,
79+ jar: CookieJar,
80+ Json(body): Json<RegStartBody>,
81+) -> Result<Response, AppError> {
82+ let Some(webauthn) = state.webauthn.clone() else {
83+ return Err(AppError::NotFound);
84+ };
85+ if !ctx.user.check_csrf(&state, &body.csrf) {
86+ return Err(AppError::Csrf);
87+ }
88+ let current = users::password_hash(&state.pool, ctx.user.id)
89+ .await?
90+ .ok_or(AppError::NotFound)?;
91+ if !password::verify(&body.mdp, &current) {
92+ return Ok(json_err(StatusCode::FORBIDDEN, "cle-mdp"));
93+ }
94+ let nom = body.nom.trim();
95+ if nom.is_empty() {
96+ return Ok(json_err(StatusCode::BAD_REQUEST, "cle-nom"));
97+ }
98+
99+ let exclude: Vec<CredentialID> = passkeys::credential_ids(&state.pool, ctx.user.id)
100+ .await?
101+ .into_iter()
102+ .map(CredentialID::from)
103+ .collect();
104+ let (mut ccr, reg_state) = webauthn
105+ .start_passkey_registration(
106+ ctx.user.id,
107+ &ctx.username,
108+ &ctx.username,
109+ (!exclude.is_empty()).then_some(exclude),
110+ )
111+ .map_err(|e| AppError::Internal(e.into()))?;
112+ // webauthn-rs (0.5.5) exige déjà la vérification utilisateur (PIN /
113+ // biométrie), mais émet `require_resident_key(false)` : une Yubikey
114+ // créerait alors un credential NON découvrable, invisible du login
115+ // usernameless — la seule porte d'entrée par clé de l'app. On force donc
116+ // la clé résidente. Revers assumé : les vieilles CTAP 2.0 n'ont que
117+ // ~25 emplacements. Le finish n'a pas à vérifier la résidence (le client
118+ // ne la rapporte pas de façon fiable) — un authenticator qui l'ignore
119+ // donnera une clé enregistrable mais inutilisable au login, listée et
120+ // supprimable depuis la page Sécurité.
121+ if let Some(sel) = ccr.public_key.authenticator_selection.as_mut() {
122+ sel.resident_key = Some(ResidentKeyRequirement::Required);
123+ sel.require_resident_key = true;
124+ }
125+
126+ let sealed = seal::seal_reg(
127+ &state.webauthn_key,
128+ &RegCeremony {
129+ user_id: ctx.user.id,
130+ label: nom.to_string(),
131+ state: reg_state,
132+ },
133+ )
134+ .map_err(AppError::Internal)?;
135+ Ok((jar.add(ceremony_cookie(REG_COOKIE, sealed)), Json(ccr)).into_response())
136+}
137+
138+#[derive(Deserialize)]
139+pub struct RegFinishBody {
140+ csrf: String,
141+ credential: RegisterPublicKeyCredential,
142+}
143+
144+pub async fn reg_finish(
145+ State(state): State<AppState>,
146+ ctx: ActiveTeam,
147+ jar: CookieJar,
148+ Json(body): Json<RegFinishBody>,
149+) -> Result<Response, AppError> {
150+ let Some(webauthn) = state.webauthn.clone() else {
151+ return Err(AppError::NotFound);
152+ };
153+ if !ctx.user.check_csrf(&state, &body.csrf) {
154+ return Err(AppError::Csrf);
155+ }
156+ let Some(cookie) = jar.get(REG_COOKIE) else {
157+ return Ok(json_err(StatusCode::BAD_REQUEST, "cle"));
158+ };
159+ let Ok(ceremony) = seal::unseal_reg(&state.webauthn_key, cookie.value()) else {
160+ return Ok(json_err(StatusCode::BAD_REQUEST, "cle"));
161+ };
162+ // La session a pu changer entre début et fin : la cérémonie est au compte.
163+ if ceremony.user_id != ctx.user.id {
164+ return Ok(json_err(StatusCode::BAD_REQUEST, "cle"));
165+ }
166+ let Ok(passkey) = webauthn.finish_passkey_registration(&body.credential, &ceremony.state)
167+ else {
168+ return Ok(json_err(StatusCode::BAD_REQUEST, "cle"));
169+ };
170+
171+ match passkeys::insert(&state.pool, ctx.user.id, &ceremony.label, &passkey).await {
172+ Ok(()) => {}
173+ // Déjà enregistrée — y compris sur un AUTRE compte (unicité globale).
174+ Err(e) if is_unique_violation(&e) => {
175+ return Ok(json_err(StatusCode::BAD_REQUEST, "cle-existe"));
176+ }
177+ Err(e) => return Err(e.into()),
178+ }
179+ Ok((
180+ jar.remove(expired_cookie(REG_COOKIE)),
181+ json_redirect("/parametres/securite?ok=cle-ajoutee"),
182+ )
183+ .into_response())
184+}
185+
186+fn is_unique_violation(e: &sqlx::Error) -> bool {
187+ matches!(e, sqlx::Error::Database(db) if db.is_unique_violation())
188+}
189+
190+/// Début de connexion usernameless : aucun corps, aucun compte encore — le
191+/// challenge est le même quel que soit l'appelant, rien à protéger ni limiter.
192+pub async fn auth_start(
193+ State(state): State<AppState>,
194+ jar: CookieJar,
195+) -> Result<Response, AppError> {
196+ let Some(webauthn) = state.webauthn.clone() else {
197+ return Err(AppError::NotFound);
198+ };
199+ let (rcr, auth_state) = webauthn
200+ .start_discoverable_authentication()
201+ .map_err(|e| AppError::Internal(e.into()))?;
202+ let sealed = seal::seal_auth(&state.webauthn_key, &auth_state).map_err(AppError::Internal)?;
203+ Ok((jar.add(ceremony_cookie(AUTH_COOKIE, sealed)), Json(rcr)).into_response())
204+}
205+
206+pub async fn auth_finish(
207+ State(state): State<AppState>,
208+ headers: HeaderMap,
209+ ConnectInfo(peer): ConnectInfo<SocketAddr>,
210+ jar: CookieJar,
211+ Json(cred): Json<PublicKeyCredential>,
212+) -> Result<Response, AppError> {
213+ let Some(webauthn) = state.webauthn.clone() else {
214+ return Err(AppError::NotFound);
215+ };
216+ // Quota consulté avant tout travail (motif signup) ; un même code
217+ // d'erreur « cle » partout ailleurs — ne rien apprendre à l'extérieur.
218+ let ip = client_ip(&headers, Some(peer));
219+ if !state.webauthn_fails.allows(&ip) {
220+ return Ok(json_err(StatusCode::TOO_MANY_REQUESTS, "essais"));
221+ }
222+ let fail = |state: &AppState| {
223+ state.webauthn_fails.record(&ip);
224+ json_err(StatusCode::UNAUTHORIZED, "cle")
225+ };
226+
227+ let Some(cookie) = jar.get(AUTH_COOKIE) else {
228+ return Ok(fail(&state));
229+ };
230+ let Ok(auth_state) = seal::unseal_auth(&state.webauthn_key, cookie.value()) else {
231+ return Ok(fail(&state));
232+ };
233+ let Ok((user_id, _)) = webauthn.identify_discoverable_authentication(&cred) else {
234+ return Ok(fail(&state));
235+ };
236+ // FK ON DELETE CASCADE : une ligne implique un compte vivant.
237+ let mut stored = passkeys::passkeys_of(&state.pool, user_id).await?;
238+ if stored.is_empty() {
239+ return Ok(fail(&state));
240+ }
241+ let discoverable: Vec<DiscoverableKey> = stored.iter().map(DiscoverableKey::from).collect();
242+ let Ok(result) = webauthn.finish_discoverable_authentication(&cred, auth_state, &discoverable)
243+ else {
244+ return Ok(fail(&state));
245+ };
246+ // Vérification utilisateur REQUISE : la clé est un facteur unique, le
247+ // PIN/la biométrie est ce qui empêche une clé volée de suffire.
248+ if !result.user_verified() {
249+ return Ok(fail(&state));
250+ }
251+
252+ // Compteur de signature : webauthn-rs tolère lui-même le 0 permanent des
253+ // passkeys synchronisées (1Password) et rejette les régressions — on ne
254+ // fait que persister ce qu'il a avancé.
255+ if result.needs_update() {
256+ for pk in stored.iter_mut() {
257+ if pk.cred_id() == result.cred_id() && pk.update_credential(&result) == Some(true) {
258+ passkeys::update(&state.pool, result.cred_id().as_ref(), pk).await?;
259+ }
260+ }
261+ }
262+
263+ // Même session que le login par mot de passe : juste une autre porte.
264+ let session = session_cookie(&state, user_id)?;
265+ Ok((
266+ jar.add(session).remove(expired_cookie(AUTH_COOKIE)),
267+ json_redirect("/"),
268+ )
269+ .into_response())
270+}
modified templates/security.html +46 -0
@@ -52,6 +52,52 @@
5252 <noscript><p class="form-error">{{ shell.tr.passkey_js_required }}</p></noscript>
5353 <button type="submit" class="btn btn-primary btn-block" style="min-height:46px">{{ shell.tr.passkey_add }}</button>
5454 </form>
55+<script>
56+ // Enregistrement d'une clé d'accès. Sans JS (ou navigateur sans l'API
57+ // JSON WebAuthn), le form POST retombe sur /parametres/securite/cles qui
58+ // explique (?err=cle-js). Toute erreur recharge avec ?err=<code> : c'est
59+ // le serveur qui traduit, le client ne porte aucun texte.
60+ (function () {
61+ var form = document.querySelector('form[data-webauthn-add]');
62+ if (!form || !window.PublicKeyCredential || !PublicKeyCredential.parseCreationOptionsFromJSON) return;
63+ form.addEventListener('submit', function (ev) {
64+ ev.preventDefault();
65+ var csrf = form.querySelector('input[name="csrf"]').value;
66+ var body = {
67+ csrf: csrf,
68+ nom: form.querySelector('input[name="nom"]').value,
69+ mdp: form.querySelector('input[name="mdp"]').value
70+ };
71+ var fail = function (code) {
72+ location = '/parametres/securite?err=' + (typeof code === 'string' ? code : 'cle');
73+ };
74+ fetch('/webauthn/enregistrement/debut', {
75+ method: 'POST',
76+ headers: { 'Content-Type': 'application/json' },
77+ body: JSON.stringify(body)
78+ }).then(function (r) {
79+ return r.json().then(function (j) {
80+ if (!r.ok) throw j.error;
81+ // webauthn-rs enveloppe les options dans {publicKey: …}.
82+ return navigator.credentials.create({
83+ publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(j.publicKey)
84+ });
85+ });
86+ }).then(function (cred) {
87+ return fetch('/webauthn/enregistrement/fin', {
88+ method: 'POST',
89+ headers: { 'Content-Type': 'application/json' },
90+ body: JSON.stringify({ csrf: csrf, credential: cred.toJSON() })
91+ });
92+ }).then(function (r) {
93+ return r.json().then(function (j) {
94+ if (!r.ok) throw j.error;
95+ location = j.redirect;
96+ });
97+ }).catch(fail);
98+ });
99+ })();
100+</script>
55101 {% endif %}
56102 {% endif %}
57103
@@ -52,6 +52,52 @@
52 <noscript><p class="form-error">{{ shell.tr.passkey_js_required }}</p></noscript>52 <noscript><p class="form-error">{{ shell.tr.passkey_js_required }}</p></noscript>
53 <button type="submit" class="btn btn-primary btn-block" style="min-height:46px">{{ shell.tr.passkey_add }}</button>53 <button type="submit" class="btn btn-primary btn-block" style="min-height:46px">{{ shell.tr.passkey_add }}</button>
54 </form>54 </form>
55+<script>
56+ // Enregistrement d'une clé d'accès. Sans JS (ou navigateur sans l'API
57+ // JSON WebAuthn), le form POST retombe sur /parametres/securite/cles qui
58+ // explique (?err=cle-js). Toute erreur recharge avec ?err=<code> : c'est
59+ // le serveur qui traduit, le client ne porte aucun texte.
60+ (function () {
61+ var form = document.querySelector('form[data-webauthn-add]');
62+ if (!form || !window.PublicKeyCredential || !PublicKeyCredential.parseCreationOptionsFromJSON) return;
63+ form.addEventListener('submit', function (ev) {
64+ ev.preventDefault();
65+ var csrf = form.querySelector('input[name="csrf"]').value;
66+ var body = {
67+ csrf: csrf,
68+ nom: form.querySelector('input[name="nom"]').value,
69+ mdp: form.querySelector('input[name="mdp"]').value
70+ };
71+ var fail = function (code) {
72+ location = '/parametres/securite?err=' + (typeof code === 'string' ? code : 'cle');
73+ };
74+ fetch('/webauthn/enregistrement/debut', {
75+ method: 'POST',
76+ headers: { 'Content-Type': 'application/json' },
77+ body: JSON.stringify(body)
78+ }).then(function (r) {
79+ return r.json().then(function (j) {
80+ if (!r.ok) throw j.error;
81+ // webauthn-rs enveloppe les options dans {publicKey: …}.
82+ return navigator.credentials.create({
83+ publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(j.publicKey)
84+ });
85+ });
86+ }).then(function (cred) {
87+ return fetch('/webauthn/enregistrement/fin', {
88+ method: 'POST',
89+ headers: { 'Content-Type': 'application/json' },
90+ body: JSON.stringify({ csrf: csrf, credential: cred.toJSON() })
91+ });
92+ }).then(function (r) {
93+ return r.json().then(function (j) {
94+ if (!r.ok) throw j.error;
95+ location = j.redirect;
96+ });
97+ }).catch(fail);
98+ });
99+ })();
100+</script>
55 {% endif %}101 {% endif %}
56 {% endif %}102 {% endif %}
57 103