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 _ = 380 crate::auth::webauthn::delete_authentication_state(&state.db, &auth.0.did).await; 381 return ( 382 StatusCode::UNAUTHORIZED, 383 Json(json!({ 384 "error": "PasskeyCounterAnomaly", 385 "message": "Authentication failed: security key counter anomaly detected. This may indicate a cloned key." 386 })), 387 ) 388 .into_response(); 389 } 390 Err(e) => { 391 error!("Failed to update passkey counter: {:?}", e); 392 } 393 Ok(true) => {} 394 } 395 396 let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &auth.0.did).await; 397 398 match update_last_reauth_cached(&state.db, &state.cache, &auth.0.did).await { 399 Ok(reauthed_at) => { 400 info!(did = %auth.0.did, "Re-auth successful via passkey"); 401 Json(ReauthResponse { reauthed_at }).into_response() 402 } 403 Err(e) => { 404 error!("DB error updating reauth: {:?}", e); 405 ( 406 StatusCode::INTERNAL_SERVER_ERROR, 407 Json(json!({"error": "InternalError"})), 408 ) 409 .into_response() 410 } 411 } 412} 413 414pub async fn update_last_reauth_cached( 415 db: &PgPool, 416 cache: &std::sync::Arc<dyn crate::cache::Cache>, 417 did: &str, 418) -> Result<DateTime<Utc>, sqlx::Error> { 419 let now = Utc::now(); 420 sqlx::query!( 421 "UPDATE session_tokens SET last_reauth_at = $1, mfa_verified = TRUE WHERE did = $2", 422 now, 423 did 424 ) 425 .execute(db) 426 .await?; 427 let cache_key = format!("reauth:{}", did); 428 let _ = cache 429 .set( 430 &cache_key, 431 &now.timestamp().to_string(), 432 std::time::Duration::from_secs(REAUTH_WINDOW_SECONDS as u64), 433 ) 434 .await; 435 Ok(now) 436} 437 438fn is_reauth_required(last_reauth_at: Option<DateTime<Utc>>) -> bool { 439 match last_reauth_at { 440 None => true, 441 Some(t) => { 442 let elapsed = Utc::now().signed_duration_since(t); 443 elapsed.num_seconds() > REAUTH_WINDOW_SECONDS 444 } 445 } 446} 447 448async fn get_available_reauth_methods(db: &PgPool, did: &str) -> Vec<String> { 449 let mut methods = Vec::new(); 450 451 let has_password = sqlx::query_scalar!( 452 "SELECT password_hash IS NOT NULL as has_pw FROM users WHERE did = $1", 453 did 454 ) 455 .fetch_optional(db) 456 .await 457 .ok() 458 .flatten() 459 .unwrap_or(Some(false)); 460 461 if has_password == Some(true) { 462 methods.push("password".to_string()); 463 } 464 465 let has_totp = crate::api::server::totp::has_totp_enabled_db(db, did).await; 466 if has_totp { 467 methods.push("totp".to_string()); 468 } 469 470 let has_passkeys = crate::api::server::passkeys::has_passkeys_for_user_db(db, did).await; 471 if has_passkeys { 472 methods.push("passkey".to_string()); 473 } 474 475 methods 476} 477 478pub async fn check_reauth_required(db: &PgPool, did: &str) -> bool { 479 let session = sqlx::query!( 480 "SELECT last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1", 481 did 482 ) 483 .fetch_optional(db) 484 .await; 485 486 match session { 487 Ok(Some(row)) => is_reauth_required(row.last_reauth_at), 488 _ => true, 489 } 490} 491 492pub async fn check_reauth_required_cached( 493 db: &PgPool, 494 cache: &std::sync::Arc<dyn crate::cache::Cache>, 495 did: &str, 496) -> bool { 497 let cache_key = format!("reauth:{}", did); 498 if let Some(timestamp_str) = cache.get(&cache_key).await 499 && let Ok(timestamp) = timestamp_str.parse::<i64>() 500 { 501 let reauth_time = chrono::DateTime::from_timestamp(timestamp, 0); 502 if let Some(t) = reauth_time { 503 let elapsed = Utc::now().signed_duration_since(t); 504 if elapsed.num_seconds() <= REAUTH_WINDOW_SECONDS { 505 return false; 506 } 507 } 508 } 509 let session = sqlx::query!( 510 "SELECT last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1", 511 did 512 ) 513 .fetch_optional(db) 514 .await; 515 516 match session { 517 Ok(Some(row)) => is_reauth_required(row.last_reauth_at), 518 _ => true, 519 } 520} 521 522#[derive(Serialize)] 523#[serde(rename_all = "camelCase")] 524pub struct ReauthRequiredError { 525 pub error: String, 526 pub message: String, 527 pub reauth_methods: Vec<String>, 528} 529 530pub async fn reauth_required_response(db: &PgPool, did: &str) -> Response { 531 let methods = get_available_reauth_methods(db, did).await; 532 ( 533 StatusCode::UNAUTHORIZED, 534 Json(ReauthRequiredError { 535 error: "ReauthRequired".to_string(), 536 message: "Re-authentication required for this action".to_string(), 537 reauth_methods: methods, 538 }), 539 ) 540 .into_response() 541} 542 543pub async fn check_legacy_session_mfa(db: &PgPool, did: &str) -> bool { 544 let session = sqlx::query!( 545 "SELECT legacy_login, mfa_verified, last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1", 546 did 547 ) 548 .fetch_optional(db) 549 .await; 550 551 match session { 552 Ok(Some(row)) => { 553 if !row.legacy_login { 554 return true; 555 } 556 if row.mfa_verified { 557 return true; 558 } 559 if let Some(last_reauth) = row.last_reauth_at { 560 let elapsed = chrono::Utc::now().signed_duration_since(last_reauth); 561 if elapsed.num_seconds() <= REAUTH_WINDOW_SECONDS { 562 return true; 563 } 564 } 565 false 566 } 567 _ => true, 568 } 569} 570 571pub async fn update_mfa_verified(db: &PgPool, did: &str) -> Result<(), sqlx::Error> { 572 sqlx::query!( 573 "UPDATE session_tokens SET mfa_verified = TRUE, last_reauth_at = NOW() WHERE did = $1", 574 did 575 ) 576 .execute(db) 577 .await?; 578 Ok(()) 579} 580 581pub async fn legacy_mfa_required_response(db: &PgPool, did: &str) -> Response { 582 let methods = get_available_reauth_methods(db, did).await; 583 ( 584 StatusCode::FORBIDDEN, 585 Json(serde_json::json!({ 586 "error": "MfaVerificationRequired", 587 "message": "This sensitive operation requires MFA verification. Your session was created via a legacy app that doesn't support MFA during login.", 588 "reauthMethods": methods 589 })), 590 ) 591 .into_response() 592}