//! Browser/native sessions, legacy Laravel import and Passport authentication. use crate::AppState; use axum::{ Json, Router, body::to_bytes, extract::{ConnectInfo, Request, State}, http::{HeaderMap, HeaderValue, Method, StatusCode, header}, middleware::Next, response::{IntoResponse, Response}, routing::{get, post}, }; use rand::{RngCore, rngs::OsRng}; use serde::Deserialize; use serde_json::json; use sha2::{Digest, Sha256}; use sqlx::{Row, SqlitePool}; use std::{ net::SocketAddr, time::{SystemTime, UNIX_EPOCH}, }; use subtle::ConstantTimeEq; const COOKIE: &str = "rust_2fauth_session"; pub(crate) const TTL: i64 = 90 * 24 * 3600; fn csrf_status() -> StatusCode { StatusCode::from_u16(419).expect("valid Laravel CSRF status") } #[derive(Clone, Debug)] pub struct AuthUser { pub id: i64, pub is_admin: bool, } pub async fn init(db: &SqlitePool) -> Result<(), sqlx::Error> { sqlx::raw_sql("CREATE TABLE IF NOT EXISTS rust_sessions (token_hash TEXT PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, csrf_hash TEXT NOT NULL, expires_at INTEGER NOT NULL); CREATE INDEX IF NOT EXISTS rust_sessions_expiry ON rust_sessions(expires_at); CREATE TABLE IF NOT EXISTS rust_login_attempts (key TEXT PRIMARY KEY, window INTEGER NOT NULL, attempts INTEGER NOT NULL);").execute(db).await?; crate::api_limits::init(db).await?; Ok(()) } pub fn routes() -> Router { Router::new() .route("/refresh-csrf", get(refresh_csrf)) .route("/user/login", post(login)) .route("/user/logout", get(logout).post(logout)) } pub(crate) fn now() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs() as i64 } pub(crate) fn random() -> String { let mut bytes = [0u8; 32]; OsRng.fill_bytes(&mut bytes); bytes.iter().map(|b| format!("{b:02x}")).collect() } pub(crate) fn digest(s: &str) -> String { format!("{:x}", Sha256::digest(s.as_bytes())) } fn token(headers: &HeaderMap) -> Option { // Reject ambiguity rather than accepting one of multiple session cookies. let mut found = None; for cookie in headers.get_all(header::COOKIE) { for part in cookie.to_str().ok()?.split(';') { if let Some((key, value)) = part.trim().split_once('=') && key == COOKIE { if found.is_some() || value.len() != 64 || !value.bytes().all(|x| x.is_ascii_hexdigit()) { return None; } found = Some(value.to_owned()); } } } found } fn secure() -> bool { std::env::var("RUST_COOKIE_SECURE").as_deref() != Ok("false") } pub(crate) fn cookies(response: &mut Response, session: &str, csrf: &str, age: i64) { let suffix = if secure() { "; Secure" } else { "" }; for cookie in [ format!("{COOKIE}={session}; Path=/; HttpOnly; SameSite=Strict; Max-Age={age}{suffix}"), format!("XSRF-TOKEN={csrf}; Path=/; SameSite=Strict; Max-Age={age}{suffix}"), ] { if let Ok(v) = HeaderValue::from_str(&cookie) { response.headers_mut().append(header::SET_COOKIE, v); } } response .headers_mut() .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); } pub(crate) fn clear_legacy_cookie(response: &mut Response) { let suffix = if secure() { "; Secure" } else { "" }; if let Ok(value) = HeaderValue::from_str(&format!( "laravel_token=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{suffix}" )) { response.headers_mut().append(header::SET_COOKIE, value); } } fn error(status: StatusCode, message: &str) -> Response { (status, Json(json!({"message":message}))).into_response() } pub(crate) fn csrf_valid(headers: &HeaderMap, expected: &str) -> bool { headers .get("x-csrf-token") .or_else(|| headers.get("x-xsrf-token")) .and_then(|v| v.to_str().ok()) .filter(|v| v.len() == 64) .is_some_and(|v| bool::from(digest(v).as_bytes().ct_eq(expected.as_bytes()))) } pub(crate) async fn session( db: &SqlitePool, headers: &HeaderMap, ) -> Result, sqlx::Error> { let Some(value) = token(headers) else { return Ok(None); }; sqlx::query("SELECT s.token_hash,s.csrf_hash,s.user_id,u.is_admin FROM rust_sessions s LEFT JOIN users u ON u.id=s.user_id WHERE s.token_hash=? AND s.expires_at>?") .bind(digest(&value)).bind(now()).fetch_optional(db).await } /// Browser guards never authenticate an Authorization bearer token. pub(crate) async fn browser_user( state: &AppState, headers: &HeaderMap, ) -> Result, crate::ApiError> { if let Some(row) = session(&state.db, headers).await? && let (Some(id), Some(is_admin)) = ( row.try_get::, _>("user_id")?, row.try_get::, _>("is_admin")?, ) { return Ok(Some(AuthUser { id, is_admin })); } if let Some(legacy) = crate::laravel_sessions::authenticate(state, headers).await? { return Ok(legacy.user); } Ok(None) } fn named_cookie(headers: &HeaderMap, name: &str) -> Option { let mut found = None; for h in headers.get_all(header::COOKIE) { for part in h.to_str().ok()?.split(';') { if let Some((key, value)) = part.trim().split_once('=') && key == name { if found.is_some() { return None; } found = Some(value.to_owned()); } } } found } pub(crate) async fn browser_csrf_token( state: &AppState, headers: &HeaderMap, ) -> Result, crate::ApiError> { if let Some(row) = session(&state.db, headers).await? && let Some(token) = named_cookie(headers, "XSRF-TOKEN") { let value = percent_encoding::percent_decode_str(&token) .decode_utf8() .ok() .map(|v| v.into_owned()); if let Some(value) = value && bool::from( digest(&value) .as_bytes() .ct_eq(row.get::("csrf_hash").as_bytes()), ) { return Ok(Some(value)); } } Ok(crate::laravel_sessions::authenticate(state, headers) .await? .map(|v| v.csrf)) } pub(crate) async fn browser_session_id( state: &AppState, headers: &HeaderMap, ) -> Result, crate::ApiError> { if let Some(row) = session(&state.db, headers).await? { return Ok(Some(row.try_get("token_hash")?)); } if let Some(legacy) = crate::laravel_sessions::authenticate(state, headers).await? && let Some(id) = legacy.id { return Ok(Some(digest(&format!("laravel-session:{id}")))); } // A recaller identifies an account, not a unique browser session. // The normal HTML bootstrap must first import it into a fresh native SID. Ok(None) } pub(crate) async fn invalidate_browser( state: &AppState, headers: &HeaderMap, ) -> Result<(), crate::ApiError> { let mut users = std::collections::BTreeSet::new(); if let Some(row) = session(&state.db, headers).await? { if let Some(user) = row.try_get::, _>("user_id")? { users.insert(user); } sqlx::query("DELETE FROM rust_sessions WHERE token_hash=?") .bind(row.try_get::("token_hash")?) .execute(&state.db) .await?; } if let Some(legacy) = crate::laravel_sessions::authenticate(state, headers).await? { if let Some(user) = legacy.user.as_ref() { users.insert(user.id); } crate::laravel_sessions::destroy(state, &legacy).await?; } for user in users { crate::laravel_sessions::revoke_remember(state, user).await?; } Ok(()) } pub(crate) fn clear_laravel_session_cookies(response: &mut Response) { let Ok(names) = crate::laravel_sessions::cookie_names() else { return; }; let path = std::env::var("SESSION_PATH").unwrap_or_else(|_| "/".into()); let domain = std::env::var("SESSION_DOMAIN") .ok() .filter(|v| !v.is_empty() && v != "null"); for name in names { let value = format!( "{name}=; Path={path}; HttpOnly; SameSite=Lax; Max-Age=0{}{}", domain .as_ref() .map(|d| format!("; Domain={d}")) .unwrap_or_default(), if secure() { "; Secure" } else { "" } ); if let Ok(value) = HeaderValue::from_str(&value) { response.headers_mut().append(header::SET_COOKIE, value); } } } /// Consent/client administration uses browser state, never an API bearer. pub(crate) async fn require_web_auth( State(state): State, mut request: Request, next: Next, ) -> Response { let mutation = !matches!( *request.method(), Method::GET | Method::HEAD | Method::OPTIONS ); if mutation { let valid = match session(&state.db, request.headers()).await { Ok(Some(row)) => csrf_valid(request.headers(), row.get("csrf_hash")), Ok(None) => { match crate::laravel_sessions::authenticate(&state, request.headers()).await { Ok(Some(legacy)) => { crate::laravel_sessions::csrf_valid(&state, request.headers(), &legacy) } _ => false, } } Err(_) => false, }; if !valid { let expected = match browser_csrf_token(&state, request.headers()).await { Ok(v) => v, Err(e) => return e.into_response(), }; let (parts, body) = request.into_parts(); let bytes = match to_bytes(body, 64 * 1024).await { Ok(bytes) => bytes, Err(_) => return error(StatusCode::PAYLOAD_TOO_LARGE, "Request body too large"), }; let supplied = if parts .headers .get(header::CONTENT_TYPE) .and_then(|h| h.to_str().ok()) .is_some_and(|h| h.starts_with("application/x-www-form-urlencoded")) { serde_urlencoded::from_bytes::>(&bytes) .ok() .and_then(|mut v| v.remove("_token")) } else { serde_json::from_slice::(&bytes) .ok() .and_then(|v| v.get("_token").and_then(|v| v.as_str()).map(str::to_owned)) }; let token_valid = if let Some(supplied) = supplied { if let Some(expected) = expected { bool::from(expected.as_bytes().ct_eq(supplied.as_bytes())) } else { match session(&state.db, &parts.headers).await { Ok(Some(row)) => bool::from( digest(&supplied) .as_bytes() .ct_eq(row.get::("csrf_hash").as_bytes()), ), _ => false, } } } else { false }; if !token_valid { return error(csrf_status(), "CSRF token mismatch."); } request = Request::from_parts(parts, axum::body::Body::from(bytes)); } } let user = match browser_user(&state, request.headers()).await { Ok(Some(user)) => user, Ok(None) => return error(StatusCode::UNAUTHORIZED, "Unauthenticated."), Err(e) => return e.into_response(), }; match active(&state, user.id).await { Ok(true) => (), Ok(false) => return error(StatusCode::IM_A_TEAPOT, "inactivity detected"), Err(e) => return e.into_response(), } if let Ok(Some(legacy)) = crate::laravel_sessions::authenticate(&state, request.headers()).await && let Err(e) = crate::laravel_sessions::touch(&state, &legacy).await { return e.into_response(); } request.extensions_mut().insert(user); next.run(request).await } pub async fn require_auth( State(state): State, mut request: Request, next: Next, ) -> Response { if crate::proxy_auth::enabled() { if crate::proxy_auth::blocked(request.method(), request.uri().path()) { return crate::proxy_auth::blocked_error().into_response(); } let peer = request .extensions() .get::>() .map(|p| p.0); match crate::proxy_auth::authenticate(&state, request.headers(), peer).await { Ok(Some(user)) => { request.extensions_mut().insert(user); return next.run(request).await; } Ok(None) => return error(StatusCode::UNAUTHORIZED, "Unauthenticated."), Err(e) => return e.into_response(), } } if request.headers().contains_key(header::AUTHORIZATION) { match crate::passport::authenticate(&state.db, request.headers()).await { Ok(Some(identity)) => { request.extensions_mut().insert(identity.user.clone()); request.extensions_mut().insert(identity); return next.run(request).await; } Ok(None) => return error(StatusCode::UNAUTHORIZED, "Unauthenticated."), Err(e) => return e.into_response(), } } let original = request .extensions() .get::() .map(|v| v.0.path()) .unwrap_or(request.uri().path()); let api = original.contains("/api/v1/") || original.ends_with("/api/v1"); if !api && session(&state.db, request.headers()) .await .ok() .flatten() .and_then(|r| r.get::, _>("user_id")) .is_none() { match crate::laravel_sessions::authenticate(&state, request.headers()).await { Ok(Some(legacy)) if legacy.user.is_some() => { if !matches!( *request.method(), Method::GET | Method::HEAD | Method::OPTIONS ) && !crate::laravel_sessions::csrf_valid(&state, request.headers(), &legacy) { return error(csrf_status(), "CSRF token mismatch"); } let user = legacy.user.clone().unwrap(); match active(&state, user.id).await { Ok(true) => (), Ok(false) => { let _ = crate::laravel_sessions::destroy(&state, &legacy).await; return error(StatusCode::IM_A_TEAPOT, "inactivity detected"); } Err(e) => return e.into_response(), } if let Err(e) = crate::laravel_sessions::touch(&state, &legacy).await { return e.into_response(); } request.extensions_mut().insert(user); return next.run(request).await; } Ok(_) => (), Err(e) => return e.into_response(), } } let native = match session(&state.db, request.headers()).await { Ok(value) => value, Err(_) => { return error( StatusCode::SERVICE_UNAVAILABLE, "Authentication unavailable", ); } }; // Once imported, the native browser session is authoritative. A fresh // Passport cookie must not force CSRF headers onto read-only native calls. if native .as_ref() .is_none_or(|row| row.get::, _>("user_id").is_none()) { match crate::passport::cookie_authenticate(&state, request.headers()).await { Ok(Some(user)) => { match active(&state, user.id).await { Ok(true) => (), Ok(false) => return error(StatusCode::IM_A_TEAPOT, "inactivity detected"), Err(e) => return e.into_response(), } request.extensions_mut().insert(user); return next.run(request).await; } Ok(None) => (), Err(e) => return e.into_response(), } } let Some(row) = native else { return error(StatusCode::UNAUTHORIZED, "Unauthenticated."); }; let Some(id) = row.get::, _>("user_id") else { return error(StatusCode::UNAUTHORIZED, "Unauthenticated."); }; let Some(admin) = row.get::, _>("is_admin") else { return error(StatusCode::UNAUTHORIZED, "Unauthenticated."); }; if !matches!( *request.method(), Method::GET | Method::HEAD | Method::OPTIONS ) && !csrf_valid(request.headers(), row.get("csrf_hash")) { return error(csrf_status(), "CSRF token mismatch"); } match active(&state, id).await { Ok(true) => (), Ok(false) => { let _ = sqlx::query("DELETE FROM rust_sessions WHERE token_hash=?") .bind(row.get::("token_hash")) .execute(&state.db) .await; return error(StatusCode::IM_A_TEAPOT, "inactivity detected"); } Err(e) => return e.into_response(), } if sqlx::query("UPDATE rust_sessions SET expires_at=? WHERE token_hash=?") .bind(now() + TTL) .bind(row.get::("token_hash")) .execute(&state.db) .await .is_err() { return error( StatusCode::SERVICE_UNAVAILABLE, "Authentication unavailable", ); } request.extensions_mut().insert(AuthUser { id, is_admin: admin, }); let mut response = next.run(request).await; response .headers_mut() .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); response } async fn active(state: &AppState, id: i64) -> Result { let row = sqlx::query("SELECT preferences,last_seen_at FROM users WHERE id=?") .bind(id) .fetch_one(&state.db) .await?; let raw: Option = row.try_get("preferences")?; let prefs = crate::settings::preferences_from(raw.as_deref()); let minutes = prefs["kickUserAfter"] .as_i64() .or_else(|| prefs["kickUserAfter"].as_str().and_then(|v| v.parse().ok())) .unwrap_or(0); let last: Option = row.try_get("last_seen_at")?; let inactive = last .as_deref() .and_then(|v| { chrono::NaiveDateTime::parse_from_str(v, "%Y-%m-%d %H:%M:%S%.f") .ok() .map(|v| v.and_utc().timestamp()) .or_else(|| { chrono::DateTime::parse_from_rfc3339(v) .ok() .map(|v| v.timestamp()) }) }) .is_some_and(|last| minutes > 0 && (now() - last).abs() > minutes.saturating_mul(60)); sqlx::query("UPDATE users SET last_seen_at=CURRENT_TIMESTAMP WHERE id=?") .bind(id) .execute(&state.db) .await?; Ok(!inactive) } pub async fn refresh_csrf(State(state): State, headers: HeaderMap) -> Response { // Bound anonymous bootstrap traffic and discard expired experimental records. match throttle_limit(&state.db, "csrf-bootstrap-global".into(), 120).await { Ok(true) => (), Ok(false) => return error(StatusCode::TOO_MANY_REQUESTS, "Too many session requests"), Err(_) => { return error( StatusCode::SERVICE_UNAVAILABLE, "Authentication unavailable", ); } } if sqlx::query("DELETE FROM rust_sessions WHERE expires_at<=?") .bind(now()) .execute(&state.db) .await .is_err() || sqlx::query("DELETE FROM rust_login_attempts WHERE window v, Err(e) => return e.into_response(), }; let legacy_user = legacy_session .as_ref() .and_then(|v| v.user.as_ref()) .map(|u| u.id) .or( crate::passport::cookie_authenticate(&state, &legacy_headers) .await .ok() .flatten() .map(|u| u.id), ); let csrf = random(); let old = match session(&state.db, &headers).await { Ok(v) => v, Err(_) => { return error( StatusCode::SERVICE_UNAVAILABLE, "Authentication unavailable", ); } }; let (sid, result) = if let Some(ref row) = old && row.get::, _>("user_id").is_none() && legacy_user.is_some() { // Import is an authentication transition. Never promote an existing // anonymous SID that could have been planted in another browser. let sid = random(); let result = async { let mut tx = state.db.begin().await?; let deleted = sqlx::query("DELETE FROM rust_sessions WHERE token_hash=? AND user_id IS NULL AND expires_at>?") .bind(row.get::("token_hash")).bind(now()).execute(&mut *tx).await?; if deleted.rows_affected() != 1 {return Err(sqlx::Error::RowNotFound);} let inserted = sqlx::query("INSERT INTO rust_sessions(token_hash,user_id,csrf_hash,expires_at) VALUES(?,?,?,?)") .bind(digest(&sid)).bind(legacy_user).bind(digest(&csrf)).bind(now()+TTL).execute(&mut *tx).await?; tx.commit().await?; Ok(inserted) }.await; (sid, result) } else if let Some(row) = old { let sid = token(&headers).unwrap(); let result = sqlx::query("UPDATE rust_sessions SET csrf_hash=?,user_id=COALESCE(user_id,?) WHERE token_hash=? AND expires_at>?") .bind(digest(&csrf)) .bind(legacy_user) .bind(row.get::("token_hash")) .bind(now()) .execute(&state.db) .await; (sid, result) } else { let sid = random(); let result = sqlx::query( "INSERT INTO rust_sessions(token_hash,user_id,csrf_hash,expires_at) VALUES (?,?,?,?)", ) .bind(digest(&sid)) .bind(legacy_user) .bind(digest(&csrf)) .bind(now() + TTL) .execute(&state.db) .await; (sid, result) }; if result.is_err() { return error( StatusCode::SERVICE_UNAVAILABLE, "Authentication unavailable", ); } let mut response = csrf.clone().into_response(); response.headers_mut().insert( header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=UTF-8"), ); cookies(&mut response, &sid, &csrf, TTL); if legacy_user.is_some() { if let Some(legacy) = legacy_session.as_ref() && let Err(e) = crate::laravel_sessions::destroy(&state, legacy).await { return e.into_response(); } clear_legacy_cookie(&mut response); clear_laravel_session_cookies(&mut response); } response } #[derive(Deserialize)] struct Credentials { email: String, password: String, } async fn throttle(db: &SqlitePool, key: String) -> Result { throttle_limit(db, key, 10).await } pub(crate) async fn throttle_limit( db: &SqlitePool, key: String, limit: i64, ) -> Result { let count:i64=sqlx::query_scalar("INSERT INTO rust_login_attempts(key,window,attempts) VALUES (?,?,1) ON CONFLICT(key) DO UPDATE SET attempts=CASE WHEN window=excluded.window THEN attempts+1 ELSE 1 END, window=excluded.window RETURNING attempts") .bind(digest(&key)).bind(now()/60).fetch_one(db).await?; Ok(count <= limit) } pub(crate) fn verify(password: String, hash: String) -> bool { if hash.starts_with("$2") { bcrypt::verify(password, &hash).unwrap_or(false) } else { use argon2::{PasswordHash, PasswordVerifier}; PasswordHash::new(&hash).ok().is_some_and(|parsed| { argon2::Argon2::default() .verify_password(password.as_bytes(), &parsed) .is_ok() }) } } async fn login(State(state): State, request: Request) -> Response { let (parts, body) = request.into_parts(); let audit_context = crate::auth_audit::Context::from_parts(&parts); let row = match session(&state.db, &parts.headers).await { Ok(Some(row)) => row, Ok(None) => return error(csrf_status(), "Fetch /refresh-csrf first"), Err(_) => { return error( StatusCode::SERVICE_UNAVAILABLE, "Authentication unavailable", ); } }; if !csrf_valid(&parts.headers, row.get("csrf_hash")) { return error(csrf_status(), "CSRF token mismatch"); } // ConnectInfo is the direct socket peer. Forwarded and X-Forwarded-For are never trusted. let ip = parts .extensions .get::>() .map(|p| p.0.ip().to_string()) .unwrap_or_else(|| "unknown-peer".into()); match throttle(&state.db, format!("peer:{ip}")).await { Ok(true) => (), Ok(false) => return error(StatusCode::TOO_MANY_REQUESTS, "Too many login attempts"), Err(_) => { return error( StatusCode::SERVICE_UNAVAILABLE, "Authentication unavailable", ); } } let bytes = match to_bytes(body, 16 * 1024).await { Ok(v) => v, Err(_) => return error(StatusCode::PAYLOAD_TOO_LARGE, "Invalid login body"), }; let content = parts .headers .get(header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .unwrap_or(""); let credentials: Credentials = match if content.starts_with("application/json") { serde_json::from_slice(&bytes).map_err(|_| ()) } else if content.starts_with("application/x-www-form-urlencoded") { serde_urlencoded::from_bytes(&bytes).map_err(|_| ()) } else { Err(()) } { Ok(v) => v, Err(_) => { return error( StatusCode::UNPROCESSABLE_ENTITY, "Email and password are required", ); } }; if credentials.email.len() > 254 || credentials.password.len() > 4096 || credentials.password.is_empty() { return error(StatusCode::UNPROCESSABLE_ENTITY, "Invalid credentials"); } match throttle( &state.db, format!("email:{}", credentials.email.to_lowercase()), ) .await { Ok(true) => (), Ok(false) => return error(StatusCode::TOO_MANY_REQUESTS, "Too many login attempts"), Err(_) => { return error( StatusCode::SERVICE_UNAVAILABLE, "Authentication unavailable", ); } } let user=match sqlx::query("SELECT id,name,email,password,is_admin,preferences,oauth_provider FROM users WHERE email=?").bind(credentials.email.to_lowercase()).fetch_optional(&state.db).await { Ok(v)=>v,Err(_)=>return error(StatusCode::SERVICE_UNAVAILABLE,"Authentication unavailable") }; // A valid dummy hash ensures unknown accounts still perform password work off the async executor. let hash = user .as_ref() .map(|u| u.get::("password")) .unwrap_or_else(|| { "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6Ttxj9I7gX7dc5BLyO2NLzO5h3Wmi".to_owned() }); static HASH_SLOTS: std::sync::OnceLock> = std::sync::OnceLock::new(); let slots = HASH_SLOTS .get_or_init(|| std::sync::Arc::new(tokio::sync::Semaphore::new(4))) .clone(); let permit = match slots.try_acquire_owned() { Ok(p) => p, Err(_) => return error(StatusCode::TOO_MANY_REQUESTS, "Authentication busy"), }; let valid = tokio::task::spawn_blocking(move || { let _permit = permit; verify(credentials.password, hash) }) .await .unwrap_or(false); if !valid && let Some(user) = user.as_ref() && crate::auth_audit::failed(&state, user.get("id"), &audit_context, Some("password")) .await .is_err() { tracing::error!("Failed-login audit persistence failed"); } let Some(user) = user.filter(|_| valid) else { return error( StatusCode::UNAUTHORIZED, "These credentials do not match our records", ); }; let admin: bool = user.get("is_admin"); let raw_preferences = user.get::, _>("preferences"); let preferences = crate::settings::preferences_from(raw_preferences.as_deref()); if preferences["useWebauthnOnly"].as_bool() == Some(true) { return error( StatusCode::UNAUTHORIZED, "These credentials do not match our records", ); } let app_settings = match crate::settings::regular_settings(&state.db).await { Ok(v) => v, Err(_) => { return error( StatusCode::SERVICE_UNAVAILABLE, "Authentication unavailable", ); } }; if app_settings["useSsoOnly"].as_bool() == Some(true) && !admin { return error(StatusCode::FORBIDDEN, "Password login is disabled"); } let sid = random(); let csrf = random(); let mut tx = match state.db.begin().await { Ok(v) => v, Err(_) => { return error( StatusCode::SERVICE_UNAVAILABLE, "Authentication unavailable", ); } }; // Delete the old session atomically; concurrent requests using it cannot both log in. let deleted = sqlx::query("DELETE FROM rust_sessions WHERE token_hash=? AND expires_at>?") .bind(row.get::("token_hash")) .bind(now()) .execute(&mut *tx) .await; if !matches!(deleted,Ok(ref v) if v.rows_affected()==1) { return error(csrf_status(), "Session expired"); } if sqlx::query("UPDATE users SET last_seen_at=CURRENT_TIMESTAMP WHERE id=?") .bind(user.get::("id")) .execute(&mut *tx) .await .is_err() { return error( StatusCode::SERVICE_UNAVAILABLE, "Authentication unavailable", ); } if sqlx::query( "INSERT INTO rust_sessions(token_hash,user_id,csrf_hash,expires_at) VALUES (?,?,?,?)", ) .bind(digest(&sid)) .bind(user.get::("id")) .bind(digest(&csrf)) .bind(now() + TTL) .execute(&mut *tx) .await .is_err() || tx.commit().await.is_err() { return error( StatusCode::SERVICE_UNAVAILABLE, "Authentication unavailable", ); } // The request still carries the old cookies, so the newly issued SID is // unaffected while stale PHP sessions and recallers are revoked. if let Err(e) = invalidate_browser(&state, &parts.headers).await { return e.into_response(); } if crate::auth_audit::login(&state, user.get("id"), &audit_context, Some("password")) .await .is_err() { return error( StatusCode::SERVICE_UNAVAILABLE, "Authentication unavailable", ); } let mut response=Json(json!({"message":"authenticated","id":user.get::("id"),"name":user.get::("name"),"email":user.get::("email"),"is_admin":admin,"preferences":preferences,"oauth_provider":user.get::,_>("oauth_provider"),"appSettings":app_settings})).into_response(); cookies(&mut response, &sid, &csrf, TTL); clear_legacy_cookie(&mut response); clear_laravel_session_cookies(&mut response); response } async fn logout(State(state): State, request: Request) -> Response { let context = crate::auth_audit::Context::from_request(&request); let headers = request.headers(); let user = match browser_user(&state, headers).await { Ok(Some(user)) => user, Ok(None) => return error(StatusCode::UNAUTHORIZED, "Unauthenticated."), Err(e) => return e.into_response(), }; let csrf = match session(&state.db, headers).await { Ok(Some(row)) => csrf_valid(headers, row.get("csrf_hash")), Ok(None) => match crate::laravel_sessions::authenticate(&state, headers).await { Ok(Some(v)) => crate::laravel_sessions::csrf_valid(&state, headers, &v), _ => false, }, Err(_) => false, }; if !csrf { return error(csrf_status(), "CSRF token mismatch"); } if let Err(e) = invalidate_browser(&state, headers).await { return e.into_response(); } if crate::auth_audit::logout(&state, user.id, &context, None) .await .is_err() { return error( StatusCode::SERVICE_UNAVAILABLE, "Authentication unavailable", ); } let mut response = Json(json!({"message":"signed out"})).into_response(); cookies(&mut response, "", "", 0); clear_legacy_cookie(&mut response); clear_laravel_session_cookies(&mut response); response } #[cfg(test)] mod tests { // Password handlers deliberately reject overload; keep integration logins // deterministic instead of competing for the process-wide hash semaphore. static LOGIN_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); use super::*; #[test] fn session_cookie_parsing() { let mut h = HeaderMap::new(); h.insert( header::COOKIE, HeaderValue::from_str(&format!("{COOKIE}={}", "a".repeat(64))).unwrap(), ); assert_eq!(token(&h), Some("a".repeat(64))); h.append( header::COOKIE, HeaderValue::from_str(&format!("{COOKIE}={}", "b".repeat(64))).unwrap(), ); assert!(token(&h).is_none()); } #[test] fn csrf_is_bound_to_session() { let mut h = HeaderMap::new(); h.insert( "x-csrf-token", HeaderValue::from_static( "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ), ); assert!(csrf_valid(&h, &digest(&"a".repeat(64)))); assert!(!csrf_valid(&h, &digest(&"b".repeat(64)))); } #[tokio::test] async fn throttling_is_atomic_and_persistent() { let db = SqlitePool::connect("sqlite::memory:").await.unwrap(); init(&db).await.unwrap(); for _ in 0..10 { assert!(throttle(&db, "test".into()).await.unwrap()); } assert!(!throttle(&db, "test".into()).await.unwrap()); } #[test] fn verifies_laravel_bcrypt() { let hash = bcrypt::hash("correct", 4) .unwrap() .replacen("$2b$", "$2y$", 1); assert!(verify("correct".into(), hash.clone())); assert!(!verify("incorrect".into(), hash)); } #[test] fn verifies_argon2() { use argon2::{ PasswordHasher, password_hash::{SaltString, rand_core::OsRng}, }; let salt = SaltString::generate(&mut OsRng); let hash = argon2::Argon2::default() .hash_password(b"correct", &salt) .unwrap() .to_string(); assert!(verify("correct".into(), hash.clone())); assert!(!verify("incorrect".into(), hash)); } async fn setup() -> (AppState, Router) { let db = sqlx::sqlite::SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); crate::db::init(&db).await.unwrap(); init(&db).await.unwrap(); let hash = bcrypt::hash("correct", 4).unwrap(); sqlx::query( "INSERT INTO users(id,name,email,password) VALUES(1,'Alice','alice@example.com',?)", ) .bind(hash) .execute(&db) .await .unwrap(); let state = AppState { db, cipher: None, encrypt_db: std::sync::atomic::AtomicBool::new(false).into(), }; let app = crate::router(state.clone()); (state, app) } #[tokio::test] async fn legacy_import_rotates_anonymous_session_instead_of_promoting_it() { let (mut state, app) = setup().await; let (anonymous, _) = bootstrap(app).await; state.cipher = Some( crate::crypto::LaravelCipher::from_app_key("0123456789abcdef0123456789abcdef") .unwrap() .into(), ); sqlx::query("UPDATE users SET remember_token='remember-fixture' WHERE id=1") .execute(&state.db) .await .unwrap(); let name = crate::laravel_sessions::recaller_name(); let recaller = state .cipher .as_ref() .unwrap() .encrypt_cookie(&name, "1|remember-fixture|unused") .unwrap(); let mut headers = HeaderMap::new(); headers.insert( header::COOKIE, HeaderValue::from_str(&format!("{anonymous}; {name}={recaller}")).unwrap(), ); let response = refresh_csrf(State(state.clone()), headers.clone()).await; assert_eq!(response.status(), StatusCode::OK); let imported = cookie(&response); assert_ne!(imported, anonymous); assert!(session(&state.db, &headers).await.unwrap().is_none()); headers.insert(header::COOKIE, HeaderValue::from_str(&imported).unwrap()); assert_eq!( session(&state.db, &headers) .await .unwrap() .unwrap() .get::, _>("user_id"), Some(1) ); } #[tokio::test] async fn invalidation_revokes_native_and_stale_recaller_users() { let (mut state, _) = setup().await; state.cipher = Some( crate::crypto::LaravelCipher::from_app_key("0123456789abcdef0123456789abcdef") .unwrap() .into(), ); sqlx::query("UPDATE users SET remember_token='native-remember' WHERE id=1") .execute(&state.db) .await .unwrap(); sqlx::query("INSERT INTO users(id,name,email,password,remember_token) VALUES(2,'Bob','bob@example.com','unused','legacy-remember')").execute(&state.db).await.unwrap(); let sid = random(); sqlx::query( "INSERT INTO rust_sessions(token_hash,user_id,csrf_hash,expires_at) VALUES(?,1,?,?)", ) .bind(digest(&sid)) .bind(digest("csrf")) .bind(now() + TTL) .execute(&state.db) .await .unwrap(); let name = crate::laravel_sessions::recaller_name(); let recaller = state .cipher .as_ref() .unwrap() .encrypt_cookie(&name, "2|legacy-remember|unused") .unwrap(); let mut headers = HeaderMap::new(); headers.insert( header::COOKIE, HeaderValue::from_str(&format!("{COOKIE}={sid}; {name}={recaller}")).unwrap(), ); invalidate_browser(&state, &headers).await.unwrap(); assert!(session(&state.db, &headers).await.unwrap().is_none()); assert!( crate::laravel_sessions::authenticate(&state, &headers) .await .unwrap() .is_none() ); let tokens: Vec = sqlx::query_scalar("SELECT remember_token FROM users ORDER BY id") .fetch_all(&state.db) .await .unwrap(); assert_ne!(tokens[0], "native-remember"); assert_ne!(tokens[1], "legacy-remember"); } async fn request( app: Router, method: &str, path: &str, cookie: Option<&str>, csrf: Option<&str>, body: serde_json::Value, ) -> Response { use tower::ServiceExt; let mut builder = axum::http::Request::builder() .method(method) .uri(path) .header("content-type", "application/json"); if let Some(cookie) = cookie { builder = builder.header("cookie", cookie); } if let Some(csrf) = csrf { builder = builder.header("x-csrf-token", csrf); } app.oneshot( builder .body(axum::body::Body::from(body.to_string())) .unwrap(), ) .await .unwrap() } fn cookie(response: &Response) -> String { response .headers() .get_all(header::SET_COOKIE) .iter() .filter_map(|v| v.to_str().ok()) .find(|v| v.starts_with(COOKIE)) .unwrap() .split(';') .next() .unwrap() .to_owned() } async fn bootstrap(app: Router) -> (String, String) { let response = request(app, "GET", "/refresh-csrf", None, None, json!(null)).await; assert_eq!(response.status(), StatusCode::OK); let c = cookie(&response); assert!( response .headers() .get(header::SET_COOKIE) .unwrap() .to_str() .unwrap() .contains("HttpOnly") ); let csrf = String::from_utf8(to_bytes(response.into_body(), 1024).await.unwrap().to_vec()) .unwrap(); (c, csrf) } async fn login_as(app: Router, email: &str, password: &str) -> Response { let (c, csrf) = bootstrap(app.clone()).await; request( app, "POST", "/user/login", Some(&c), Some(&csrf), json!({"email":email,"password":password}), ) .await } #[tokio::test] async fn public_login_failure_success_and_logout_write_audit_history() { use tower::ServiceExt; let _test_guard = LOGIN_TEST_LOCK.lock().await; let (state, app) = setup().await; let (anonymous, csrf) = bootstrap(app.clone()).await; let send = |method: &str, path: &str, cookie: &str, csrf: &str, payload: serde_json::Value| { let request = axum::http::Request::builder() .method(method) .uri(path) .header("content-type", "application/json") .header("cookie", cookie) .header("x-csrf-token", csrf) .header("user-agent", "Public audit fixture/1.0") .header("x-forwarded-for", "198.51.100.123") .extension(ConnectInfo( "127.0.0.1:43210".parse::().unwrap(), )) .body(axum::body::Body::from(payload.to_string())) .unwrap(); app.clone().oneshot(request) }; let failed = send( "POST", "/user/login", &anonymous, &csrf, json!({"email":"alice@example.com","password":"wrong"}), ) .await .unwrap(); assert_eq!(failed.status(), StatusCode::UNAUTHORIZED); let login = send( "POST", "/user/login", &anonymous, &csrf, json!({"email":"alice@example.com","password":"correct"}), ) .await .unwrap(); assert_eq!(login.status(), StatusCode::OK); let authenticated = cookie(&login); let new_csrf = login .headers() .get_all(header::SET_COOKIE) .iter() .filter_map(|h| h.to_str().ok()) .find_map(|h| { h.strip_prefix("XSRF-TOKEN=") .and_then(|h| h.split(';').next()) }) .unwrap() .to_owned(); assert_eq!( send("POST", "/user/logout", &authenticated, &new_csrf, json!({})) .await .unwrap() .status(), StatusCode::OK ); let rows = sqlx::query("SELECT * FROM auth_logs WHERE authenticatable_id=1 ORDER BY id") .fetch_all(&state.db) .await .unwrap(); assert_eq!(rows.len(), 2); assert!(!rows[0].get::("login_successful")); assert!(rows[1].get::("login_successful")); assert!(rows[1].get::, _>("logout_at").is_some()); for row in rows { assert_eq!(row.get::("ip_address"), "127.0.0.1"); assert_eq!( row.get::("user_agent"), "Public audit fixture/1.0" ); assert_eq!(row.get::("guard"), "web-guard"); assert_eq!(row.get::("login_method"), "password"); } } #[tokio::test] async fn cookie_login_rotation_crud_csrf_logout_and_expiration() { let _test_guard = LOGIN_TEST_LOCK.lock().await; let (state, app) = setup().await; assert_eq!( request( app.clone(), "GET", "/api/v1/twofaccounts", None, None, json!(null) ) .await .status(), StatusCode::UNAUTHORIZED ); assert_eq!( request( app.clone(), "POST", "/user/login", None, None, json!({"email":"alice@example.com","password":"correct"}) ) .await .status(), csrf_status() ); let (anonymous, csrf) = bootstrap(app.clone()).await; let response = request( app.clone(), "POST", "/user/login", Some(&anonymous), Some(&csrf), json!({"email":"ALICE@example.com","password":"correct"}), ) .await; assert_eq!(response.status(), StatusCode::OK); let session = cookie(&response); let new_csrf = response .headers() .get_all(header::SET_COOKIE) .iter() .filter_map(|h| h.to_str().ok()) .find_map(|h| { h.strip_prefix("XSRF-TOKEN=") .and_then(|h| h.split(';').next()) }) .unwrap() .to_owned(); assert_ne!(session, anonymous); let payload: serde_json::Value = serde_json::from_slice(&to_bytes(response.into_body(), 64 * 1024).await.unwrap()) .unwrap(); assert!(payload.get("csrf_token").is_none()); assert_ne!(new_csrf, csrf); assert_eq!( request( app.clone(), "GET", "/api/v1/twofaccounts", Some(&anonymous), None, json!(null) ) .await .status(), StatusCode::UNAUTHORIZED ); assert_eq!( request( app.clone(), "GET", "/api/v1/twofaccounts", Some(&session), None, json!(null) ) .await .status(), StatusCode::OK ); let account = json!({"otp_type":"totp","account":"alice","secret":"GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ","digits":6,"algorithm":"sha1","period":30}); assert_eq!( request( app.clone(), "POST", "/api/v1/twofaccounts", Some(&session), None, account.clone() ) .await .status(), csrf_status() ); assert_eq!( request( app.clone(), "POST", "/api/v1/twofaccounts", Some(&session), Some(&csrf), account.clone() ) .await .status(), csrf_status() ); assert_eq!( request( app.clone(), "POST", "/api/v1/twofaccounts", Some(&session), Some(&new_csrf), account ) .await .status(), StatusCode::CREATED ); let plain = session.split_once('=').unwrap().1; let stored: String = sqlx::query_scalar("SELECT token_hash FROM rust_sessions WHERE user_id=1") .fetch_one(&state.db) .await .unwrap(); assert_eq!(stored, digest(plain)); assert_ne!(stored, plain); assert_eq!( request( app.clone(), "POST", "/user/logout", Some(&session), None, json!(null) ) .await .status(), csrf_status() ); assert_eq!( request( app.clone(), "GET", "/user/logout", Some(&session), None, json!(null) ) .await .status(), csrf_status() ); assert_eq!( request( app.clone(), "GET", "/user/logout", Some(&session), Some(&new_csrf), json!(null) ) .await .status(), StatusCode::OK ); assert_eq!( request( app.clone(), "GET", "/api/v1/twofaccounts", Some(&session), None, json!(null) ) .await .status(), StatusCode::UNAUTHORIZED ); let response = login_as(app.clone(), "alice@example.com", "correct").await; assert_eq!(response.status(), StatusCode::OK); let expired = cookie(&response); sqlx::query("UPDATE rust_sessions SET expires_at=0") .execute(&state.db) .await .unwrap(); assert_eq!( request( app, "GET", "/api/v1/twofaccounts", Some(&expired), None, json!(null) ) .await .status(), StatusCode::UNAUTHORIZED ); } #[tokio::test] async fn respects_existing_sso_and_webauthn_only_accounts() { let _test_guard = LOGIN_TEST_LOCK.lock().await; let (state, app) = setup().await; sqlx::query("INSERT INTO options(key,value) VALUES('useSsoOnly','{{1}}')") .execute(&state.db) .await .unwrap(); assert_eq!( login_as(app.clone(), "alice@example.com", "correct") .await .status(), StatusCode::FORBIDDEN ); sqlx::query("UPDATE users SET is_admin=1") .execute(&state.db) .await .unwrap(); assert_eq!( login_as(app.clone(), "alice@example.com", "correct") .await .status(), StatusCode::OK ); sqlx::query("UPDATE users SET preferences='{\"useWebauthnOnly\":true}'") .execute(&state.db) .await .unwrap(); assert_eq!( login_as(app, "alice@example.com", "correct").await.status(), StatusCode::UNAUTHORIZED ); } #[tokio::test] async fn password_login_is_throttled_without_proxy_trust() { let _test_guard = LOGIN_TEST_LOCK.lock().await; let (_state, app) = setup().await; let (c, csrf) = bootstrap(app.clone()).await; for _ in 0..10 { assert_eq!( request( app.clone(), "POST", "/user/login", Some(&c), Some(&csrf), json!({"email":"alice@example.com","password":"wrong"}) ) .await .status(), StatusCode::UNAUTHORIZED ); } assert_eq!( request( app, "POST", "/user/login", Some(&c), Some(&csrf), json!({"email":"alice@example.com","password":"correct"}) ) .await .status(), StatusCode::TOO_MANY_REQUESTS ); } #[tokio::test] async fn accepts_frontend_xsrf_header_and_urlencoded_credentials() { let _test_guard = LOGIN_TEST_LOCK.lock().await; use tower::ServiceExt; let (state, app) = setup().await; let (c, csrf) = bootstrap(app.clone()).await; let req = axum::http::Request::builder() .method("POST") .uri("/user/login") .header("cookie", c) .header("x-xsrf-token", csrf) .header("content-type", "application/x-www-form-urlencoded") .body(axum::body::Body::from( "email=alice%40example.com&password=correct", )) .unwrap(); let response = app.oneshot(req).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let seen: Option = sqlx::query_scalar("SELECT last_seen_at FROM users WHERE id=1") .fetch_one(&state.db) .await .unwrap(); assert!(seen.is_some()); } #[tokio::test] async fn spoofing_forwarded_headers_does_not_reset_peer_throttle() { let _test_guard = LOGIN_TEST_LOCK.lock().await; use tower::ServiceExt; let (_state, app) = setup().await; let (c, csrf) = bootstrap(app.clone()).await; for i in 1..=11 { let req = axum::http::Request::builder() .method("POST") .uri("/user/login") .header("cookie", &c) .header("x-csrf-token", &csrf) .header("x-forwarded-for", format!("203.0.113.{i}")) .header("forwarded", format!("for=203.0.113.{i}")) .header("content-type", "application/json") .body(axum::body::Body::from( json!({"email":"alice@example.com","password":"incorrect"}).to_string(), )) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); assert_eq!( response.status(), if i <= 10 { StatusCode::UNAUTHORIZED } else { StatusCode::TOO_MANY_REQUESTS } ); } } }