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(&state.db, &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(&state.db, &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(&state.db, &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 413async fn update_last_reauth(db: &PgPool, did: &str) -> Result<DateTime<Utc>, sqlx::Error> { 414 let now = Utc::now(); 415 sqlx::query!( 416 "UPDATE session_tokens SET last_reauth_at = $1, mfa_verified = TRUE WHERE did = $2", 417 now, 418 did 419 ) 420 .execute(db) 421 .await?; 422 Ok(now) 423} 424 425fn is_reauth_required(last_reauth_at: Option<DateTime<Utc>>) -> bool { 426 match last_reauth_at { 427 None => true, 428 Some(t) => { 429 let elapsed = Utc::now().signed_duration_since(t); 430 elapsed.num_seconds() > REAUTH_WINDOW_SECONDS 431 } 432 } 433} 434 435async fn get_available_reauth_methods(db: &PgPool, did: &str) -> Vec<String> { 436 let mut methods = Vec::new(); 437 438 let has_password = sqlx::query_scalar!( 439 "SELECT password_hash IS NOT NULL as has_pw FROM users WHERE did = $1", 440 did 441 ) 442 .fetch_optional(db) 443 .await 444 .ok() 445 .flatten() 446 .unwrap_or(Some(false)); 447 448 if has_password == Some(true) { 449 methods.push("password".to_string()); 450 } 451 452 let has_totp = crate::api::server::totp::has_totp_enabled_db(db, did).await; 453 if has_totp { 454 methods.push("totp".to_string()); 455 } 456 457 let has_passkeys = crate::api::server::passkeys::has_passkeys_for_user_db(db, did).await; 458 if has_passkeys { 459 methods.push("passkey".to_string()); 460 } 461 462 methods 463} 464 465pub async fn check_reauth_required(db: &PgPool, did: &str) -> bool { 466 let session = sqlx::query!( 467 "SELECT last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1", 468 did 469 ) 470 .fetch_optional(db) 471 .await; 472 473 match session { 474 Ok(Some(row)) => is_reauth_required(row.last_reauth_at), 475 _ => true, 476 } 477} 478 479#[derive(Serialize)] 480#[serde(rename_all = "camelCase")] 481pub struct ReauthRequiredError { 482 pub error: String, 483 pub message: String, 484 pub reauth_methods: Vec<String>, 485} 486 487pub async fn reauth_required_response(db: &PgPool, did: &str) -> Response { 488 let methods = get_available_reauth_methods(db, did).await; 489 ( 490 StatusCode::UNAUTHORIZED, 491 Json(ReauthRequiredError { 492 error: "ReauthRequired".to_string(), 493 message: "Re-authentication required for this action".to_string(), 494 reauth_methods: methods, 495 }), 496 ) 497 .into_response() 498} 499 500pub async fn check_legacy_session_mfa(db: &PgPool, did: &str) -> bool { 501 let session = sqlx::query!( 502 "SELECT legacy_login, mfa_verified, last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1", 503 did 504 ) 505 .fetch_optional(db) 506 .await; 507 508 match session { 509 Ok(Some(row)) => { 510 if !row.legacy_login { 511 return true; 512 } 513 if row.mfa_verified { 514 return true; 515 } 516 if let Some(last_reauth) = row.last_reauth_at { 517 let elapsed = chrono::Utc::now().signed_duration_since(last_reauth); 518 if elapsed.num_seconds() <= REAUTH_WINDOW_SECONDS { 519 return true; 520 } 521 } 522 false 523 } 524 _ => true, 525 } 526} 527 528pub async fn update_mfa_verified(db: &PgPool, did: &str) -> Result<(), sqlx::Error> { 529 sqlx::query!( 530 "UPDATE session_tokens SET mfa_verified = TRUE, last_reauth_at = NOW() WHERE did = $1", 531 did 532 ) 533 .execute(db) 534 .await?; 535 Ok(()) 536} 537 538pub async fn legacy_mfa_required_response(db: &PgPool, did: &str) -> Response { 539 let methods = get_available_reauth_methods(db, did).await; 540 ( 541 StatusCode::FORBIDDEN, 542 Json(serde_json::json!({ 543 "error": "MfaVerificationRequired", 544 "message": "This sensitive operation requires MFA verification. Your session was created via a legacy app that doesn't support MFA during login.", 545 "reauthMethods": methods 546 })), 547 ) 548 .into_response() 549}