use std::sync::Arc; use axum::{ extract::{FromRequestParts, Path}, http::{header, request::Parts, HeaderMap, HeaderValue, StatusCode}, response::{IntoResponse, Response}, Json, }; use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString}; use serde::{Deserialize, Serialize}; use serde_json::json; use crate::error::{AppError, AppResult}; use crate::state::{AppState, Cx}; use crate::util::{gen_token, new_id, now_iso, plus_hours, sha256_hex}; pub const COOKIE_NAME: &str = "sl_session"; const USER_COLS_FULL: &str = "id,email,password_hash,name,role,active,ai_enabled,daily_run_limit,created_at"; struct RowUser { id: String, email: String, password_hash: String, name: String, role: String, active: i64, ai_enabled: i64, daily_run_limit: i64, created_at: String, } fn row_user(r: &rusqlite::Row) -> rusqlite::Result { Ok(RowUser { id: r.get(0)?, email: r.get(1)?, password_hash: r.get(2)?, name: r.get(3)?, role: r.get(4)?, active: r.get(5)?, ai_enabled: r.get(6)?, daily_run_limit: r.get(7)?, created_at: r.get(8)?, }) } fn user_json(u: &RowUser) -> serde_json::Value { json!({ "id": u.id, "email": u.email, "name": u.name, "role": u.role, "active": u.active != 0, "ai_enabled": u.ai_enabled != 0, "daily_run_limit": u.daily_run_limit, "created_at": u.created_at, }) } #[derive(Debug, Clone, Serialize)] pub struct User { pub id: String, pub email: String, pub name: String, pub role: String, pub active: bool, pub ai_enabled: bool, pub daily_run_limit: i64, pub created_at: String, } #[derive(Clone)] pub struct AuthUser { pub id: String, pub email: String, pub role: String, pub ai_enabled: bool, pub daily_run_limit: i64, pub session_id: String, } impl FromRequestParts> for AuthUser { type Rejection = AppError; async fn from_request_parts(parts: &mut Parts, state: &Arc) -> Result { let Some(token) = cookie_token(&parts.headers) else { return Err(AppError::unauthorized("authentication required")); }; if token.len() < 32 { return Err(AppError::unauthorized("invalid or expired session")); } let id = sha256_hex(token.as_bytes()); let now = now_iso(); let user = state.with_db(|db| { db.query_row( "SELECT u.id,u.email,u.role,u.ai_enabled,u.daily_run_limit,s.id FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.id=?1 AND s.expires_at > ?2 AND u.active=1", [&id, &now], |r| Ok(AuthUser { id: r.get(0)?, email: r.get(1)?, role: r.get(2)?, ai_enabled: r.get::<_, i64>(3)? != 0, daily_run_limit: r.get(4)?, session_id: r.get(5)? }), ).ok() }).await; user.ok_or_else(|| AppError::unauthorized("invalid or expired session")) } } /// Argon2id password hashing with a fresh random salt per call. pub fn hash_password(p: &str) -> AppResult { let salt = SaltString::generate(&mut rand::rngs::OsRng); Argon2::default().hash_password(p.as_bytes(), &salt) .map(|h| h.to_string()) .map_err(|e| AppError::internal(format!("hash: {e}"))) } pub fn verify_password(hash: &str, p: &str) -> bool { PasswordHash::new(hash).ok() .and_then(|h| Argon2::default().verify_password(p.as_bytes(), &h).ok()) .is_some() } pub async fn fetch_user(cx: &Cx, id: &str) -> AppResult { cx.with_db(|db| { db.query_row( "SELECT id,email,name,role,active,ai_enabled,daily_run_limit,created_at FROM users WHERE id=?1", [id], |r| Ok(User { id: r.get(0)?, email: r.get(1)?, name: r.get(2)?, role: r.get(3)?, active: r.get::<_, i64>(4)? != 0, ai_enabled: r.get::<_, i64>(5)? != 0, daily_run_limit: r.get(6)?, created_at: r.get(7)? }), ).map_err(|_| AppError::not_found("user not found")) }).await } pub fn cookie_token(headers: &HeaderMap) -> Option { let raw = headers.get(header::COOKIE)?.to_str().ok()?; raw.split(';').find_map(|c| { let c = c.trim(); c.strip_prefix(COOKIE_NAME) .and_then(|v| v.strip_prefix('=')) .map(str::to_string) .filter(|v| !v.is_empty()) }) } /// Issue a random 256-bit session token; only sha256(token) is stored. pub async fn session_cookie(cx: &Cx, user_id: &str) -> AppResult<(String, String)> { let token = gen_token(); let id = sha256_hex(token.as_bytes()); let expires = plus_hours(cx.cfg.session_hours); cx.with_db(|db| -> AppResult<()> { db.execute("INSERT INTO sessions (id,user_id,created_at,expires_at) VALUES (?1,?2,?3,?4)", rusqlite::params![&id, user_id, now_iso(), &expires])?; Ok(()) }).await?; let mut cookie = format!("{COOKIE_NAME}={token}; Path=/; HttpOnly; SameSite=Strict; Max-Age={}", cx.cfg.session_hours * 3600); if cx.cfg.secure_cookies { cookie.push_str("; Secure"); } Ok((cookie, expires)) } pub fn clear_cookie() -> String { format!("{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0") } pub async fn revoke_all_sessions(cx: &Cx, user_id: &str) -> AppResult<()> { cx.with_db(|db| -> AppResult<()> { db.execute("DELETE FROM sessions WHERE user_id=?1", [user_id])?; Ok(()) }).await } /// Write a sanitized audit event. Callers must never pass passwords, keys, /// tokens, strategy source or object payloads; only fixed action labels and /// id/email targets, which sanitize_target bounds before storage. pub async fn audit(cx: &Cx, actor: Option<&str>, action: &str, target: &str, status: &str) { let target = sanitize_target(target); cx.with_db(|db| { db.execute("INSERT INTO audit (ts,actor_id,action,target,status) VALUES (?1,?2,?3,?4,?5)", rusqlite::params![now_iso(), actor.map(str::to_string), action, target, status]).ok() }).await; } fn sanitize_target(t: &str) -> String { if t.len() > 300 || t.lines().count() > 3 { "redacted-oversized".to_string() } else { t.to_string() } } fn with_cookie(mut resp: Response, cookie: &str) -> Response { if let Ok(hv) = HeaderValue::from_str(cookie) { resp.headers_mut().insert(header::SET_COOKIE, hv); } resp } async fn record_failure(cx: &Cx, email: &str) { cx.with_db(|db| { db.execute("INSERT INTO login_failures (email,failed_at) VALUES (?1,?2)", rusqlite::params![email.to_string(), now_iso()]).ok() }).await; } /// Admin-issued one-time hashed token ("invitations" or "password_resets"). /// Returns (raw_token shown once, expires_at). Only sha256 is persisted. pub async fn make_admin_token( cx: &Cx, table: &str, user_id: Option<&str>, hours: i64, email: Option<&str>, ) -> AppResult<(String, String)> { let token = gen_token(); let th = sha256_hex(token.as_bytes()); let expires = plus_hours(hours); let id = new_id(); let email = email.map(|e| e.trim().to_lowercase()); cx.with_db(|db| -> AppResult<()> { match table { "invitations" => { db.execute("INSERT INTO invitations (id,email,token_hash,role,expires_at,created_at) VALUES (?1,?2,?3,'member',?4,?5)", rusqlite::params![&id, email, &th, &expires, now_iso()])?; } "password_resets" => { let uid = user_id.ok_or_else(|| AppError::bad("validation", "reset requires target user"))?; db.execute("INSERT INTO password_resets (id,user_id,token_hash,expires_at) VALUES (?1,?2,?3,?4)", rusqlite::params![&id, uid, &th, &expires])?; } other => return Err(AppError::internal(format!("unknown token table {other}"))), } Ok(()) }).await?; Ok((token, expires)) } pub async fn purge_expired(cx: &Cx) { cx.with_db(|db| { let now = now_iso(); db.execute("DELETE FROM sessions WHERE expires_at <= ?1", [&now]).ok(); db.execute("DELETE FROM invitations WHERE expires_at <= ?1", [&now]).ok(); db.execute("DELETE FROM password_resets WHERE expires_at <= ?1", [&now]).ok(); db.execute("DELETE FROM login_failures WHERE failed_at <= ?1", [&plus_hours(-2)]).ok(); }).await; } // ---- handlers ---- #[derive(Deserialize)] pub struct LoginReq { pub email: String, pub password: String } pub async fn login(cx: Cx, body: Option>) -> AppResult { let Json(r) = body.ok_or_else(|| AppError::bad("invalid_body", "JSON body required"))?; let email = r.email.trim().to_lowercase(); if email.is_empty() || r.password.is_empty() { return Err(AppError::bad("validation", "email and password required")); } let failures: i64 = cx.with_db(|db| { db.query_row("SELECT COUNT(*) FROM login_failures WHERE email=?1 AND failed_at >= ?2", [&email, &plus_hours(-1)], |row| row.get(0)).unwrap_or(0) }).await; if failures > 20 { audit(&cx, None, "login_throttled", &email, "rate_limited").await; return Err(AppError::new(StatusCode::TOO_MANY_REQUESTS, "rate_limited", "too many failed logins; try again later")); } let Some(u) = cx.with_db(|db| { db.query_row(&format!("SELECT {USER_COLS_FULL} FROM users WHERE email=?1"), [&email], row_user).ok() }).await else { record_failure(&cx, &email).await; audit(&cx, None, "login_failed", &email, "invalid_credentials").await; return Err(AppError::unauthorized("invalid credentials")); }; if !verify_password(&u.password_hash, &r.password) { record_failure(&cx, &email).await; audit(&cx, Some(&u.id), "login_failed", &u.id, "invalid_credentials").await; return Err(AppError::unauthorized("invalid credentials")); } if u.active == 0 { audit(&cx, Some(&u.id), "login_denied", &u.email, "disabled").await; return Err(AppError::forbidden("account is disabled")); } purge_expired(&cx).await; let (cookie, _) = session_cookie(&cx, &u.id).await?; audit(&cx, Some(&u.id), "login", &u.id, "ok").await; let resp = Json(user_json(&u)).into_response(); Ok(with_cookie(resp, &cookie)) } /// Registration payload has NO role field: role comes only from the /// admin-issued invitation ("member" only in this POC). serde ignores extra /// client fields, so role escalation at registration is impossible. #[derive(Deserialize)] pub struct RegisterReq { pub invite_token: String, pub name: String, pub email: String, pub password: String, } pub async fn register(cx: Cx, body: Option>) -> AppResult { let Json(r) = body.ok_or_else(|| AppError::bad("invalid_body", "JSON body required"))?; if r.name.trim().is_empty() || r.name.trim().len() > 200 { return Err(AppError::bad("validation", "name required (max 200)")); } if r.password.len() < 8 || r.password.len() > 256 { return Err(AppError::bad("validation", "password must be 8-256 characters")); } let email = r.email.trim().to_lowercase(); if !email.contains('@') || email.len() < 5 || email.len() > 320 { return Err(AppError::bad("validation", "valid email required")); } let pw = hash_password(&r.password)?; let token_hash = sha256_hex(r.invite_token.as_bytes()); let day_limit = cx.cfg.default_run_limit_per_day; let user_id: String = cx.with_db(|db| -> AppResult { // One-time atomically consumed invitation with email binding: the // select, binding/role/expiry/used checks, user insert and invite // marking all run inside ONE transaction; the marking ( // UPDATE ... WHERE used_by IS NULL) guarantees concurrent retries lose. let now = now_iso(); let tx = db.transaction()?; let invite: Option<(String, String, Option)> = tx.query_row( "SELECT id,role,email FROM invitations WHERE token_hash=?1", [&token_hash], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))).ok(); let Some((inv_id, role, inv_email)) = invite else { return Err(AppError::bad("invite_invalid", "invalid, used or expired invitation token")); }; if role != "member" { return Err(AppError::bad("invite_invalid", "invitation does not permit this role")); } // Email binding: an invitation addressed to a specific email can only // be consumed with that exact (case-insensitive) email. if let Some(bound) = &inv_email { let bound = bound.trim().to_lowercase(); if !bound.is_empty() && bound != email { return Err(AppError::bad("invite_email_mismatch", "invitation is bound to a different email")); } } let expired = tx.query_row("SELECT expires_at FROM invitations WHERE id=?1", [&inv_id], |r| r.get::<_, String>(0)) .map(|exp| exp <= now).unwrap_or(true); if expired { return Err(AppError::bad("invite_invalid", "invalid, used or expired invitation token")); } if tx.query_row("SELECT COUNT(*) FROM users WHERE email=?1", [&email], |c| c.get::<_, i64>(0)).unwrap_or(0) > 0 { return Err(AppError::conflict("email_taken", "an account with this email already exists")); } let uid = new_id(); tx.execute( "INSERT INTO users (id,email,password_hash,name,role,active,ai_enabled,daily_run_limit,created_at) VALUES (?1,?2,?3,?4,'member',1,0,?5,?6)", rusqlite::params![&uid, &email, &pw, r.name.trim(), day_limit, &now])?; let n = tx.execute("UPDATE invitations SET used_by=?1 WHERE id=?2 AND used_by IS NULL AND expires_at > ?3", rusqlite::params![&uid, &inv_id, &now])?; if n != 1 { return Err(AppError::conflict("invite_used", "invitation token already consumed")); } tx.commit()?; Ok(uid) }).await?; audit(&cx, Some(&user_id), "register", &email, "ok").await; purge_expired(&cx).await; let (cookie, _) = session_cookie(&cx, &user_id).await?; let u = fetch_user(&cx, &user_id).await?; let resp = (StatusCode::CREATED, Json(json!({ "user": u }))).into_response(); Ok(with_cookie(resp, &cookie)) } pub async fn logout(cx: Cx, auth: AuthUser, headers: HeaderMap) -> AppResult { if let Some(t) = cookie_token(&headers) { let id = sha256_hex(t.as_bytes()); cx.with_db(|db| { db.execute("DELETE FROM sessions WHERE id=?1", [&id]).ok() }).await; } audit(&cx, Some(&auth.id), "logout", &auth.id, "ok").await; let resp = StatusCode::NO_CONTENT.into_response(); Ok(with_cookie(resp, &clear_cookie())) } pub async fn me(cx: Cx, auth: AuthUser) -> AppResult> { let u = fetch_user(&cx, &auth.id).await?; Ok(Json(json!({ "user": u }))) } #[derive(Deserialize)] pub struct ProfileReq { pub name: String } pub async fn patch_profile(cx: Cx, auth: AuthUser, body: Option>) -> AppResult> { let Json(r) = body.ok_or_else(|| AppError::bad("invalid_body", "JSON body required"))?; let name = r.name.trim().to_string(); if name.is_empty() || name.len() > 200 { return Err(AppError::bad("validation", "name required (max 200)")); } cx.with_db(|db| -> AppResult<()> { let n = db.execute("UPDATE users SET name=?1 WHERE id=?2", rusqlite::params![&name, &auth.id])?; if n == 0 { return Err(AppError::not_found("user not found")); } Ok(()) }).await?; audit(&cx, Some(&auth.id), "profile_update", &auth.id, "ok").await; let u = fetch_user(&cx, &auth.id).await?; Ok(Json(json!({ "user": u }))) } #[derive(Deserialize)] pub struct PasswordReq { pub current_password: String, pub new_password: String } pub async fn change_password(cx: Cx, auth: AuthUser, headers: HeaderMap, body: Option>) -> AppResult> { let Json(r) = body.ok_or_else(|| AppError::bad("invalid_body", "JSON body required"))?; if r.new_password.len() < 8 || r.new_password.len() > 256 { return Err(AppError::bad("validation", "new password must be 8-256 characters")); } let hash: String = cx.with_db(|db| { db.query_row("SELECT password_hash FROM users WHERE id=?1", [&auth.id], |row| row.get(0)) .map_err(|_| AppError::unauthorized("authentication required")) }).await?; if !verify_password(&hash, &r.current_password) { audit(&cx, Some(&auth.id), "password_change", &auth.id, "wrong_current_password").await; return Err(AppError::unauthorized("current password incorrect")); } let new_hash = hash_password(&r.new_password)?; // Keep the current session, revoke all others; update + revoke atomically. let keep = cookie_token(&headers).map(|t| sha256_hex(t.as_bytes())).unwrap_or_default(); cx.with_db(|db| -> AppResult<()> { let tx = db.transaction()?; tx.execute("UPDATE users SET password_hash=?1 WHERE id=?2", rusqlite::params![&new_hash, &auth.id])?; tx.execute("DELETE FROM sessions WHERE user_id=?1 AND id != ?2", rusqlite::params![&auth.id, &keep])?; tx.commit()?; Ok(()) }).await?; audit(&cx, Some(&auth.id), "password_change", &auth.id, "ok").await; Ok(Json(json!({ "ok": true, "other_sessions_revoked": true }))) } /// Own-session listing, sanitized: sha256-derived ids only, never raw tokens; /// expired sessions are never listed. `current` marks the caller's live session. #[derive(Serialize)] pub struct SessionInfo { pub id: String, pub created_at: String, pub expires_at: String } pub async fn list_sessions(cx: Cx, auth: AuthUser) -> AppResult> { let current = auth.session_id.clone(); let rows: Vec<(String, String, String)> = cx.with_db(|db| { let now = now_iso(); let mut st = db.prepare("SELECT id,created_at,expires_at FROM sessions WHERE user_id=?1 AND expires_at > ?2 ORDER BY created_at DESC")?; let rows = st .query_map(rusqlite::params![&auth.id, &now], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))? .collect::, _>>()?; Ok(rows) }).await.map_err(|e: rusqlite::Error| AppError::internal(e.to_string()))?; Ok(Json(json!({ "items": rows.into_iter().map(|(id, c, e)| json!({ "id": id, "created_at": c, "expires_at": e, "current": id == current, })).collect::>() }))) } pub async fn delete_session(cx: Cx, auth: AuthUser, Path(id): Path) -> AppResult> { cx.with_db(|db| -> AppResult<()> { if db.execute("DELETE FROM sessions WHERE id=?1 AND user_id=?2", rusqlite::params![&id, &auth.id])? == 0 { // Never reveal another user's session ids. return Err(AppError::not_found("session not found")); } Ok(()) }).await?; audit(&cx, Some(&auth.id), "session_revoke", &auth.id, "ok").await; Ok(Json(json!({"ok": true}))) } #[derive(Deserialize)] pub struct ResetReq { pub token: String, pub new_password: String } pub async fn reset_password(cx: Cx, body: Option>) -> AppResult> { let Json(r) = body.ok_or_else(|| AppError::bad("invalid_body", "JSON body required"))?; if r.new_password.len() < 8 || r.new_password.len() > 256 { return Err(AppError::bad("validation", "password must be 8-256 characters")); } let new_hash = hash_password(&r.new_password)?; let th = sha256_hex(r.token.as_bytes()); // Single-use consumption plus password update plus session revocation in // ONE transaction: a second submission of the same token cannot succeed. let uid: String = cx.with_db(|db| -> AppResult { let tx = db.transaction()?; let (uid, used): (String, i64) = tx.query_row( "SELECT user_id,used FROM password_resets WHERE token_hash=?1", [&th], |row| Ok((row.get(0)?, row.get(1)?))) .map_err(|_| AppError::bad("reset_invalid", "invalid or expired reset token"))?; if used != 0 { return Err(AppError::conflict("reset_used", "reset token already consumed")); } let expired = tx.query_row("SELECT expires_at FROM password_resets WHERE token_hash=?1", [&th], |row| row.get::<_, String>(0)) .map(|exp| exp <= now_iso()).unwrap_or(true); if expired { return Err(AppError::bad("reset_invalid", "invalid or expired reset token")); } tx.execute("UPDATE password_resets SET used=1 WHERE token_hash=?1", [&th])?; tx.execute("UPDATE users SET password_hash=?1 WHERE id=?2", rusqlite::params![&new_hash, &uid])?; tx.execute("DELETE FROM sessions WHERE user_id=?1", [&uid])?; tx.commit()?; Ok(uid) }).await?; audit(&cx, Some(&uid), "password_reset", &uid, "ok").await; Ok(Json(json!({"ok": true}))) } #[cfg(test)] mod tests { use super::*; use crate::db::init_db; fn st() -> Arc { let conn = rusqlite::Connection::open_in_memory().expect("in-memory db"); init_db(&conn).expect("schema"); let cfg = crate::config::Config { bind_addr: "127.0.0.1:0".into(), canonical_origin: String::new(), secure_cookies: false, db_path: ":memory:".into(), frontend_dir: "frontend/dist".into(), data_dir: std::env::temp_dir().to_string_lossy().into_owned(), worker_image: "test".into(), fetch_timeout_secs: 1, backtest_timeout_secs: 1, run_concurrency: 1, fetch_concurrency: 1, session_hours: 24, bootstrap_admin_email: None, bootstrap_admin_password: None, ai_base_url: "http://127.0.0.1:9".into(), ai_model: "test-model".into(), ai_daily_request_cap: 1, ai_input_token_cap: 1, ai_output_token_cap: 1, ai_enabled_poc: false, default_run_limit_per_day: 10, version: "test".into(), }; Arc::new(AppState { cfg, db: tokio::sync::Mutex::new(conn), run_sem: Arc::new(tokio::sync::Semaphore::new(1)), fetch_sem: Arc::new(tokio::sync::Semaphore::new(1)), }) } fn handle(s: &Arc) -> Cx { axum::extract::State(s.clone()) } fn cookie_of(token: &str) -> HeaderMap { let mut hm = HeaderMap::new(); hm.insert(header::COOKIE, HeaderValue::from_str(&format!("{COOKIE_NAME}={token}")).unwrap()); hm } /// Register through the real handler, then resolve the session exactly the /// way the AuthUser extractor does, to produce a test auth context. async fn register_ok(s: &Arc, email: &str, name: &str, password: &str) -> (AuthUser, HeaderMap) { let (inv, _) = make_admin_token(&handle(s), "invitations", None, 24, Some(email)).await.unwrap(); let body = RegisterReq { invite_token: inv, name: name.into(), email: email.into(), password: password.into(), }; let resp = register(handle(s), Some(Json(body))).await.expect("register ok"); let cookie = resp.headers().get(header::SET_COOKIE).unwrap().to_str().unwrap().to_string(); let tok = cookie.split(';').next().unwrap() .strip_prefix(COOKIE_NAME).and_then(|c| c.strip_prefix('=')) .expect("cookie contains the raw session token").to_string(); let now = now_iso(); let au = s.with_db(|db| { db.query_row( "SELECT u.id,u.email,u.role,u.ai_enabled,u.daily_run_limit,s.id FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.id=?1 AND s.expires_at > ?2", [&sha256_hex(tok.as_bytes()), &now], |r| Ok(AuthUser { id: r.get(0)?, email: r.get(1)?, role: r.get(2)?, ai_enabled: r.get::<_, i64>(3)? != 0, daily_run_limit: r.get(4)?, session_id: r.get(5)? }), ).unwrap() }).await; (au, cookie_of(&tok)) } #[tokio::test] async fn password_hashes_are_argon2() { let h = hash_password("correct horse battery").unwrap(); assert!(h.starts_with("$argon2")); assert!(verify_password(&h, "correct horse battery")); assert!(!verify_password(&h, "wrong password")); } #[tokio::test] async fn register_creates_member_and_ignores_client_role() { let s = st(); let (auth, _) = register_ok(&s, "tester@example.invalid", "Tester User", "a-reasonable-passphrase-1").await; assert_eq!(auth.email, "tester@example.invalid"); assert_eq!(auth.role, "member", "role must never come from client input"); assert!(!auth.ai_enabled, "ai stays off by default for members"); // No password hash ever appears in user-facing rows' JSON contract: // the users table keeps the hash, API payloads never include it. let u = fetch_user(&handle(&s), &auth.id).await.unwrap(); let payload = serde_json::to_string(&u).unwrap(); assert!(!payload.contains("password_hash")); assert!(!payload.contains("argon2")); } #[tokio::test] async fn invitation_is_single_use() { let s = st(); let (inv, _) = make_admin_token(&handle(&s), "invitations", None, 24, None).await.unwrap(); let body = RegisterReq { invite_token: inv, name: "Once".into(), email: "once@example.invalid".into(), password: "a-reasonable-passphrase-1".into(), }; register(handle(&s), Some(Json(body))).await.expect("first use ok"); let consumed: i64 = handle(&s).with_db(|db| db.query_row( "SELECT COUNT(*) FROM invitations WHERE used_by IS NOT NULL", [], |r| r.get(0)).unwrap()).await; assert_eq!(consumed, 1); let token_rows: i64 = handle(&s).with_db(|db| db.query_row( "SELECT COUNT(*) FROM invitations WHERE used_by IS NULL", [], |r| r.get(0)).unwrap()).await; assert_eq!(token_rows, 0, "invitation must be one-time"); } #[tokio::test] async fn invitation_email_binding_enforced() { let s = st(); let (inv, _) = make_admin_token(&handle(&s), "invitations", None, 24, Some("bound@example.invalid")).await.unwrap(); let body = RegisterReq { invite_token: inv, name: "Bound".into(), email: "other@example.invalid".into(), password: "a-reasonable-passphrase-1".into(), }; let err = register(handle(&s), Some(Json(body))).await.err().expect("must reject mismatched email"); assert_eq!(err.code, "invite_email_mismatch"); // The token was NOT consumed by the failed attempt. let unused: i64 = handle(&s).with_db(|db| { db.query_row("SELECT COUNT(*) FROM invitations WHERE used_by IS NULL", [], |r| r.get(0)).unwrap() }).await; assert_eq!(unused, 1); } #[tokio::test] async fn reset_password_is_single_use_and_revokes_sessions() { let s = st(); let (auth, _) = register_ok(&s, "reset@example.invalid", "Reset User", "original-passphrase-1").await; handle(&s).with_db(|db| { db.execute("INSERT INTO sessions (id,user_id,created_at,expires_at) VALUES ('dead-session',?1,?2,?3)", rusqlite::params![&auth.id, now_iso(), plus_hours(1)]).unwrap(); }).await; let (token, _) = make_admin_token(&handle(&s), "password_resets", Some(auth.id.as_str()), 1, None).await.unwrap(); reset_password(handle(&s), Some(Json(ResetReq { token: token.clone(), new_password: "new-passphrase-1".into() }))).await.unwrap(); let sessions: i64 = handle(&s).with_db(|db| db.query_row( "SELECT COUNT(*) FROM sessions WHERE user_id=?1", [&auth.id], |r| r.get(0)).unwrap()).await; assert_eq!(sessions, 0, "reset must revoke all sessions"); let second = reset_password(handle(&s), Some(Json(ResetReq { token, new_password: "new-passphrase-2".into() }))).await; assert!(second.is_err(), "reset token must be single-use"); let hash: String = handle(&s).with_db(|db| db.query_row( "SELECT password_hash FROM users WHERE id=?1", [&auth.id], |r| r.get(0)).unwrap()).await; assert!(verify_password(&hash, "new-passphrase-1")); assert!(!verify_password(&hash, "new-passphrase-2")); } #[tokio::test] async fn change_password_revokes_others_keeps_current() { let s = st(); let (auth, headers) = register_ok(&s, "change@example.invalid", "Change User", "original-passphrase-1").await; let extra = gen_token(); let extra_id = sha256_hex(extra.as_bytes()); handle(&s).with_db(|db| { db.execute("INSERT INTO sessions (id,user_id,created_at,expires_at) VALUES (?1,?2,?3,?4)", rusqlite::params![&extra_id, &auth.id, now_iso(), plus_hours(1)]).unwrap(); }).await; let done = change_password(handle(&s), auth.clone(), headers.clone(), Some(Json(PasswordReq { current_password: "original-passphrase-1".into(), new_password: "brand-new-passphrase".into(), }))).await; done.unwrap_or_else(|e| panic!("change failed: {e}")); let remaining: i64 = handle(&s).with_db(|db| { let now = now_iso(); db.query_row("SELECT COUNT(*) FROM sessions WHERE user_id=?1 AND expires_at > ?2", [&auth.id, &now], |r| r.get(0)).unwrap() }).await; assert_eq!(remaining, 1, "only the current session survives"); let wrong = change_password(handle(&s), auth.clone(), headers, Some(Json(PasswordReq { current_password: "wrong".into(), new_password: "another-passphrase-1".into(), }))).await; assert!(wrong.is_err(), "wrong current password must be rejected"); } #[tokio::test] async fn audit_never_records_secrets() { let s = st(); let long = format!("{}password=secret-value", "x".repeat(400)); audit(&handle(&s), None, "login_failed", &long, "fail").await; let stored: String = handle(&s).with_db(|db| db.query_row( "SELECT target FROM audit ORDER BY seq DESC LIMIT 1", [], |r| r.get(0)).unwrap()).await; assert_eq!(stored, "redacted-oversized"); let dirty: i64 = handle(&s).with_db(|db| { db.query_row("SELECT COUNT(*) FROM audit WHERE target LIKE '%password=%'", [], |r| r.get(0)).unwrap() }).await; assert_eq!(dirty, 0, "secrets must never be written into the audit trail"); } #[tokio::test] async fn cookie_sessions_are_hashed_never_plaintext() { let s = st(); handle(&s).with_db(|db| { db.execute("INSERT INTO users (id,email,password_hash,name,role,active,ai_enabled,daily_run_limit,created_at) VALUES ('some-user','su@example.invalid','h','n','member',1,0,10,?1)", [now_iso()]).unwrap(); }).await; let (cookie, _) = session_cookie(&handle(&s), "some-user").await.unwrap(); let tok = cookie.split(';').next().unwrap() .strip_prefix(COOKIE_NAME).and_then(|c| c.strip_prefix('=')).unwrap(); let plaintext: i64 = handle(&s).with_db(|db| db.query_row( "SELECT COUNT(*) FROM sessions WHERE id=?1", [&tok], |r| r.get(0)).unwrap()).await; assert_eq!(plaintext, 0, "raw token must never be stored"); let hashed: i64 = handle(&s).with_db(|db| db.query_row( "SELECT COUNT(*) FROM sessions WHERE id=?1", [&sha256_hex(tok.as_bytes())], |r| r.get(0)).unwrap()).await; assert_eq!(hashed, 1); assert!(cookie.contains("HttpOnly") && cookie.contains("SameSite=Strict")); } #[tokio::test] async fn login_throttles_after_repeated_failures() { let s = st(); for _ in 0..25 { record_failure(&handle(&s), "flood@example.invalid").await; } let res = login(handle(&s), Some(Json(LoginReq { email: "flood@example.invalid".into(), password: "whatever-passphrase-1".into(), }))).await; match res { Err(e) => assert_eq!(e.status, StatusCode::TOO_MANY_REQUESTS), Ok(_) => panic!("login must be throttled after sustained failures"), } } #[tokio::test] async fn logout_invalidates_the_server_session() { let s = st(); let (auth, headers) = register_ok(&s, "logout@example.invalid", "Logout User", "a-reasonable-passphrase-1").await; let tok = headers.get(header::COOKIE).unwrap().to_str().unwrap() .split(';').next().unwrap() .strip_prefix(COOKIE_NAME).and_then(|c| c.strip_prefix('=')).unwrap().to_string(); logout(handle(&s), auth, headers).await.unwrap(); let left: i64 = handle(&s).with_db(|db| db.query_row( "SELECT COUNT(*) FROM sessions WHERE id=?1", [&sha256_hex(tok.as_bytes())], |r| r.get(0)).unwrap()).await; assert_eq!(left, 0, "logout must delete the server-side session row"); } }