//! Self-service credentials: the password, passkeys, and which of the two an //! account needs to sign in. //! //! Every route here except the two `login_*` ones needs a session. The two //! that do not are the passkey half of signing in, which by definition runs //! before there is one. use std::sync::Arc; use api_types::{ AuthMode, ChangePassword, LoginResp, OkResp, PasskeyChallenge, PasskeyInfo, PasskeyLoginBegin, PasskeyLoginFinish, PasskeyRegisterFinish, PasswordStep, SetAuthMode, }; use axum::Json; use axum::extract::{Path as AxumPath, State}; use axum::http::{HeaderMap, StatusCode, header}; use axum::response::{IntoResponse, Response}; use webauthn_rs::prelude::*; use webauthn_rs_proto::ResidentKeyRequirement; use crate::api::common::{SessionUser, credential_label, hash_password, validate_password}; use crate::auth::{self, parse_session_cookie, session_cookie}; use crate::db::{PASSKEY_LIMIT, PasskeyDeleted, PasskeyRow}; use crate::error::{ApiError, AppState}; use crate::webauthn::{Pending, Rp}; // --------------------------------------------------------------------------- // Errors // --------------------------------------------------------------------------- pub(crate) fn challenge_expired() -> ApiError { ApiError::localized( StatusCode::BAD_REQUEST, "that took too long, please try again", "err_challenge_expired", ) } /// One message for every way a WebAuthn ceremony can fail. /// /// The detail goes to the log, never to the client: a bad signature, a /// mismatched origin and an unknown credential are all "it did not work" to /// the person at the keyboard, and telling them apart only helps an attacker. fn webauthn_failed(e: WebauthnError) -> ApiError { tracing::warn!(error = ?e, "webauthn ceremony failed"); ApiError::localized( StatusCode::UNAUTHORIZED, "that passkey could not be used", "err_passkey_failed", ) } /// The database refused a change that would have left the account with no way /// to sign in. /// /// Each handler checks its own rule first and says which one, so this is only /// reached when two changes race: a count taken before the write was already /// stale. Rare enough that one message covers it. fn locked_out() -> ApiError { ApiError::localized( StatusCode::BAD_REQUEST, "that would leave the account with no way to sign in", "err_locked_out", ) } fn too_many_passkeys() -> ApiError { ApiError::localized( StatusCode::BAD_REQUEST, "this account already holds as many passkeys as it may", "err_passkey_limit", ) } fn bad_credential() -> ApiError { ApiError::localized( StatusCode::BAD_REQUEST, "the browser sent an unreadable credential", "err_passkey_malformed", ) } // --------------------------------------------------------------------------- // Shared checks // --------------------------------------------------------------------------- /// Apply the fallout of any credential change: every other session of this /// user is dropped, and cached WebDAV credentials are forgotten. /// /// This carries more weight than it looks. Nothing on these routes asks for /// the current password — a passkey-only account has none — so the session is /// the only thing standing behind a credential change. Dropping the others /// keeps a session stolen before the change from outliving it. async fn invalidate_elsewhere( state: &AppState, user_id: i64, headers: &HeaderMap, ) -> Result<(), ApiError> { let current = parse_session_cookie(headers).unwrap_or_default(); state.db.delete_other_sessions(user_id, ¤t).await?; auth::forget_verified_for(user_id); Ok(()) } fn info(row: &PasskeyRow) -> PasskeyInfo { PasskeyInfo { id: row.id, name: row.name.clone(), created_at: row.created_at.clone(), last_used_at: row.last_used_at.clone(), } } /// The stored credentials of one account, ready for `webauthn-rs`. /// /// A row that will not deserialize is skipped rather than fatal. It can only /// come from a `webauthn-rs` format change, and one unreadable passkey must /// not lock an account out of the others. pub(crate) async fn load_passkeys( state: &AppState, user_id: i64, ) -> Result, ApiError> { Ok(state .db .user_passkeys(user_id) .await? .into_iter() .filter_map(|r| match serde_json::from_str::(&r.passkey) { Ok(k) => Some((r.id, k)), Err(e) => { tracing::error!(passkey_id = r.id, error = %e, "stored passkey is unreadable"); None } }) .collect()) } // --------------------------------------------------------------------------- // Password // --------------------------------------------------------------------------- /// POST `{AUTH_PASSWORD}` — set or change the password. pub async fn change_password( State(state): State>, SessionUser { user, .. }: SessionUser, headers: HeaderMap, Json(body): Json, ) -> Result, ApiError> { validate_password(&body.new_password)?; let hash = hash_password(&body.new_password).await?; state .db .set_password_keeping_sessions(user.id, &hash) .await?; invalidate_elsewhere(&state, user.id, &headers).await?; Ok(Json(OkResp {})) } /// DELETE `{AUTH_PASSWORD}` — leave the account on passkeys alone. pub async fn delete_password( State(state): State>, SessionUser { user, .. }: SessionUser, headers: HeaderMap, ) -> Result, ApiError> { if !user.has_password { return Ok(Json(OkResp {})); } // The account must keep at least one way in, and `Both` needs a password // by definition. if state.db.count_passkeys(user.id).await? == 0 { return Err(ApiError::localized( StatusCode::BAD_REQUEST, "add a passkey before removing your password", "err_password_last_credential", )); } if user.auth_mode == AuthMode::Both { return Err(ApiError::localized( StatusCode::BAD_REQUEST, "this account requires a password and a passkey", "err_required_by_mode", )); } if !state.db.clear_user_password(user.id).await? { return Err(locked_out()); } invalidate_elsewhere(&state, user.id, &headers).await?; Ok(Json(OkResp {})) } // --------------------------------------------------------------------------- // Sign-in requirement // --------------------------------------------------------------------------- /// PUT `{AUTH_MODE}`. pub async fn set_mode( State(state): State>, SessionUser { user, .. }: SessionUser, headers: HeaderMap, Json(body): Json, ) -> Result, ApiError> { if body.mode == AuthMode::Both { if !user.has_password { return Err(ApiError::localized( StatusCode::BAD_REQUEST, "set a password before requiring both", "err_mode_needs_password", )); } if state.db.count_passkeys(user.id).await? == 0 { return Err(ApiError::localized( StatusCode::BAD_REQUEST, "add a passkey before requiring both", "err_mode_needs_passkey", )); } } if !state.db.set_user_auth_mode(user.id, body.mode).await? { return Err(locked_out()); } // WebDAV speaks HTTP Basic, which carries a password and nothing else. An // account that requires both can no longer mount with its account // password, so any cached Basic credential has to go. Its app passwords // are unaffected and keep working. invalidate_elsewhere(&state, user.id, &headers).await?; Ok(Json(OkResp {})) } // --------------------------------------------------------------------------- // Managing passkeys // --------------------------------------------------------------------------- /// GET `{AUTH_PASSKEYS}`. pub async fn list( State(state): State>, SessionUser { user, .. }: SessionUser, ) -> Result>, ApiError> { let rows = state.db.user_passkeys(user.id).await?; Ok(Json(rows.iter().map(info).collect())) } /// DELETE `{AUTH_PASSKEYS}/{id}`. pub async fn delete( State(state): State>, SessionUser { user, .. }: SessionUser, headers: HeaderMap, AxumPath(id): AxumPath, ) -> Result, ApiError> { // The database decides, inside one transaction, whether the account would // still have a way in. Asking it first means an id that does not exist is // a plain 404, not a complaint about a rule it never reached. match state.db.delete_passkey(id, user.id).await? { PasskeyDeleted::Gone => {} PasskeyDeleted::NotFound => { return Err(ApiError::localized( StatusCode::NOT_FOUND, "no such passkey", "err_passkey_not_found", )); } // Say which of the two rules stopped it. PasskeyDeleted::LastCredential if user.auth_mode == AuthMode::Both => { return Err(ApiError::localized( StatusCode::BAD_REQUEST, "this account requires a password and a passkey", "err_required_by_mode", )); } PasskeyDeleted::LastCredential => { return Err(ApiError::localized( StatusCode::BAD_REQUEST, "set a password before removing your last passkey", "err_passkey_last_credential", )); } } invalidate_elsewhere(&state, user.id, &headers).await?; Ok(Json(OkResp {})) } /// POST `{AUTH_PASSKEYS_REGISTER}` — first leg of registration. pub async fn register_begin( State(state): State>, SessionUser { user, .. }: SessionUser, Rp(rp): Rp, ) -> Result, ApiError> { // Checked again inside `add_passkey`, which is where it actually holds. // This one only spares the user a ceremony that could not be stored. if state.db.count_passkeys(user.id).await? as usize >= PASSKEY_LIMIT { return Err(too_many_passkeys()); } let wid = state.db.user_webauthn_id(user.id).await?; // Excluding what is already registered makes the authenticator refuse a // second credential for this account, instead of silently creating one // the user then has to tell apart from the first. let existing: Vec = load_passkeys(&state, user.id) .await? .iter() .map(|(_, k)| k.cred_id().clone()) .collect(); let (mut options, reg) = rp .start_passkey_registration(wid, &user.name, &user.name, Some(existing)) .map_err(webauthn_failed)?; require_discoverable(&mut options); Ok(Json(challenge( Pending::Register { user_id: user.id, state: Box::new(reg), }, &options, )?)) } /// POST `{AUTH_PASSKEYS_REGISTER}{FINISH_SUFFIX}`. pub async fn register_finish( State(state): State>, SessionUser { user, .. }: SessionUser, Rp(rp): Rp, headers: HeaderMap, Json(body): Json, ) -> Result, ApiError> { let Some(Pending::Register { user_id, state: reg, }) = crate::webauthn::take(&body.state_id) else { return Err(challenge_expired()); }; // The handle is opaque and single-use, so this can only be a client that // mixed two ceremonies up. Refuse rather than register to the wrong // account. if user_id != user.id { return Err(challenge_expired()); } let cred: RegisterPublicKeyCredential = serde_json::from_str(&body.credential).map_err(|_| bad_credential())?; let passkey = rp .finish_passkey_registration(&cred, ®) .map_err(webauthn_failed)?; let encoded = serde_json::to_string(&passkey).map_err(|e| { ApiError::new( StatusCode::INTERNAL_SERVER_ERROR, format!("cannot store passkey: {e}"), ) })?; let Some(row) = state .db .add_passkey( user.id, passkey.cred_id().as_ref(), &encoded, &credential_label(&body.name, "Passkey"), ) .await .map_err(|e| match e { // `passkeys.cred_id` is UNIQUE across the whole table, so this // also fires when the credential belongs to another account. rusqlite::Error::SqliteFailure(f, _) if f.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE => { ApiError::localized( StatusCode::CONFLICT, "that passkey is already registered", "err_passkey_duplicate", ) } other => other.into(), })? else { return Err(too_many_passkeys()); }; invalidate_elsewhere(&state, user.id, &headers).await?; Ok(Json(info(&row))) } // --------------------------------------------------------------------------- // Signing in with a passkey // --------------------------------------------------------------------------- /// POST `{AUTH_PASSKEY_LOGIN}` — first leg of a passkey sign-in. /// /// The challenge lists no credentials, so any passkey the browser holds for /// this site can answer it, and it says nothing about which accounts exist. pub async fn login_begin( Rp(rp): Rp, Json(body): Json, ) -> Result, ApiError> { let (mut options, disc) = rp .start_discoverable_authentication() .map_err(webauthn_failed)?; // `start_discoverable_authentication` always asks for conditional // mediation, which parks the request in the autofill dropdown. The button // wants the modal picker instead. if !body.conditional { options.mediation = None; } Ok(Json(challenge( Pending::Discoverable(Box::new(disc)), &options, )?)) } /// POST `{AUTH_PASSKEY_LOGIN}{FINISH_SUFFIX}`. /// /// Either signs the user in, or — for an account that needs both factors and /// started with the passkey — asks for the password next. pub async fn login_finish( State(state): State>, Rp(rp): Rp, Json(body): Json, ) -> Result { let Some(pending) = crate::webauthn::take(&body.state_id) else { return Err(challenge_expired()); }; let cred: PublicKeyCredential = serde_json::from_str(&body.credential).map_err(|_| bad_credential())?; let (user_id, second_factor, result) = match pending { Pending::SecondFactor { user_id, state: auth_state, } => { let res = rp .finish_passkey_authentication(&cred, &auth_state) .map_err(webauthn_failed)?; (user_id, true, res) } Pending::Discoverable(disc) => { // The user handle comes from the credential, so it is only a // claim until `finish_discoverable_authentication` checks the // signature against that account's own keys below. let (wid, _) = rp .identify_discoverable_authentication(&cred) .map_err(webauthn_failed)?; let user = state .db .find_user_by_webauthn_id(&wid) .await? .filter(|u| u.active) .ok_or_else(|| webauthn_failed(WebauthnError::CredentialNotFound))?; let keys: Vec = load_passkeys(&state, user.id) .await? .iter() .map(|(_, k)| k.into()) .collect(); let res = rp .finish_discoverable_authentication(&cred, *disc, &keys) .map_err(webauthn_failed)?; (user.id, false, res) } // Any other handle names a different ceremony. Refusing keeps a // registration challenge from being answered as a sign-in. _ => return Err(challenge_expired()), }; record_use(&state, user_id, &result).await?; let user = state .db .find_user_by_id(user_id) .await? .filter(|u| u.active) .ok_or_else(|| webauthn_failed(WebauthnError::CredentialNotFound))?; if user.auth_mode == AuthMode::Both && !second_factor { let state_id = crate::webauthn::put(Pending::NeedsPassword { user_id }); return Ok(Json(LoginResp { ok: false, password_required: Some(PasswordStep { name: user.name, state_id, }), ..Default::default() }) .into_response()); } sign_in(&state, user_id).await } /// Persist what the assertion changed: the signature counter and backup /// flags move, and the settings list shows when a passkey was last used. async fn record_use( state: &AppState, user_id: i64, result: &AuthenticationResult, ) -> Result<(), ApiError> { let Some((id, mut key)) = load_passkeys(state, user_id) .await? .into_iter() .find(|(_, k)| k.cred_id() == result.cred_id()) else { return Ok(()); }; key.update_credential(result); let encoded = serde_json::to_string(&key).unwrap_or_default(); if !encoded.is_empty() { state.db.passkey_used(id, &encoded).await?; } Ok(()) } /// Create the session and send its cookie. pub(crate) async fn sign_in(state: &AppState, user_id: i64) -> Result { let token = auth::random_token(); state.db.create_session(user_id, &token).await?; Ok(( [(header::SET_COOKIE, session_cookie(&token, state.https()))], Json(LoginResp { ok: true, ..Default::default() }), ) .into_response()) } /// Require the authenticator to store the credential itself. /// /// Sign-in only issues discoverable challenges, so a credential the /// authenticator does not store could never sign in. `start_passkey_registration` /// sends `residentKey: "discouraged"`, and the passkey API has no builder /// switch for it, hence the patch. With `required` the browser refuses an /// authenticator that cannot store the credential, such as a U2F-only key. fn require_discoverable(options: &mut CreationChallengeResponse) { match options.public_key.authenticator_selection.as_mut() { Some(sel) => { sel.resident_key = Some(ResidentKeyRequirement::Required); // Browsers that predate `residentKey` read only this flag. sel.require_resident_key = true; } // `webauthn-rs` always sends this block today. If a future version // stops, new passkeys may not be discoverable and could not sign in. None => tracing::warn!("no authenticatorSelection to ask for a discoverable credential"), } } /// Park a ceremony's state and pair its handle with the browser's options. pub(crate) fn challenge( pending: Pending, options: &T, ) -> Result { let options = serde_json::to_string(options).map_err(|e| { ApiError::new( StatusCode::INTERNAL_SERVER_ERROR, format!("cannot encode the challenge: {e}"), ) })?; Ok(PasskeyChallenge { state_id: crate::webauthn::put(pending), options, }) }