summaryrefslogtreecommitdiff
path: root/server/src/auth.rs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-17 14:32:37 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-17 14:32:37 +0800
commit5c0ba37eda80d39e6ceca59bb1d5f4942f858995 (patch)
tree948723f9cedf7ccb0707fa6ee516bd30fe20fd10 /server/src/auth.rs
downloadstrategy-lab-5c0ba37eda80d39e6ceca59bb1d5f4942f858995.tar.gz
chore: establish Strategy Lab source baseline (development, not release)
Diffstat (limited to 'server/src/auth.rs')
-rw-r--r--server/src/auth.rs713
1 files changed, 713 insertions, 0 deletions
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<RowUser> {
+ 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<Arc<AppState>> for AuthUser {
+ type Rejection = AppError;
+ async fn from_request_parts(parts: &mut Parts, state: &Arc<AppState>) -> Result<Self, Self::Rejection> {
+ 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<String> {
+ 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<User> {
+ 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<String> {
+ 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<Json<LoginReq>>) -> AppResult<Response> {
+ 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<Json<RegisterReq>>) -> AppResult<Response> {
+ 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<String> {
+ // 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<String>)> = 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<Response> {
+ 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<Json<serde_json::Value>> {
+ 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<Json<ProfileReq>>) -> AppResult<Json<serde_json::Value>> {
+ 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<Json<PasswordReq>>) -> AppResult<Json<serde_json::Value>> {
+ 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<Json<serde_json::Value>> {
+ 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::<Result<Vec<_>, _>>()?;
+ 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::<Vec<_>>()
+ })))
+}
+
+pub async fn delete_session(cx: Cx, auth: AuthUser, Path(id): Path<String>) -> AppResult<Json<serde_json::Value>> {
+ 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<Json<ResetReq>>) -> AppResult<Json<serde_json::Value>> {
+ 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<String> {
+ 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<AppState> {
+ 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<AppState>) -> 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<AppState>, 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, "[email protected]", "Tester User", "a-reasonable-passphrase-1").await;
+ assert_eq!(auth.email, "[email protected]");
+ 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: "[email protected]".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("[email protected]")).await.unwrap();
+ let body = RegisterReq {
+ invite_token: inv,
+ name: "Bound".into(),
+ email: "[email protected]".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, "[email protected]", "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, "[email protected]", "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','[email protected]','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), "[email protected]").await;
+ }
+ let res = login(handle(&s), Some(Json(LoginReq {
+ email: "[email protected]".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, "[email protected]", "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");
+ }
+}