Something went wrong. Try again.
Our Personal Data Server from scratch!
Something went wrong. Try again.
12 kB · 390 lines
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391use 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<DateTime<Utc>>, pub reauth_required: bool, pub available_methods: Vec<ReauthMethod>,}
pub async fn get_reauth_status( State(state): State<AppState>, auth: Auth<Active>,) -> Result<Json<ReauthStatusOutput>, 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<Utc>,}
pub async fn reauth_password( State(state): State<AppState>, auth: Auth<Active>, Json(input): Json<PasswordReauthInput>,) -> Result<Json<ReauthOutput>, 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<AppState>, auth: Auth<Active>, Json(input): Json<TotpReauthInput>,) -> Result<Json<ReauthOutput>, ApiError> { let _rate_limit = check_user_rate_limit_with_message::<TotpVerifyLimit>( &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<AppState>, auth: Auth<Active>,) -> Result<Json<PasskeyReauthStartOutput>, 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<webauthn_rs::prelude::SecurityKey> = 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<AppState>, auth: Auth<Active>, Json(input): Json<PasskeyReauthFinishInput>,) -> Result<Json<ReauthOutput>, 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::SecurityKeyAuthentication = 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<dyn tranquil_pds::cache::Cache>, did: &tranquil_pds::types::Did,) -> Result<DateTime<Utc>, 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<DateTime<Utc>>) -> 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<ReauthMethod> { 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<dyn tranquil_pds::cache::Cache>, 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::<i64>() { 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}