this repo has no description
1use axum::{ 2 Json, 3 extract::State, 4 http::StatusCode, 5 response::{IntoResponse, Response}, 6}; 7use chrono::{DateTime, Utc}; 8use serde::{Deserialize, Serialize}; 9use serde_json::json; 10use sqlx::PgPool; 11use tracing::{error, info, warn}; 12 13use crate::auth::BearerAuth; 14use crate::state::{AppState, RateLimitKind}; 15 16const REAUTH_WINDOW_SECONDS: i64 = 300; 17 18#[derive(Serialize)] 19#[serde(rename_all = "camelCase")] 20pub struct ReauthStatusResponse { 21 pub last_reauth_at: Option<DateTime<Utc>>, 22 pub reauth_required: bool, 23 pub available_methods: Vec<String>, 24} 25 26pub async fn get_reauth_status(State(state): State<AppState>, auth: BearerAuth) -> Response { 27 let session = sqlx::query!( 28 "SELECT last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1", 29 auth.0.did 30 ) 31 .fetch_optional(&state.db) 32 .await; 33 34 let last_reauth_at = match session { 35 Ok(Some(row)) => row.last_reauth_at, 36 Ok(None) => None, 37 Err(e) => { 38 error!("DB error: {:?}", e); 39 return ( 40 StatusCode::INTERNAL_SERVER_ERROR, 41 Json(json!({"error": "InternalError"})), 42 ) 43 .into_response(); 44 } 45 }; 46 47 let reauth_required = is_reauth_required(last_reauth_at); 48 let available_methods = get_available_reauth_methods(&state.db, &auth.0.did).await; 49 50 Json(ReauthStatusResponse { 51 last_reauth_at, 52 reauth_required, 53 available_methods, 54 }) 55 .into_response() 56} 57 58#[derive(Deserialize)] 59#[serde(rename_all = "camelCase")] 60pub struct PasswordReauthInput { 61 pub password: String, 62} 63 64#[derive(Serialize)] 65#[serde(rename_all = "camelCase")] 66pub struct ReauthResponse { 67 pub reauthed_at: DateTime<Utc>, 68} 69 70pub async fn reauth_password( 71 State(state): State<AppState>, 72 auth: BearerAuth, 73 Json(input): Json<PasswordReauthInput>, 74) -> Response { 75 let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did) 76 .fetch_optional(&state.db) 77 .await; 78 79 let password_hash = match user { 80 Ok(Some(row)) => row.password_hash, 81 Ok(None) => { 82 return ( 83 StatusCode::NOT_FOUND, 84 Json(json!({"error": "AccountNotFound"})), 85 ) 86 .into_response(); 87 } 88 Err(e) => { 89 error!("DB error: {:?}", e); 90 return ( 91 StatusCode::INTERNAL_SERVER_ERROR, 92 Json(json!({"error": "InternalError"})), 93 ) 94 .into_response(); 95 } 96 }; 97 98 let password_valid = password_hash 99 .as_ref() 100 .map(|h| bcrypt::verify(&input.password, h).unwrap_or(false)) 101 .unwrap_or(false); 102 103 if !password_valid { 104 let app_passwords = sqlx::query!( 105 "SELECT ap.password_hash FROM app_passwords ap 106 JOIN users u ON ap.user_id = u.id 107 WHERE u.did = $1", 108 auth.0.did 109 ) 110 .fetch_all(&state.db) 111 .await 112 .unwrap_or_default(); 113 114 let app_password_valid = app_passwords 115 .iter() 116 .any(|ap| bcrypt::verify(&input.password, &ap.password_hash).unwrap_or(false)); 117 118 if !app_password_valid { 119 warn!(did = %auth.0.did, "Re-auth failed: invalid password"); 120 return ( 121 StatusCode::UNAUTHORIZED, 122 Json(json!({ 123 "error": "InvalidPassword", 124 "message": "Password is incorrect" 125 })), 126 ) 127 .into_response(); 128 } 129 } 130 131 match update_last_reauth_cached(&state.db, &state.cache, &auth.0.did).await { 132 Ok(reauthed_at) => { 133 info!(did = %auth.0.did, "Re-auth successful via password"); 134 Json(ReauthResponse { reauthed_at }).into_response() 135 } 136 Err(e) => { 137 error!("DB error updating reauth: {:?}", e); 138 ( 139 StatusCode::INTERNAL_SERVER_ERROR, 140 Json(json!({"error": "InternalError"})), 141 ) 142 .into_response() 143 } 144 } 145} 146 147#[derive(Deserialize)] 148#[serde(rename_all = "camelCase")] 149pub struct TotpReauthInput { 150 pub code: String, 151} 152 153pub async fn reauth_totp( 154 State(state): State<AppState>, 155 auth: BearerAuth, 156 Json(input): Json<TotpReauthInput>, 157) -> Response { 158 if !state 159 .check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did) 160 .await 161 { 162 warn!(did = %auth.0.did, "TOTP verification rate limit exceeded"); 163 return ( 164 StatusCode::TOO_MANY_REQUESTS, 165 Json(json!({ 166 "error": "RateLimitExceeded", 167 "message": "Too many verification attempts. Please try again in a few minutes." 168 })), 169 ) 170 .into_response(); 171 } 172 173 let valid = 174 crate::api::server::totp::verify_totp_or_backup_for_user(&state, &auth.0.did, &input.code) 175 .await; 176 177 if !valid { 178 warn!(did = %auth.0.did, "Re-auth failed: invalid TOTP code"); 179 return ( 180 StatusCode::UNAUTHORIZED, 181 Json(json!({ 182 "error": "InvalidCode", 183 "message": "Invalid TOTP or backup code" 184 })), 185 ) 186 .into_response(); 187 } 188 189 match update_last_reauth_cached(&state.db, &state.cache, &auth.0.did).await { 190 Ok(reauthed_at) => { 191 info!(did = %auth.0.did, "Re-auth successful via TOTP"); 192 Json(ReauthResponse { reauthed_at }).into_response() 193 } 194 Err(e) => { 195 error!("DB error updating reauth: {:?}", e); 196 ( 197 StatusCode::INTERNAL_SERVER_ERROR, 198 Json(json!({"error": "InternalError"})), 199 ) 200 .into_response() 201 } 202 } 203} 204 205#[derive(Serialize)] 206#[serde(rename_all = "camelCase")] 207pub struct PasskeyReauthStartResponse { 208 pub options: serde_json::Value, 209} 210 211pub async fn reauth_passkey_start(State(state): State<AppState>, auth: BearerAuth) -> Response { 212 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 213 214 let stored_passkeys = 215 match crate::auth::webauthn::get_passkeys_for_user(&state.db, &auth.0.did).await { 216 Ok(pks) => pks, 217 Err(e) => { 218 error!("Failed to get passkeys: {:?}", e); 219 return ( 220 StatusCode::INTERNAL_SERVER_ERROR, 221 Json(json!({"error": "InternalError"})), 222 ) 223 .into_response(); 224 } 225 }; 226 227 if stored_passkeys.is_empty() { 228 return ( 229 StatusCode::BAD_REQUEST, 230 Json(json!({ 231 "error": "NoPasskeys", 232 "message": "No passkeys registered for this account" 233 })), 234 ) 235 .into_response(); 236 } 237 238 let passkeys: Vec<webauthn_rs::prelude::SecurityKey> = stored_passkeys 239 .iter() 240 .filter_map(|sp| sp.to_security_key().ok()) 241 .collect(); 242 243 if passkeys.is_empty() { 244 return ( 245 StatusCode::INTERNAL_SERVER_ERROR, 246 Json(json!({"error": "InternalError", "message": "Failed to load passkeys"})), 247 ) 248 .into_response(); 249 } 250 251 let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) { 252 Ok(w) => w, 253 Err(e) => { 254 error!("Failed to create WebAuthn config: {:?}", e); 255 return ( 256 StatusCode::INTERNAL_SERVER_ERROR, 257 Json(json!({"error": "InternalError"})), 258 ) 259 .into_response(); 260 } 261 }; 262 263 let (rcr, auth_state) = match webauthn.start_authentication(passkeys) { 264 Ok(result) => result, 265 Err(e) => { 266 error!("Failed to start passkey authentication: {:?}", e); 267 return ( 268 StatusCode::INTERNAL_SERVER_ERROR, 269 Json(json!({"error": "InternalError"})), 270 ) 271 .into_response(); 272 } 273 }; 274 275 if let Err(e) = 276 crate::auth::webauthn::save_authentication_state(&state.db, &auth.0.did, &auth_state).await 277 { 278 error!("Failed to save authentication state: {:?}", e); 279 return ( 280 StatusCode::INTERNAL_SERVER_ERROR, 281 Json(json!({"error": "InternalError"})), 282 ) 283 .into_response(); 284 } 285 286 let options = serde_json::to_value(&rcr).unwrap_or(json!({})); 287 Json(PasskeyReauthStartResponse { options }).into_response() 288} 289 290#[derive(Deserialize)] 291#[serde(rename_all = "camelCase")] 292pub struct PasskeyReauthFinishInput { 293 pub credential: serde_json::Value, 294} 295 296pub async fn reauth_passkey_finish( 297 State(state): State<AppState>, 298 auth: BearerAuth, 299 Json(input): Json<PasskeyReauthFinishInput>, 300) -> Response { 301 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 302 303 let auth_state = 304 match crate::auth::webauthn::load_authentication_state(&state.db, &auth.0.did).await { 305 Ok(Some(s)) => s, 306 Ok(None) => { 307 return ( 308 StatusCode::BAD_REQUEST, 309 Json(json!({ 310 "error": "NoChallengeInProgress", 311 "message": "No passkey authentication in progress or challenge expired" 312 })), 313 ) 314 .into_response(); 315 } 316 Err(e) => { 317 error!("Failed to load authentication state: {:?}", e); 318 return ( 319 StatusCode::INTERNAL_SERVER_ERROR, 320 Json(json!({"error": "InternalError"})), 321 ) 322 .into_response(); 323 } 324 }; 325 326 let credential: webauthn_rs::prelude::PublicKeyCredential = 327 match serde_json::from_value(input.credential) { 328 Ok(c) => c, 329 Err(e) => { 330 warn!("Failed to parse credential: {:?}", e); 331 return ( 332 StatusCode::BAD_REQUEST, 333 Json(json!({ 334 "error": "InvalidCredential", 335 "message": "Failed to parse credential response" 336 })), 337 ) 338 .into_response(); 339 } 340 }; 341 342 let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) { 343 Ok(w) => w, 344 Err(e) => { 345 error!("Failed to create WebAuthn config: {:?}", e); 346 return ( 347 StatusCode::INTERNAL_SERVER_ERROR, 348 Json(json!({"error": "InternalError"})), 349 ) 350 .into_response(); 351 } 352 }; 353 354 let auth_result = match webauthn.finish_authentication(&credential, &auth_state) { 355 Ok(r) => r, 356 Err(e) => { 357 warn!(did = %auth.0.did, "Passkey re-auth failed: {:?}", e); 358 return ( 359 StatusCode::UNAUTHORIZED, 360 Json(json!({ 361 "error": "AuthenticationFailed", 362 "message": "Passkey authentication failed" 363 })), 364 ) 365 .into_response(); 366 } 367 }; 368 369 let cred_id_bytes = auth_result.cred_id().as_ref(); 370 match crate::auth::webauthn::update_passkey_counter( 371 &state.db, 372 cred_id_bytes, 373 auth_result.counter(), 374 ) 375 .await 376 { 377 Ok(false) => { 378 warn!(did = %auth.0.did, "Passkey counter anomaly detected - possible cloned key"); 379 let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &auth.0.did).await; 380 return ( 381 StatusCode::UNAUTHORIZED, 382 Json(json!({ 383 "error": "PasskeyCounterAnomaly", 384 "message": "Authentication failed: security key counter anomaly detected. This may indicate a cloned key." 385 })), 386 ) 387 .into_response(); 388 } 389 Err(e) => { 390 error!("Failed to update passkey counter: {:?}", e); 391 } 392 Ok(true) => {} 393 } 394 395 let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &auth.0.did).await; 396 397 match update_last_reauth_cached(&state.db, &state.cache, &auth.0.did).await { 398 Ok(reauthed_at) => { 399 info!(did = %auth.0.did, "Re-auth successful via passkey"); 400 Json(ReauthResponse { reauthed_at }).into_response() 401 } 402 Err(e) => { 403 error!("DB error updating reauth: {:?}", e); 404 ( 405 StatusCode::INTERNAL_SERVER_ERROR, 406 Json(json!({"error": "InternalError"})), 407 ) 408 .into_response() 409 } 410 } 411} 412 413pub async fn update_last_reauth_cached( 414 db: &PgPool, 415 cache: &std::sync::Arc<dyn crate::cache::Cache>, 416 did: &str, 417) -> Result<DateTime<Utc>, sqlx::Error> { 418 let now = Utc::now(); 419 sqlx::query!( 420 "UPDATE session_tokens SET last_reauth_at = $1, mfa_verified = TRUE WHERE did = $2", 421 now, 422 did 423 ) 424 .execute(db) 425 .await?; 426 let cache_key = format!("reauth:{}", did); 427 let _ = cache 428 .set( 429 &cache_key, 430 &now.timestamp().to_string(), 431 std::time::Duration::from_secs(REAUTH_WINDOW_SECONDS as u64), 432 ) 433 .await; 434 Ok(now) 435} 436 437fn is_reauth_required(last_reauth_at: Option<DateTime<Utc>>) -> bool { 438 match last_reauth_at { 439 None => true, 440 Some(t) => { 441 let elapsed = Utc::now().signed_duration_since(t); 442 elapsed.num_seconds() > REAUTH_WINDOW_SECONDS 443 } 444 } 445} 446 447async fn get_available_reauth_methods(db: &PgPool, did: &str) -> Vec<String> { 448 let mut methods = Vec::new(); 449 450 let has_password = sqlx::query_scalar!( 451 "SELECT password_hash IS NOT NULL as has_pw FROM users WHERE did = $1", 452 did 453 ) 454 .fetch_optional(db) 455 .await 456 .ok() 457 .flatten() 458 .unwrap_or(Some(false)); 459 460 if has_password == Some(true) { 461 methods.push("password".to_string()); 462 } 463 464 let has_totp = crate::api::server::totp::has_totp_enabled_db(db, did).await; 465 if has_totp { 466 methods.push("totp".to_string()); 467 } 468 469 let has_passkeys = crate::api::server::passkeys::has_passkeys_for_user_db(db, did).await; 470 if has_passkeys { 471 methods.push("passkey".to_string()); 472 } 473 474 methods 475} 476 477pub async fn check_reauth_required(db: &PgPool, did: &str) -> bool { 478 let session = sqlx::query!( 479 "SELECT last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1", 480 did 481 ) 482 .fetch_optional(db) 483 .await; 484 485 match session { 486 Ok(Some(row)) => is_reauth_required(row.last_reauth_at), 487 _ => true, 488 } 489} 490 491pub async fn check_reauth_required_cached( 492 db: &PgPool, 493 cache: &std::sync::Arc<dyn crate::cache::Cache>, 494 did: &str, 495) -> bool { 496 let cache_key = format!("reauth:{}", did); 497 if let Some(timestamp_str) = cache.get(&cache_key).await { 498 if let Ok(timestamp) = timestamp_str.parse::<i64>() { 499 let reauth_time = chrono::DateTime::from_timestamp(timestamp, 0); 500 if let Some(t) = reauth_time { 501 let elapsed = Utc::now().signed_duration_since(t); 502 if elapsed.num_seconds() <= REAUTH_WINDOW_SECONDS { 503 return false; 504 } 505 } 506 } 507 } 508 let session = sqlx::query!( 509 "SELECT last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1", 510 did 511 ) 512 .fetch_optional(db) 513 .await; 514 515 match session { 516 Ok(Some(row)) => is_reauth_required(row.last_reauth_at), 517 _ => true, 518 } 519} 520 521#[derive(Serialize)] 522#[serde(rename_all = "camelCase")] 523pub struct ReauthRequiredError { 524 pub error: String, 525 pub message: String, 526 pub reauth_methods: Vec<String>, 527} 528 529pub async fn reauth_required_response(db: &PgPool, did: &str) -> Response { 530 let methods = get_available_reauth_methods(db, did).await; 531 ( 532 StatusCode::UNAUTHORIZED, 533 Json(ReauthRequiredError { 534 error: "ReauthRequired".to_string(), 535 message: "Re-authentication required for this action".to_string(), 536 reauth_methods: methods, 537 }), 538 ) 539 .into_response() 540} 541 542pub async fn check_legacy_session_mfa(db: &PgPool, did: &str) -> bool { 543 let session = sqlx::query!( 544 "SELECT legacy_login, mfa_verified, last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1", 545 did 546 ) 547 .fetch_optional(db) 548 .await; 549 550 match session { 551 Ok(Some(row)) => { 552 if !row.legacy_login { 553 return true; 554 } 555 if row.mfa_verified { 556 return true; 557 } 558 if let Some(last_reauth) = row.last_reauth_at { 559 let elapsed = chrono::Utc::now().signed_duration_since(last_reauth); 560 if elapsed.num_seconds() <= REAUTH_WINDOW_SECONDS { 561 return true; 562 } 563 } 564 false 565 } 566 _ => true, 567 } 568} 569 570pub async fn update_mfa_verified(db: &PgPool, did: &str) -> Result<(), sqlx::Error> { 571 sqlx::query!( 572 "UPDATE session_tokens SET mfa_verified = TRUE, last_reauth_at = NOW() WHERE did = $1", 573 did 574 ) 575 .execute(db) 576 .await?; 577 Ok(()) 578} 579 580pub async fn legacy_mfa_required_response(db: &PgPool, did: &str) -> Response { 581 let methods = get_available_reauth_methods(db, did).await; 582 ( 583 StatusCode::FORBIDDEN, 584 Json(serde_json::json!({ 585 "error": "MfaVerificationRequired", 586 "message": "This sensitive operation requires MFA verification. Your session was created via a legacy app that doesn't support MFA during login.", 587 "reauthMethods": methods 588 })), 589 ) 590 .into_response() 591}