//! WebAuthn ceremonies with server-side, expiring, one-use challenges. //! Existing Laragear encrypted PEM credentials are imported without reenrolment. use crate::{ApiError, AppState, auth}; use axum::{ Extension, Json, Router, extract::{Path, State}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, routing::{get, patch, post}, }; use base64::{ Engine, engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, }; use serde_json::{Value, json}; use sqlx::{Row, SqlitePool, sqlite::SqliteRow}; use std::time::Duration; use webauthn_rs::prelude::*; use webauthn_rs_core::proto::{ COSEAlgorithm, COSEEC2Key, COSEKey, COSEKeyType, COSEOKPKey, COSERSAKey, CredentialV3, ECDSACurve, EDDSACurve, UserVerificationPolicy, }; type ResultResponse = Result; const MODEL: &str = "App\\Models\\User"; fn invalid() -> ApiError { ApiError( StatusCode::UNPROCESSABLE_ENTITY, "The given data was invalid.", ) } fn denied() -> ApiError { ApiError(StatusCode::UNAUTHORIZED, "unauthorized") } fn broken() -> ApiError { ApiError( StatusCode::SERVICE_UNAVAILABLE, "WebAuthn configuration is unavailable", ) } fn encoded(bytes: impl AsRef<[u8]>) -> String { URL_SAFE_NO_PAD.encode(bytes) } fn decoded(s: &str) -> Result, ApiError> { URL_SAFE_NO_PAD .decode(s.trim_end_matches('=')) .or_else(|_| STANDARD.decode(s)) .map_err(|_| invalid()) } pub async fn init(db: &SqlitePool) -> Result<(), sqlx::Error> { sqlx::raw_sql("CREATE TABLE IF NOT EXISTS rust_webauthn_challenges(session_hash TEXT PRIMARY KEY, user_id INTEGER NOT NULL, kind TEXT NOT NULL, payload TEXT NOT NULL, expires_at INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS rust_webauthn_metadata(credential_id TEXT PRIMARY KEY REFERENCES webauthn_credentials(id) ON DELETE CASCADE,credential TEXT NOT NULL);").execute(db).await?; Ok(()) } pub fn routes(_state: AppState) -> Router { Router::new() .route("/webauthn/register/options", post(register_options)) .route("/webauthn/register", post(register)) .route("/webauthn/login/options", post(login_options)) .route("/webauthn/login", post(login)) .route("/webauthn/credentials", get(list)) .route("/webauthn/credentials/{id}/name", patch(rename)) .route("/webauthn/credentials/{id}", axum::routing::delete(delete)) .route("/webauthn/lost", post(lost)) .route("/webauthn/recover", post(recover)) } fn required() -> bool { std::env::var("WEBAUTHN_USER_VERIFICATION").as_deref() == Ok("required") } fn webauthn() -> Result { let origin = Url::parse(&std::env::var("APP_URL").unwrap_or_else(|_| "http://localhost".into())) .map_err(|_| broken())?; let rp = std::env::var("WEBAUTHN_ID") .ok() .filter(|s| !s.is_empty()) .unwrap_or_else(|| origin.host_str().unwrap_or("localhost").into()); let rp = Url::parse(&rp) .ok() .and_then(|u| u.host_str().map(str::to_owned)) .unwrap_or(rp); let name = std::env::var("WEBAUTHN_NAME") .or_else(|_| std::env::var("APP_NAME")) .unwrap_or_else(|_| "2FAuth".into()); let mut builder = WebauthnBuilder::new(&rp, &origin) .map_err(|_| broken())? .rp_name(&name) .timeout(Duration::from_secs(60)); for origin in std::env::var("WEBAUTHN_ORIGINS") .unwrap_or_default() .split(',') .filter(|s| !s.trim().is_empty()) { builder = builder.append_allowed_origin(&Url::parse(origin.trim()).map_err(|_| broken())?); } if std::env::var("WEBAUTHN_USER_VERIFICATION").as_deref() == Ok("discouraged") { builder = builder.danger_set_user_presence_only_security_keys(true); } builder.build().map_err(|_| broken()) } async fn session(s: &AppState, h: &HeaderMap, mutation: bool) -> Result { let row = auth::session(&s.db, h).await?.ok_or(ApiError( StatusCode::from_u16(419).unwrap(), "Session expired", ))?; if mutation && !auth::csrf_valid(h, row.try_get("csrf_hash")?) { return Err(ApiError( StatusCode::from_u16(419).unwrap(), "CSRF token mismatch", )); } Ok(row) } async fn manage(s: &AppState, h: &HeaderMap, mutation: bool) -> Result { if std::env::var("AUTH_GUARD") .or_else(|_| std::env::var("AUTHENTICATION_GUARD")) .as_deref() == Ok("reverse-proxy-guard") { return Err(ApiError(StatusCode::FORBIDDEN, "Forbidden")); } let row = session(s, h, mutation).await?; let Some(id) = row.try_get::, _>("user_id")? else { return Err(denied()); }; let user = sqlx::query("SELECT * FROM users WHERE id=?") .bind(id) .fetch_optional(&s.db) .await? .ok_or_else(denied)?; if !user.try_get::("is_admin")? && crate::settings::all(&s.db).await?["useSsoOnly"] == true { return Err(ApiError( StatusCode::FORBIDDEN, "This authentication method is for administrators only. Users must log in with SSO.", )); } Ok(user) } async fn save_challenge( s: &AppState, h: &HeaderMap, user: i64, kind: &str, payload: Value, ) -> Result<(), ApiError> { let session = session(s, h, true).await?; sqlx::query("DELETE FROM rust_webauthn_challenges WHERE expires_at("token_hash")?).bind(user).bind(kind).bind(payload.to_string()).bind(auth::now()+60).execute(&s.db).await?; Ok(()) } async fn take_challenge(s: &AppState, h: &HeaderMap, kind: &str) -> Result<(i64, Value), ApiError> { let session = session(s, h, true).await?; let row=sqlx::query("DELETE FROM rust_webauthn_challenges WHERE session_hash=? AND kind=? AND expires_at>? RETURNING user_id,payload") .bind(session.try_get::("token_hash")?).bind(kind).bind(auth::now()).fetch_optional(&s.db).await?.ok_or_else(denied)?; Ok(( row.try_get("user_id")?, serde_json::from_str(row.try_get("payload")?).map_err(|_| denied())?, )) } fn pem_key(pem: &str) -> Result { use openssl::{ bn::{BigNum, BigNumContext}, nid::Nid, pkey::{Id, PKey}, }; let key = PKey::public_key_from_pem(pem.as_bytes()).map_err(|_| broken())?; Ok(match key.id() { Id::EC => { let ec = key.ec_key().map_err(|_| broken())?; let (curve, algorithm, length) = match ec.group().curve_name() { Some(Nid::X9_62_PRIME256V1) => (ECDSACurve::SECP256R1, COSEAlgorithm::ES256, 32), Some(Nid::SECP384R1) => (ECDSACurve::SECP384R1, COSEAlgorithm::ES384, 48), Some(Nid::SECP521R1) => (ECDSACurve::SECP521R1, COSEAlgorithm::ES512, 66), _ => return Err(broken()), }; let mut x = BigNum::new().map_err(|_| broken())?; let mut y = BigNum::new().map_err(|_| broken())?; let mut ctx = BigNumContext::new().map_err(|_| broken())?; ec.public_key() .affine_coordinates_gfp(ec.group(), &mut x, &mut y, &mut ctx) .map_err(|_| broken())?; COSEKey { type_: algorithm, key: COSEKeyType::EC_EC2(COSEEC2Key { curve, x: x.to_vec_padded(length).map_err(|_| broken())?.into(), y: y.to_vec_padded(length).map_err(|_| broken())?.into(), }), } } Id::RSA => { let rsa = key.rsa().map_err(|_| broken())?; let e: [u8; 3] = rsa.e().to_vec().try_into().map_err(|_| broken())?; COSEKey { type_: COSEAlgorithm::RS256, key: COSEKeyType::RSA(COSERSAKey { n: rsa.n().to_vec().into(), e, }), } } Id::ED25519 => COSEKey { type_: COSEAlgorithm::EDDSA, key: COSEKeyType::EC_OKP(COSEOKPKey { curve: EDDSACurve::ED25519, x: key.raw_public_key().map_err(|_| broken())?.into(), }), }, _ => return Err(broken()), }) } async fn credential(s: &AppState, row: &SqliteRow) -> Result { let id: String = row.try_get("id")?; let stored: String = row.try_get("public_key")?; // Laragear's encrypted cast is unconditional, regardless of the account encryption switch. let pem = s .cipher .as_ref() .ok_or_else(broken)? .decrypt(&stored) .map_err(|_| broken())?; let key = pem_key(&pem)?; let counter = row.try_get::, _>("counter")?.unwrap_or(0); let mut cred: Credential = CredentialV3 { cred_id: decoded(&id)?, cred: key.clone(), counter: counter.try_into().map_err(|_| broken())?, verified: false, registration_policy: UserVerificationPolicy::Preferred, } .into(); if let Some(raw) = sqlx::query_scalar::<_, String>( "SELECT credential FROM rust_webauthn_metadata WHERE credential_id=?", ) .bind(&id) .fetch_optional(&s.db) .await? { cred = serde_json::from_str(&raw).map_err(|_| broken())?; // The Laravel table remains authoritative for key, counter and status. cred.cred = key; cred.counter = counter.try_into().map_err(|_| broken())?; } // Laragear does not bind future assertions to the registration's UV flag. // Enforce the configured ceremony policy separately rather than requiring UV // just because this authenticator happened to verify a user during enrollment. cred.user_verified = false; cred.registration_policy = UserVerificationPolicy::Preferred; cred.transports = row .try_get::, _>("transports")? .and_then(|s| serde_json::from_str(&s).ok()); Ok(cred) } async fn rows(s: &AppState, user: i64) -> Result, ApiError> { Ok(sqlx::query("SELECT * FROM webauthn_credentials WHERE authenticatable_id=? AND authenticatable_type=? AND disabled_at IS NULL ORDER BY created_at,id").bind(user).bind(MODEL).fetch_all(&s.db).await?) } fn options_value(value: T) -> Result { let mut v = serde_json::to_value(value).map_err(|_| broken())?["publicKey"].take(); if required() { if v.get("authenticatorSelection").is_some() { v["authenticatorSelection"]["userVerification"] = json!("required"); } else { v["userVerification"] = json!("required"); } } Ok(v) } async fn register_options(State(s): State, h: HeaderMap) -> ResultResponse { let user = manage(&s, &h, true).await?; let id: i64 = user.try_get("id")?; let existing = rows(&s, id).await?; let handle = existing .first() .and_then(|r| r.try_get::("user_id").ok()) .and_then(|s| Uuid::parse_str(&s).ok()) .unwrap_or_else(Uuid::new_v4); let exclude = existing .iter() .map(|row| decoded(&row.get::("id")).map(Into::into)) .collect::, _>>()?; let (options, state) = webauthn()? .start_securitykey_registration( handle, user.try_get("email")?, user.try_get("name")?, Some(exclude), None, None, ) .map_err(|_| invalid())?; let mut options = options_value(options)?; let mut state = serde_json::to_value(state).map_err(|_| broken())?; // Laragear offers Ed25519 alongside ES256 and RS256. The library supports // EdDSA verification but excludes it from its default advertised list. state["rs"]["credential_algorithms"] .as_array_mut() .ok_or_else(broken)? .push(json!("EDDSA")); options["pubKeyCredParams"] .as_array_mut() .ok_or_else(broken)? .push(json!({"type":"public-key","alg":-8})); save_challenge( &s, &h, id, "register", json!({"state":state,"handle":handle,"required":required()}), ) .await?; Ok(Json(options).into_response()) } async fn register(State(s): State, h: HeaderMap, Json(v): Json) -> ResultResponse { let user = manage(&s, &h, true).await?; let (id, payload) = take_challenge(&s, &h, "register").await?; if id != user.try_get::("id")? { return Err(denied()); } let response: RegisterPublicKeyCredential = serde_json::from_value(v.clone()).map_err(|_| invalid())?; let state: SecurityKeyRegistration = serde_json::from_value(payload["state"].clone()).map_err(|_| denied())?; let key = webauthn()? .finish_securitykey_registration(&response, &state) .map_err(|_| invalid())?; let cred: Credential = key.into(); if decoded(&response.id)?.as_slice() != cred.cred_id.as_slice() || response.raw_id.as_slice() != cred.cred_id.as_slice() { return Err(invalid()); } if payload["required"] == true && !cred.user_verified { return Err(invalid()); } let pem = cred .cred .get_openssl_pkey() .map_err(|_| invalid())? .public_key_to_pem() .map_err(|_| broken())?; let pem = String::from_utf8(pem).map_err(|_| broken())?; let encrypted = s .cipher .as_ref() .ok_or_else(broken)? .encrypt(&pem) .map_err(|_| broken())?; let client: Value = serde_json::from_slice(&decoded( v["response"]["clientDataJSON"] .as_str() .ok_or_else(invalid)?, )?) .map_err(|_| invalid())?; let origin = client["origin"].as_str().ok_or_else(invalid)?; let rp = std::env::var("WEBAUTHN_ID") .ok() .filter(|s| !s.is_empty()) .unwrap_or_else(|| { Url::parse(origin) .ok() .and_then(|u| u.host_str().map(str::to_owned)) .unwrap_or_default() }); let raw_attestation = decoded( v["response"]["attestationObject"] .as_str() .ok_or_else(invalid)?, )?; let attestation: serde_cbor_2::Value = serde_cbor_2::from_slice(&raw_attestation).map_err(|_| invalid())?; let mut aaguid = None; if let serde_cbor_2::Value::Map(map) = attestation && let Some(serde_cbor_2::Value::Bytes(data)) = map.get(&serde_cbor_2::Value::Text("authData".into())) { aaguid = data .get(37..53) .and_then(|b| Uuid::from_slice(b).ok()) .map(|u| u.to_string()); } let credential_id = encoded(&cred.cred_id); let mut tx = s.db.begin().await?; let duplicate: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM webauthn_credentials WHERE id=?)") .bind(&credential_id) .fetch_one(&mut *tx) .await?; if duplicate { return Err(invalid()); } sqlx::query("INSERT INTO webauthn_credentials(id,authenticatable_type,authenticatable_id,user_id,alias,counter,rp_id,origin,transports,aaguid,public_key,attestation_format,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)") .bind(&credential_id).bind(MODEL).bind(id).bind(payload["handle"].as_str().ok_or_else(denied)?).bind(v["response"]["alias"].as_str()).bind(cred.counter as i64).bind(rp).bind(origin).bind(cred.transports.as_ref().map(|t|serde_json::to_string(t).unwrap())).bind(aaguid).bind(encrypted).bind(serde_json::to_value(&cred.attestation_format).map_err(|_|broken())?.as_str().unwrap_or("none")).execute(&mut *tx).await?; sqlx::query("INSERT INTO rust_webauthn_metadata(credential_id,credential) VALUES(?,?)") .bind(&credential_id) .bind(serde_json::to_string(&cred).map_err(|_| broken())?) .execute(&mut *tx) .await?; tx.commit().await?; Ok(StatusCode::NO_CONTENT.into_response()) } async fn login_options( State(s): State, h: HeaderMap, Json(v): Json, ) -> ResultResponse { session(&s, &h, true).await?; let email = v["email"] .as_str() .filter(|s| s.contains('@')) .ok_or_else(invalid)?; let user = sqlx::query("SELECT id,is_admin FROM users WHERE LOWER(email)=LOWER(?)") .bind(email) .fetch_optional(&s.db) .await? .ok_or_else(invalid)?; let id: i64 = user.try_get("id")?; if crate::settings::all(&s.db).await?["useSsoOnly"] == true && !user.try_get::("is_admin")? { return Err(ApiError( StatusCode::FORBIDDEN, "This authentication method is for administrators only. Users must log in with SSO.", )); } let mut credentials = vec![]; for row in rows(&s, id).await? { credentials.push(SecurityKey::from(credential(&s, &row).await?)); } let (options, state) = webauthn()? .start_securitykey_authentication(&credentials) .map_err(|_| invalid())?; let mut state = serde_json::to_value(state).map_err(|_| broken())?; // Laragear does not persist backup eligibility. Permit the same one-way eligibility // upgrade as the library's passkey API, while preserving all signature/UV checks. state["ast"]["allow_backup_eligible_upgrade"] = json!(true); save_challenge( &s, &h, id, "login", json!({"state":state,"required":required()}), ) .await?; Ok(Json(options_value(options)?).into_response()) } async fn login( State(s): State, h: HeaderMap, peer: Option>>, Json(v): Json, ) -> ResultResponse { let context = crate::auth_audit::Context::from_headers( &h, peer.map(|Extension(axum::extract::ConnectInfo(peer))| peer), ); let old = session(&s, &h, true).await?; let (id, payload) = take_challenge(&s, &h, "login").await?; if !auth::throttle_limit(&s.db, format!("webauthn-login:{id}"), 10).await? { return Err(ApiError( StatusCode::TOO_MANY_REQUESTS, "Too Many Attempts.", )); } let user = sqlx::query("SELECT * FROM users WHERE id=? AND LOWER(email)=LOWER(?)") .bind(id) .bind(v["email"].as_str().ok_or_else(invalid)?) .fetch_optional(&s.db) .await? .ok_or_else(denied)?; let admin: bool = user.try_get("is_admin")?; let settings = crate::settings::regular_settings(&s.db).await?; if settings["useSsoOnly"] == true && !admin { return Err(ApiError( StatusCode::FORBIDDEN, "This authentication method is for administrators only. Users must log in with SSO.", )); } let response: PublicKeyCredential = serde_json::from_value(v.clone()).map_err(|_| invalid())?; let credential_id = encoded(decoded(v["id"].as_str().ok_or_else(invalid)?)?); if response.raw_id.as_slice() != decoded(&credential_id)?.as_slice() { return Err(invalid()); } let row=sqlx::query("SELECT * FROM webauthn_credentials WHERE id=? AND authenticatable_id=? AND authenticatable_type=? AND disabled_at IS NULL").bind(&credential_id).bind(id).bind(MODEL).fetch_optional(&s.db).await?.ok_or_else(denied)?; if let Some(handle) = v["response"]["userHandle"] .as_str() .filter(|s| !s.is_empty()) { let uuid = Uuid::parse_str(row.try_get("user_id")?).map_err(|_| denied())?; if decoded(handle)?.as_slice() != uuid.as_bytes() { crate::auth_audit::failed(&s, id, &context, Some("webauthn")).await?; return Err(denied()); } } let state: SecurityKeyAuthentication = serde_json::from_value(payload["state"].clone()).map_err(|_| denied())?; let result = match webauthn()?.finish_securitykey_authentication(&response, &state) { Ok(result) => result, Err(_) => { crate::auth_audit::failed(&s, id, &context, Some("webauthn")).await?; return Err(denied()); } }; if payload["required"] == true && !result.user_verified() { crate::auth_audit::failed(&s, id, &context, Some("webauthn")).await?; return Err(denied()); } let counter: i64 = row.try_get::, _>("counter")?.unwrap_or(0); if (counter > 0 || result.counter() > 0) && result.counter() as i64 <= counter { crate::auth_audit::failed(&s, id, &context, Some("webauthn")).await?; return Err(denied()); } let mut updated_credential = credential(&s, &row).await?; updated_credential.counter = result.counter(); updated_credential.backup_eligible = result.backup_eligible(); updated_credential.backup_state = result.backup_state(); let sid = auth::random(); let csrf = auth::random(); let mut tx = s.db.begin().await?; let updated=sqlx::query("UPDATE webauthn_credentials SET counter=?,updated_at=CURRENT_TIMESTAMP WHERE id=? AND disabled_at IS NULL AND COALESCE(counter,0)=?").bind(result.counter() as i64).bind(&credential_id).bind(counter).execute(&mut *tx).await?; if updated.rows_affected() != 1 { return Err(denied()); } sqlx::query("INSERT INTO rust_webauthn_metadata(credential_id,credential) VALUES(?,?) ON CONFLICT(credential_id) DO UPDATE SET credential=excluded.credential").bind(&credential_id).bind(serde_json::to_string(&updated_credential).map_err(|_|broken())?).execute(&mut *tx).await?; let consumed = sqlx::query("DELETE FROM rust_sessions WHERE token_hash=? AND expires_at>?") .bind(old.try_get::("token_hash")?) .bind(auth::now()) .execute(&mut *tx) .await?; if consumed.rows_affected() != 1 { return Err(denied()); } sqlx::query( "INSERT INTO rust_sessions(token_hash,user_id,csrf_hash,expires_at) VALUES(?,?,?,?)", ) .bind(auth::digest(&sid)) .bind(id) .bind(auth::digest(&csrf)) .bind(auth::now() + auth::TTL) .execute(&mut *tx) .await?; sqlx::query("UPDATE users SET last_seen_at=CURRENT_TIMESTAMP WHERE id=?") .bind(id) .execute(&mut *tx) .await?; tx.commit().await?; crate::auth_audit::login(&s, id, &context, Some("webauthn")).await?; let preferences = crate::settings::preferences_from( user.try_get::, _>("preferences")?.as_deref(), ); let mut response=Json(json!({"message":"authenticated","id":id,"name":user.try_get::("name")?,"email":user.try_get::("email")?,"oauth_provider":user.try_get::,_>("oauth_provider")?,"is_admin":admin,"preferences":preferences,"appSettings":settings})).into_response(); auth::cookies(&mut response, &sid, &csrf, auth::TTL); auth::clear_legacy_cookie(&mut response); Ok(response) } async fn list(State(s): State, h: HeaderMap) -> ResultResponse { let user = manage(&s, &h, false).await?; let mut values = vec![]; for row in rows(&s, user.try_get("id")?).await? { values.push(json!({"id":row.try_get::("id")?,"origin":row.try_get::("origin")?,"alias":row.try_get::,_>("alias")?,"aaguid":row.try_get::,_>("aaguid")?,"attestation_format":row.try_get::("attestation_format")?,"disabled_at":null})); } Ok(Json(json!(values)).into_response()) } async fn rename( State(s): State, h: HeaderMap, Path(id): Path, Json(v): Json, ) -> ResultResponse { let user = manage(&s, &h, true).await?; let name = v["name"] .as_str() .filter(|s| !s.trim().is_empty()) .ok_or_else(invalid)?; let n=sqlx::query("UPDATE webauthn_credentials SET alias=?,updated_at=CURRENT_TIMESTAMP WHERE id=? AND authenticatable_id=? AND authenticatable_type=?").bind(name).bind(id).bind(user.try_get::("id")?).bind(MODEL).execute(&s.db).await?; if n.rows_affected() == 0 { return Err(ApiError(StatusCode::NOT_FOUND, "Not Found")); } Ok(Json(json!({"name":name})).into_response()) } async fn delete(State(s): State, h: HeaderMap, Path(id): Path) -> ResultResponse { let user = manage(&s, &h, true).await?; let user_id: i64 = user.try_get("id")?; let mut tx = s.db.begin().await?; sqlx::query("DELETE FROM webauthn_credentials WHERE id=? AND authenticatable_id=? AND authenticatable_type=?").bind(id).bind(user_id).bind(MODEL).execute(&mut *tx).await?; let remains:bool=sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM webauthn_credentials WHERE authenticatable_id=? AND authenticatable_type=? AND disabled_at IS NULL)").bind(user_id).bind(MODEL).fetch_one(&mut *tx).await?; if !remains { let mut prefs = crate::settings::preferences_from( user.try_get::, _>("preferences")?.as_deref(), ); prefs["useWebauthnOnly"] = json!(false); sqlx::query("UPDATE users SET preferences=?,updated_at=CURRENT_TIMESTAMP WHERE id=?") .bind(prefs.to_string()) .bind(user_id) .execute(&mut *tx) .await?; } tx.commit().await?; Ok(StatusCode::NO_CONTENT.into_response()) } async fn recovery_user(s: &AppState, v: &Value) -> Result { if std::env::var("IS_DEMO_APP").is_ok_and(|v| matches!(v.as_str(), "true" | "1")) { return Err(ApiError(StatusCode::FORBIDDEN, "Forbidden")); } let email = v["email"] .as_str() .filter(|s| s.contains('@')) .ok_or_else(invalid)?; let user = sqlx::query("SELECT * FROM users WHERE LOWER(email)=LOWER(?)") .bind(email) .fetch_optional(&s.db) .await? .ok_or_else(invalid)?; if crate::settings::all(&s.db).await?["useSsoOnly"] == true && !user.try_get::("is_admin")? { return Err(ApiError( StatusCode::FORBIDDEN, "This authentication method is for administrators only. Users must log in with SSO.", )); } Ok(user) } async fn lost(State(s): State, h: HeaderMap, Json(v): Json) -> ResultResponse { session(&s, &h, true).await?; let user = recovery_user(&s, &v).await?; let email: String = user.try_get("email")?; let token = auth::random(); let value = token.clone(); let hash = tokio::task::spawn_blocking(move || bcrypt::hash(value, 12)) .await .map_err(|_| broken())? .map_err(|_| broken())?; let mut url = Url::parse(&format!( "{}/webauthn/recover", std::env::var("APP_URL") .unwrap_or_else(|_| "http://localhost".into()) .trim_end_matches('/') )) .map_err(|_| broken())?; url.query_pairs_mut() .append_pair("email", &email) .append_pair("token", &token); let mut tx = s.db.begin().await?; sqlx::query("UPDATE webauthn_recoveries SET token=token WHERE email=?") .bind(&email) .execute(&mut *tx) .await?; let recent:bool=sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM webauthn_recoveries WHERE email=? AND created_at>datetime('now','-60 seconds'))").bind(&email).fetch_one(&mut *tx).await?; if recent { return Err(ApiError( StatusCode::TOO_MANY_REQUESTS, "Please wait before retrying.", )); } sqlx::query("DELETE FROM webauthn_recoveries WHERE email=?") .bind(&email) .execute(&mut *tx) .await?; sqlx::query( "INSERT INTO webauthn_recoveries(email,token,created_at) VALUES(?,?,CURRENT_TIMESTAMP)", ) .bind(&email) .bind(hash) .execute(&mut *tx) .await?; crate::mail::webauthn_recovery(&email, url.as_str()) .await .map_err(|_| ApiError(StatusCode::SERVICE_UNAVAILABLE, "Mail delivery failed"))?; tx.commit().await?; Ok(Json(json!({"message":"Account recovery email sent!"})).into_response()) } async fn recover( State(s): State, h: HeaderMap, peer: Option>>, Json(v): Json, ) -> ResultResponse { let context = crate::auth_audit::Context::from_headers( &h, peer.map(|Extension(axum::extract::ConnectInfo(peer))| peer), ); let old = session(&s, &h, true).await?; let user = recovery_user(&s, &v).await?; let email: String = user.try_get("email")?; let id: i64 = user.try_get("id")?; if !auth::throttle_limit(&s.db, format!("webauthn-recover:{id}"), 10).await? { return Err(ApiError( StatusCode::TOO_MANY_REQUESTS, "Too Many Attempts.", )); } let stored:Option=sqlx::query_scalar("SELECT token FROM webauthn_recoveries WHERE email=? AND created_at>datetime('now','-60 minutes')").bind(&email).fetch_optional(&s.db).await?; let stored = stored.ok_or_else(invalid)?; let token = v["token"].as_str().ok_or_else(invalid)?.to_owned(); let hash = stored.clone(); let valid = tokio::task::spawn_blocking(move || auth::verify(token, hash)) .await .unwrap_or(false); if !valid { return Err(invalid()); } let password = v["password"].as_str().ok_or_else(invalid)?.to_owned(); let hash: String = user.try_get("password")?; if !tokio::task::spawn_blocking(move || auth::verify(password, hash)) .await .unwrap_or(false) { crate::auth_audit::failed(&s, id, &context, Some("password")).await?; return Err(denied()); } let mut prefs = crate::settings::preferences_from( user.try_get::, _>("preferences")?.as_deref(), ); prefs["useWebauthnOnly"] = json!(false); let unique = h .get("WebAuthn-Unique") .and_then(|h| h.to_str().ok()) .is_some_and(|s| matches!(s.to_ascii_lowercase().as_str(), "true" | "1" | "yes" | "on")); let revoke = unique || v.get("revokeAll").and_then(Value::as_bool).unwrap_or(true); let sid = auth::random(); let csrf = auth::random(); let mut tx = s.db.begin().await?; let consumed=sqlx::query("DELETE FROM webauthn_recoveries WHERE email=? AND token=? AND created_at>datetime('now','-60 minutes')").bind(&email).bind(stored).execute(&mut *tx).await?; if consumed.rows_affected() != 1 { return Err(invalid()); } if revoke { sqlx::query("DELETE FROM webauthn_credentials WHERE authenticatable_id=? AND authenticatable_type=?").bind(id).bind(MODEL).execute(&mut *tx).await?; } sqlx::query("UPDATE users SET preferences=?,last_seen_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=?").bind(prefs.to_string()).bind(id).execute(&mut *tx).await?; let deleted = sqlx::query("DELETE FROM rust_sessions WHERE token_hash=? AND expires_at>?") .bind(old.try_get::("token_hash")?) .bind(auth::now()) .execute(&mut *tx) .await?; if deleted.rows_affected() != 1 { return Err(denied()); } sqlx::query( "INSERT INTO rust_sessions(token_hash,user_id,csrf_hash,expires_at) VALUES(?,?,?,?)", ) .bind(auth::digest(&sid)) .bind(id) .bind(auth::digest(&csrf)) .bind(auth::now() + auth::TTL) .execute(&mut *tx) .await?; sqlx::query("DELETE FROM rust_webauthn_challenges WHERE user_id=?") .bind(id) .execute(&mut *tx) .await?; tx.commit().await?; crate::auth_audit::login(&s, id, &context, Some("password")).await?; let mut response = Json(json!({"message":"Webauthn login disabled"})).into_response(); auth::cookies(&mut response, &sid, &csrf, auth::TTL); auth::clear_legacy_cookie(&mut response); Ok(response) } #[cfg(test)] mod tests { use super::*; use axum::{ body::{Body, to_bytes}, http::Request, }; use openssl::{ hash::MessageDigest, pkey::{PKey, Private}, sign::Signer, }; use sha2::{Digest, Sha256}; use std::sync::Arc; use tower::ServiceExt; const SID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const CSRF: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; fn key() -> PKey { PKey::private_key_from_pem(include_bytes!("../tests/fixtures/webauthn-private.pem")) .unwrap() } async fn setup(user: Option) -> AppState { let db = sqlx::sqlite::SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); crate::db::init(&db).await.unwrap(); auth::init(&db).await.unwrap(); crate::users::init(&db).await.unwrap(); init(&db).await.unwrap(); sqlx::query("INSERT INTO users(id,name,email,password,preferences) VALUES(1,'Alice','alice@example.com','unused','{}'),(2,'Bob','bob@example.com','unused','{}')").execute(&db).await.unwrap(); sqlx::query( "INSERT INTO rust_sessions(token_hash,user_id,csrf_hash,expires_at) VALUES(?,?,?,?)", ) .bind(auth::digest(SID)) .bind(user) .bind(auth::digest(CSRF)) .bind(auth::now() + 3600) .execute(&db) .await .unwrap(); AppState { db, cipher: Some(Arc::new( crate::crypto::LaravelCipher::from_app_key( "base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", ) .unwrap(), )), encrypt_db: std::sync::atomic::AtomicBool::new(false).into(), } } async fn request(s: &AppState, method: &str, url: &str, value: Value) -> (StatusCode, Value) { let app = routes(s.clone()).with_state(s.clone()); let response = app .oneshot( Request::builder() .method(method) .uri(url) .header("cookie", format!("rust_2fauth_session={SID}")) .header("x-csrf-token", CSRF) .header("content-type", "application/json") .body(Body::from(value.to_string())) .unwrap(), ) .await .unwrap(); let status = response.status(); let data = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); (status, serde_json::from_slice(&data).unwrap_or(Value::Null)) } async fn import(s: &AppState) { sqlx::query("INSERT INTO webauthn_credentials(id,authenticatable_type,authenticatable_id,user_id,counter,rp_id,origin,public_key) VALUES(?,?,1,'550e8400-e29b-41d4-a716-446655440000',0,'localhost','http://localhost',?)") .bind(encoded(b"php-credential")).bind(MODEL).bind(include_str!("../tests/fixtures/webauthn-php-encrypted.txt").trim()).execute(&s.db).await.unwrap(); } fn assertion( options: &Value, id: &[u8], counter: u32, origin: &str, handle: Option<&[u8]>, ) -> Value { assertion_with_key(options, id, counter, origin, handle, &key()) } fn assertion_with_key( options: &Value, id: &[u8], counter: u32, origin: &str, handle: Option<&[u8]>, pkey: &PKey, ) -> Value { let client=serde_json::to_vec(&json!({"type":"webauthn.get","challenge":options["challenge"],"origin":origin,"crossOrigin":false})).unwrap(); let mut authdata = Sha256::digest(b"localhost").to_vec(); authdata.push(0x05); authdata.extend(counter.to_be_bytes()); let mut signed = authdata.clone(); signed.extend(Sha256::digest(&client)); let signature = if pkey.id() == openssl::pkey::Id::ED25519 { Signer::new_without_digest(pkey) .unwrap() .sign_oneshot_to_vec(&signed) .unwrap() } else { let mut signer = Signer::new(MessageDigest::sha256(), pkey).unwrap(); signer.update(&signed).unwrap(); signer.sign_to_vec().unwrap() }; json!({"id":encoded(id),"rawId":STANDARD.encode(id),"type":"public-key","response":{"clientDataJSON":STANDARD.encode(client),"authenticatorData":STANDARD.encode(authdata),"signature":STANDARD.encode(signature),"userHandle":handle.map(|b|STANDARD.encode(b))}}) } fn registration(options: &Value, id: &[u8]) -> Value { use serde_cbor_2::Value as C; use std::collections::BTreeMap; let ec = key().ec_key().unwrap(); let mut ctx = openssl::bn::BigNumContext::new().unwrap(); let mut x = openssl::bn::BigNum::new().unwrap(); let mut y = openssl::bn::BigNum::new().unwrap(); ec.public_key() .affine_coordinates_gfp(ec.group(), &mut x, &mut y, &mut ctx) .unwrap(); let cose = C::Map(BTreeMap::from([ (C::Integer(1), C::Integer(2)), (C::Integer(3), C::Integer(-7)), (C::Integer(-1), C::Integer(1)), (C::Integer(-2), C::Bytes(x.to_vec_padded(32).unwrap())), (C::Integer(-3), C::Bytes(y.to_vec_padded(32).unwrap())), ])); registration_with_cose(options, id, cose) } fn registration_with_cose(options: &Value, id: &[u8], cose: serde_cbor_2::Value) -> Value { use serde_cbor_2::Value as C; use std::collections::BTreeMap; let mut authdata = Sha256::digest(b"localhost").to_vec(); authdata.push(0x45); authdata.extend(0_u32.to_be_bytes()); authdata.extend([0_u8; 16]); authdata.extend((id.len() as u16).to_be_bytes()); authdata.extend(id); authdata.extend(serde_cbor_2::to_vec(&cose).unwrap()); let attestation = C::Map(BTreeMap::from([ (C::Text("fmt".into()), C::Text("none".into())), (C::Text("attStmt".into()), C::Map(BTreeMap::new())), (C::Text("authData".into()), C::Bytes(authdata)), ])); let client=serde_json::to_vec(&json!({"type":"webauthn.create","challenge":options["challenge"],"origin":"http://localhost","crossOrigin":false})).unwrap(); json!({"id":encoded(id),"rawId":STANDARD.encode(id),"type":"public-key","response":{"clientDataJSON":STANDARD.encode(client),"attestationObject":STANDARD.encode(serde_cbor_2::to_vec(&attestation).unwrap()),"transports":["usb"]}}) } #[tokio::test] async fn ed25519_enrolment_and_assertion_are_supported_like_laragear() { use serde_cbor_2::Value as C; use std::collections::BTreeMap; let s = setup(Some(1)).await; let key = PKey::generate_ed25519().unwrap(); let (_, options) = request(&s, "POST", "/webauthn/register/options", json!({})).await; assert!( options["pubKeyCredParams"] .as_array() .unwrap() .iter() .any(|p| p["alg"] == -8) ); let cose = C::Map(BTreeMap::from([ (C::Integer(1), C::Integer(1)), (C::Integer(3), C::Integer(-8)), (C::Integer(-1), C::Integer(6)), (C::Integer(-2), C::Bytes(key.raw_public_key().unwrap())), ])); let registration = registration_with_cose(&options, b"ed25519-credential", cose); let (status, value) = request(&s, "POST", "/webauthn/register", registration).await; assert_eq!(status, StatusCode::NO_CONTENT, "{value}"); let (_, options) = request( &s, "POST", "/webauthn/login/options", json!({"email":"alice@example.com"}), ) .await; let mut signed = assertion_with_key( &options, b"ed25519-credential", 1, "http://localhost", None, &key, ); signed["email"] = json!("alice@example.com"); let (status, value) = request(&s, "POST", "/webauthn/login", signed).await; assert_eq!(status, StatusCode::OK, "{value}"); } #[tokio::test] async fn laragear_rsa_and_eddsa_pem_credentials_verify_real_signatures() { let rsa = PKey::from_rsa(openssl::rsa::Rsa::generate(2048).unwrap()).unwrap(); let ed = PKey::generate_ed25519().unwrap(); for key in [rsa, ed] { let s = setup(None).await; import(&s).await; let pem = String::from_utf8(key.public_key_to_pem().unwrap()).unwrap(); sqlx::query("UPDATE webauthn_credentials SET public_key=?") .bind(s.cipher.as_ref().unwrap().encrypt(&pem).unwrap()) .execute(&s.db) .await .unwrap(); let (_, options) = request( &s, "POST", "/webauthn/login/options", json!({"email":"alice@example.com"}), ) .await; let mut signed = assertion_with_key( &options, b"php-credential", 1, "http://localhost", None, &key, ); signed["email"] = json!("alice@example.com"); let (status, value) = request(&s, "POST", "/webauthn/login", signed).await; assert_eq!(status, StatusCode::OK, "{value}"); } } #[tokio::test] async fn recovery_requires_token_and_password_consumes_token_and_resets_device_only_mode() { let s = setup(None).await; import(&s).await; let hash = bcrypt::hash("password", 4).unwrap(); sqlx::query( "UPDATE users SET password=?,preferences='{\"useWebauthnOnly\":true}' WHERE id=1", ) .bind(hash) .execute(&s.db) .await .unwrap(); sqlx::query("INSERT INTO webauthn_recoveries(email,token,created_at) VALUES('alice@example.com',?,CURRENT_TIMESTAMP)").bind(bcrypt::hash("recovery-token",4).unwrap()).execute(&s.db).await.unwrap(); assert_eq!( request( &s, "POST", "/webauthn/recover", json!({"email":"alice@example.com","token":"recovery-token","password":"wrong"}) ) .await .0, StatusCode::UNAUTHORIZED ); assert_eq!( sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM webauthn_recoveries") .fetch_one(&s.db) .await .unwrap(), 1 ); let payload = json!({"email":"alice@example.com","token":"recovery-token","password":"password"}); let (status, v) = request(&s, "POST", "/webauthn/recover", payload.clone()).await; assert_eq!(status, StatusCode::OK, "{v}"); assert_eq!( sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM webauthn_recoveries") .fetch_one(&s.db) .await .unwrap(), 0 ); assert_eq!( sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM webauthn_credentials") .fetch_one(&s.db) .await .unwrap(), 0 ); let prefs: String = sqlx::query_scalar("SELECT preferences FROM users WHERE id=1") .fetch_one(&s.db) .await .unwrap(); assert_eq!( serde_json::from_str::(&prefs).unwrap()["useWebauthnOnly"], false ); assert_ne!( request(&s, "POST", "/webauthn/recover", payload).await.0, StatusCode::OK ); } #[tokio::test] async fn php_encrypted_pem_migrates_and_valid_signature_authenticates_once() { let s = setup(None).await; import(&s).await; let (status, options) = request( &s, "POST", "/webauthn/login/options", json!({"email":"ALICE@example.com"}), ) .await; assert_eq!(status, StatusCode::OK, "{options}"); let mut response = assertion(&options, b"php-credential", 1, "http://localhost", None); response["email"] = json!("alice@example.com"); let (status, v) = request(&s, "POST", "/webauthn/login", response.clone()).await; assert_eq!(status, StatusCode::OK, "{v}"); assert_eq!(v["message"], "authenticated"); assert_eq!(v["id"], 1); let count: i64 = sqlx::query_scalar("SELECT counter FROM webauthn_credentials") .fetch_one(&s.db) .await .unwrap(); assert_eq!(count, 1); let method: String = sqlx::query_scalar("SELECT login_method FROM auth_logs WHERE login_successful=1") .fetch_one(&s.db) .await .unwrap(); assert_eq!(method, "webauthn"); assert_ne!( request(&s, "POST", "/webauthn/login", response).await.0, StatusCode::OK ); } #[tokio::test] async fn wrong_origin_user_handle_signature_and_expired_challenge_fail_closed() { let s = setup(None).await; import(&s).await; for variant in 0..4 { let (_, options) = request( &s, "POST", "/webauthn/login/options", json!({"email":"alice@example.com"}), ) .await; let mut response = assertion( &options, b"php-credential", 1, if variant == 0 { "https://evil.example" } else { "http://localhost" }, if variant == 1 { Some(&[0_u8; 16]) } else { None }, ); response["email"] = json!("alice@example.com"); if variant == 2 { response["response"]["signature"] = json!(STANDARD.encode([0_u8; 64])); } if variant == 3 { sqlx::query("UPDATE rust_webauthn_challenges SET expires_at=0") .execute(&s.db) .await .unwrap(); } assert_ne!( request(&s, "POST", "/webauthn/login", response).await.0, StatusCode::OK ); } assert_eq!( sqlx::query_scalar::<_, i64>("SELECT counter FROM webauthn_credentials") .fetch_one(&s.db) .await .unwrap(), 0 ); } #[tokio::test] async fn real_registration_persists_encrypted_key_manage_routes_are_owner_scoped() { let s = setup(Some(1)).await; let (status, options) = request(&s, "POST", "/webauthn/register/options", json!({})).await; assert_eq!(status, StatusCode::OK, "{options}"); let response = registration(&options, b"new-credential"); let (status, v) = request(&s, "POST", "/webauthn/register", response).await; assert_eq!(status, StatusCode::NO_CONTENT, "{v}"); let pem: String = sqlx::query_scalar("SELECT public_key FROM webauthn_credentials") .fetch_one(&s.db) .await .unwrap(); assert!(!pem.contains("BEGIN PUBLIC KEY")); assert!( s.cipher .as_ref() .unwrap() .decrypt(&pem) .unwrap() .contains("BEGIN PUBLIC KEY") ); let (status, v) = request(&s, "GET", "/webauthn/credentials", json!({})).await; assert_eq!(status, StatusCode::OK); assert_eq!(v.as_array().unwrap().len(), 1); assert!(v[0].get("public_key").is_none()); let id = encoded(b"new-credential"); assert_eq!( request( &s, "PATCH", &format!("/webauthn/credentials/{id}/name"), json!({"name":"Security Key"}) ) .await .1["name"], "Security Key" ); sqlx::query("UPDATE rust_sessions SET user_id=2") .execute(&s.db) .await .unwrap(); assert_eq!( request( &s, "PATCH", &format!("/webauthn/credentials/{id}/name"), json!({"name":"Stolen"}) ) .await .0, StatusCode::NOT_FOUND ); request( &s, "DELETE", &format!("/webauthn/credentials/{id}"), json!({}), ) .await; assert_eq!( sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM webauthn_credentials") .fetch_one(&s.db) .await .unwrap(), 1 ); } }