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é.
19eed06 parent: 83a5aa4 modified
Cargo.lock +1 -0 | @@ -864,6 +864,7 @@ dependencies = [ | ||
| 864 | 864 | "tracing-subscriber", |
| 865 | 865 | "uuid", |
| 866 | 866 | "webauthn-rs", |
| 867 | + "webauthn-rs-proto", | |
| 867 | 868 | ] |
| 868 | 869 | |
| 869 | 870 | [[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"] } | ||
| 15 | 15 | # danger-allow-state-serialisation : l'état de cérémonie part dans un cookie |
| 16 | 16 | # signé éphémère (l'app est stateless), pas dans une table. |
| 17 | 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 | 21 | # NOTE : tire biscuit-quote → proc-macro-error2 (non maintenue, future-incompat |
| 19 | 22 | # E0365) via la feature par défaut datalog-macro. On n'utilise pas ces macros, |
| 20 | 23 | # 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 cookie | 15 | # 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-incompat | 21 | # 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 de | 23 | # 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; | ||
| 2 | 2 | pub(crate) mod login; |
| 3 | 3 | mod security; |
| 4 | 4 | mod settings; |
| 5 | +mod webauthn; | |
| 5 | 6 | mod signup; |
| 6 | 7 | mod statics; |
| 7 | 8 | mod stats; |
| @@ -68,6 +69,12 @@ pub fn router(state: AppState) -> Router { | ||
| 68 | 69 | "/parametres/mot-de-passe", |
| 69 | 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 | 78 | .route("/parametres/langue", post(settings::lang_change)) |
| 72 | 79 | .route("/parametres/affichage", post(settings::theme_change)) |
| 73 | 80 | .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, ¤t) { | |
| 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, ¤t) { | ||
| 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 @@ | ||
| 52 | 52 | <noscript><p class="form-error">{{ shell.tr.passkey_js_required }}</p></noscript> |
| 53 | 53 | <button type="submit" class="btn btn-primary btn-block" style="min-height:46px">{{ shell.tr.passkey_add }}</button> |
| 54 | 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 | 101 | {% endif %} |
| 56 | 102 | {% endif %} |
| 57 | 103 | |
| @@ -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 | ||