From 5c0ba37eda80d39e6ceca59bb1d5f4942f858995 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Thu, 17 Sep 2026 14:32:37 +0800 Subject: chore: establish Strategy Lab source baseline (development, not release) --- server/src/auth.rs | 713 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 713 insertions(+) create mode 100644 server/src/auth.rs (limited to 'server/src/auth.rs') diff --git a/server/src/auth.rs b/server/src/auth.rs new file mode 100644 index 0000000..8942a83 --- /dev/null +++ b/server/src/auth.rs @@ -0,0 +1,713 @@ +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"); + } +} -- cgit v1.2.3