use axum::{Json, extract::State}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use tracing::{error, info, warn}; use tranquil_db_traits::{SessionRepository, UserRepository, WebauthnChallengeType}; use tranquil_pds::api::error::{ApiError, DbResultExt}; use tranquil_pds::auth::{Active, Auth}; use tranquil_pds::rate_limit::{TotpVerifyLimit, check_user_rate_limit_with_message}; use tranquil_pds::state::AppState; use tranquil_pds::types::PlainPassword; pub const REAUTH_WINDOW_SECONDS: i64 = 300; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] pub enum ReauthMethod { Password, Totp, Passkey, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct ReauthStatusOutput { pub last_reauth_at: Option>, pub reauth_required: bool, pub available_methods: Vec, } pub async fn get_reauth_status( State(state): State, auth: Auth, ) -> Result, ApiError> { let last_reauth_at = state .repos .session .get_last_reauth_at(&auth.did) .await .log_db_err("getting last reauth")?; let reauth_required = is_reauth_required(last_reauth_at); let available_methods = get_available_reauth_methods(&*state.repos.user, &auth.did).await; Ok(Json(ReauthStatusOutput { last_reauth_at, reauth_required, available_methods, })) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct PasswordReauthInput { pub password: PlainPassword, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct ReauthOutput { pub reauthed_at: DateTime, } pub async fn reauth_password( State(state): State, auth: Auth, Json(input): Json, ) -> Result, ApiError> { let password_hash = state .repos .user .get_password_hash_by_did(&auth.did) .await .log_db_err("fetching password hash")? .ok_or(ApiError::AccountNotFound)?; let password_valid = bcrypt::verify(&input.password, password_hash.as_str()).unwrap_or(false); if !password_valid { let app_password_hashes = state .repos .session .get_app_password_hashes_by_did(&auth.did) .await .unwrap_or_default(); let app_password_valid = app_password_hashes.iter().fold(false, |acc, h| { acc | bcrypt::verify(&input.password, h.as_str()).unwrap_or(false) }); if !app_password_valid { warn!(did = %&auth.did, "Re-auth failed: invalid password"); return Err(ApiError::InvalidPassword("Password is incorrect".into())); } } let reauthed_at = update_last_reauth_cached(&*state.repos.session, &state.cache, &auth.did) .await .log_db_err("updating reauth")?; info!(did = %&auth.did, "Re-auth successful via password"); Ok(Json(ReauthOutput { reauthed_at })) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct TotpReauthInput { pub code: String, } pub async fn reauth_totp( State(state): State, auth: Auth, Json(input): Json, ) -> Result, ApiError> { let _rate_limit = check_user_rate_limit_with_message::( &state, &auth.did, "Too many verification attempts. Please try again in a few minutes.", ) .await?; let valid = crate::server::totp::verify_totp_or_backup_for_user(&state, &auth.did, &input.code).await; if !valid { warn!(did = %&auth.did, "Re-auth failed: invalid TOTP code"); return Err(ApiError::InvalidCode(Some( "Invalid TOTP or backup code".into(), ))); } let reauthed_at = update_last_reauth_cached(&*state.repos.session, &state.cache, &auth.did) .await .log_db_err("updating reauth")?; info!(did = %&auth.did, "Re-auth successful via TOTP"); Ok(Json(ReauthOutput { reauthed_at })) } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct PasskeyReauthStartOutput { pub options: serde_json::Value, } pub async fn reauth_passkey_start( State(state): State, auth: Auth, ) -> Result, ApiError> { let stored_passkeys = state .repos .user .get_passkeys_for_user(&auth.did) .await .log_db_err("getting passkeys")?; if stored_passkeys.is_empty() { return Err(ApiError::NoPasskeys); } let passkeys: Vec = stored_passkeys .iter() .filter_map(|sp| serde_json::from_slice(&sp.public_key).ok()) .collect(); if passkeys.is_empty() { return Err(ApiError::InternalError(Some( "Failed to load passkeys".into(), ))); } let webauthn = &state.webauthn_config; let (rcr, auth_state) = webauthn.start_authentication(passkeys).map_err(|e| { error!("Failed to start passkey authentication: {:?}", e); ApiError::InternalError(None) })?; let state_json = serde_json::to_string(&auth_state).map_err(|e| { error!("Failed to serialize authentication state: {:?}", e); ApiError::InternalError(None) })?; state .repos .user .save_webauthn_challenge( &auth.did, WebauthnChallengeType::Authentication, &state_json, ) .await .log_db_err("saving authentication state")?; let options = serde_json::to_value(&rcr).unwrap_or(serde_json::json!({})); Ok(Json(PasskeyReauthStartOutput { options })) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct PasskeyReauthFinishInput { pub credential: serde_json::Value, } pub async fn reauth_passkey_finish( State(state): State, auth: Auth, Json(input): Json, ) -> Result, ApiError> { let auth_state_json = state .repos .user .load_webauthn_challenge(&auth.did, WebauthnChallengeType::Authentication) .await .log_db_err("loading authentication state")? .ok_or(ApiError::NoChallengeInProgress)?; let auth_state: webauthn_rs::prelude::PasskeyAuthentication = serde_json::from_str(&auth_state_json).map_err(|e| { error!("Failed to deserialize authentication state: {:?}", e); ApiError::InternalError(None) })?; let credential: webauthn_rs::prelude::PublicKeyCredential = serde_json::from_value(input.credential).map_err(|e| { warn!("Failed to parse credential: {:?}", e); ApiError::InvalidCredential })?; let auth_result = state .webauthn_config .finish_authentication(&credential, &auth_state) .map_err(|e| { warn!(did = %&auth.did, "Passkey re-auth failed: {:?}", e); ApiError::AuthenticationFailed(Some("Passkey authentication failed".into())) })?; let cred_id_bytes = auth_result.cred_id().as_ref(); match state .repos .user .update_passkey_counter( cred_id_bytes, i32::try_from(auth_result.counter()).unwrap_or(i32::MAX), ) .await { Ok(false) => { warn!(did = %&auth.did, "Passkey counter anomaly detected - possible cloned key"); let _ = state .repos .user .delete_webauthn_challenge(&auth.did, WebauthnChallengeType::Authentication) .await; return Err(ApiError::PasskeyCounterAnomaly); } Err(e) => { error!("Failed to update passkey counter: {:?}", e); } Ok(true) => {} } let _ = state .repos .user .delete_webauthn_challenge(&auth.did, WebauthnChallengeType::Authentication) .await; let reauthed_at = update_last_reauth_cached(&*state.repos.session, &state.cache, &auth.did) .await .log_db_err("updating reauth")?; info!(did = %&auth.did, "Re-auth successful via passkey"); Ok(Json(ReauthOutput { reauthed_at })) } pub async fn update_last_reauth_cached( session_repo: &dyn SessionRepository, cache: &std::sync::Arc, did: &tranquil_pds::types::Did, ) -> Result, tranquil_db_traits::DbError> { let now = session_repo.update_last_reauth(did).await?; let cache_key = tranquil_pds::cache_keys::reauth_key(did); let _ = cache .set( &cache_key, &now.timestamp().to_string(), std::time::Duration::from_secs(u64::try_from(REAUTH_WINDOW_SECONDS).unwrap_or(300)), ) .await; Ok(now) } fn is_reauth_required(last_reauth_at: Option>) -> bool { match last_reauth_at { None => true, Some(t) => { let elapsed = Utc::now().signed_duration_since(t); elapsed.num_seconds() > REAUTH_WINDOW_SECONDS } } } async fn get_available_reauth_methods( user_repo: &dyn UserRepository, did: &tranquil_pds::types::Did, ) -> Vec { let has_password = user_repo .get_password_hash_by_did(did) .await .ok() .flatten() .is_some(); let has_totp = user_repo.has_totp_enabled(did).await.unwrap_or(false); let has_passkeys = user_repo.has_passkeys(did).await.unwrap_or(false); [ (has_password, ReauthMethod::Password), (has_totp, ReauthMethod::Totp), (has_passkeys, ReauthMethod::Passkey), ] .into_iter() .filter_map(|(enabled, method)| enabled.then_some(method)) .collect() } pub async fn check_reauth_required( session_repo: &dyn SessionRepository, did: &tranquil_pds::types::Did, ) -> bool { match session_repo.get_last_reauth_at(did).await { Ok(last_reauth_at) => is_reauth_required(last_reauth_at), _ => true, } } pub async fn check_reauth_required_cached( session_repo: &dyn SessionRepository, cache: &std::sync::Arc, did: &tranquil_pds::types::Did, ) -> bool { let cache_key = tranquil_pds::cache_keys::reauth_key(did); if let Some(timestamp_str) = cache.get(&cache_key).await && let Ok(timestamp) = timestamp_str.parse::() { let reauth_time = chrono::DateTime::from_timestamp(timestamp, 0); if let Some(t) = reauth_time { let elapsed = Utc::now().signed_duration_since(t); if elapsed.num_seconds() <= REAUTH_WINDOW_SECONDS { return false; } } } match session_repo.get_last_reauth_at(did).await { Ok(last_reauth_at) => is_reauth_required(last_reauth_at), _ => true, } } pub async fn check_legacy_session_mfa( session_repo: &dyn SessionRepository, did: &tranquil_pds::types::Did, ) -> bool { match session_repo.get_session_mfa_status(did).await { Ok(Some(status)) => { if status.login_type.is_modern() { return true; } if status.mfa_verified { return true; } if let Some(last_reauth) = status.last_reauth_at { let elapsed = chrono::Utc::now().signed_duration_since(last_reauth); if elapsed.num_seconds() <= REAUTH_WINDOW_SECONDS { return true; } } false } _ => true, } } pub async fn update_mfa_verified( session_repo: &dyn SessionRepository, did: &tranquil_pds::types::Did, ) -> Result<(), tranquil_db_traits::DbError> { session_repo.update_mfa_verified(did).await }