//! Authentication, session management, and account security. //! //! Passwords are hashed with Argon2id (random salt per hash). Sessions use //! `tower-sessions` with ID regeneration on login (prevents fixation) and //! full flush on logout. Each login creates a tracked session row in //! `user_sessions` for remote revocation from the security dashboard. //! //! Two-factor authentication supports both TOTP (time-based one-time //! passwords via `totp-rs`) and WebAuthn passkeys (via `webauthn-rs`). //! Account lockout is enforced after 5 failed login attempts and lasts a flat //! 15 minutes, tracked by `failed_login_attempts` and `locked_until` on the //! user row. New-device login notifications are sent via Postmark //! when enabled. //! //! Extractors: [`AuthUser`] (required login), [`MaybeUserUnverified`] (optional, //! no revocation check, public read-only pages only), [`MaybeUserVerified`] //! (optional with revocation check, anywhere identity actually gates behavior), //! [`AdminUser`] (admin-only, hides routes with 404). use argon2::{ Algorithm, Argon2, Params, Version, password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng}, }; use axum::{ extract::FromRequestParts, http::{header::HeaderMap, request::Parts}, }; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use tower_sessions::Session; use std::time::Instant; use crate::config::Config; use crate::constants; use crate::db::{self, UserId, UserSessionId, Username}; use crate::error::{AppError, ResultExt}; use crate::helpers::constant_time_compare; /// Session key for storing user data const USER_SESSION_KEY: &str = "user"; /// Session key for linking to the `user_sessions` tracking row. pub const SESSION_TRACKING_KEY: &str = "session_tracking_id"; /// User data stored in session #[derive(Clone, Debug, Serialize, Deserialize)] pub struct SessionUser { pub id: UserId, pub username: Username, pub email: String, pub display_name: Option, #[serde(default)] pub can_create_projects: bool, #[serde(default)] pub suspended: bool, #[serde(default)] pub is_admin: bool, #[serde(default)] pub is_fan_plus: bool, #[serde(default)] pub creator_tier: Option, #[serde(default)] pub deactivated: bool, #[serde(default)] pub is_sandbox: bool, /// This creator's settlement currency, cached from `users` at session build. /// /// Cached, so it can lag by one session if the creator's Stripe currency /// changes mid-session. That is tolerable precisely because a change is not /// silent: it raises a creator alert telling them to re-check their prices, /// and signing in again refreshes this. Surfaces that must never lag (the /// project dashboard's revenue figures) read the row instead. #[serde(default)] pub settlement_currency: crate::currency::SettlementCurrency, /// As a *buyer*: how this user wants a cross-currency purchase converted. /// A default for the checkout form, not a lock, so a session-cached copy is /// harmless: the form is what actually decides, per purchase. #[serde(default)] pub conversion_preference: crate::currency::ConversionChoice, } impl SessionUser { /// Build a `SessionUser` from a DB user row + async lookups for fan_plus and creator_tier. /// /// Used by all login paths (password, passkey, 2FA, email link) except the join wizard /// (which uses hardcoded defaults for a freshly created account). pub async fn from_db_user( user: db::DbUser, pool: &sqlx::PgPool, admin_user_id: Option, ) -> Self { let suspended = user.is_suspended(); let deactivated = user.is_deactivated(); let is_admin = admin_user_id == Some(user.id); let is_fan_plus = db::fan_plus::is_fan_plus_active(pool, user.id) .await .unwrap_or(false); let creator_tier = db::creator_tiers::get_active_creator_tier(pool, user.id) .await .ok() .flatten(); Self { settlement_currency: user.settlement_currency, conversion_preference: user.conversion_preference, id: user.id, username: user.username, email: user.email.into_inner(), display_name: user.display_name, can_create_projects: user.can_create_projects, suspended, is_admin, is_fan_plus, creator_tier, deactivated, is_sandbox: user.is_sandbox, } } /// Returns `Err(Forbidden)` if the user is a sandbox account. /// Call at the top of routes that sandbox users must not access (Stripe, email, etc.). pub fn check_not_sandbox(&self) -> Result<(), AppError> { if self.is_sandbox { Err(AppError::Forbidden) } else { Ok(()) } } /// Returns `Err(Forbidden)` if the user is suspended or deactivated. /// Call at the top of write routes that suspended/deactivated users should not access. pub fn check_not_suspended(&self) -> Result<(), AppError> { if self.suspended || self.deactivated { Err(AppError::Forbidden) } else { Ok(()) } } } /// Read the cached `SessionUser` from a session, if one is logged in. /// /// Coarse read: it does NOT revalidate the session-tracking row (the way the /// `AuthUser` extractor does). Intended for middleware-level pre-filters like /// the site access gate, where the per-route `AuthUser` extractor still /// enforces full validation downstream. Returns `None` for anonymous sessions. pub async fn session_user(session: &Session) -> Option { session .get::(USER_SESSION_KEY) .await .ok() .flatten() } /// Extractor for authenticated users - returns error if not logged in. /// /// Specialized to `AppState` (not generic `S`) to access the DB pool for /// session tracking validation. If the session's tracking row has been /// deleted (revoked), the session is flushed and Unauthorized is returned. /// Legacy sessions without a tracking ID are allowed through until they /// expire naturally. pub struct AuthUser(pub SessionUser); impl FromRequestParts for AuthUser { type Rejection = AppError; async fn from_request_parts( parts: &mut Parts, state: &crate::AppState, ) -> Result { let session = parts .extensions .get::() .ok_or(AppError::Internal(anyhow::anyhow!("Session not found")))?; authenticate(session, state).await.map(AuthUser) } } /// Resolve the signed-in user from a session, or refuse. /// /// The whole of [`AuthUser`]'s work, split out because the extractor is no /// longer the only caller: a described screen is served by quasi's adapter, /// which resolves the state a request is answered against from the request /// head, in async context, before the sync router runs. It holds a [`Session`] /// out of the same extensions the extractor reads and needs the same answer. /// /// Split rather than duplicated for the obvious reason and one less obvious /// one: the revocation rules here are the security-relevant part (a session /// with no tracking row is refused, a touch that fails invalidates), and a /// second copy is a second place for them to fall behind. pub async fn authenticate( session: &Session, state: &crate::AppState, ) -> Result { { let user: SessionUser = session .get(USER_SESSION_KEY) .await .context("session error")? .ok_or(AppError::Unauthorized)?; // Every live session carries a tracking id, set at login by // `track_session`. A session with USER_SESSION_KEY but no // SESSION_TRACKING_KEY is a legacy pre-tracking session that cannot be // revoked, "log out everywhere", suspend, and password-change all act // on `user_sessions` rows it doesn't have. Refuse it (force re-login) // rather than trust an unrevocable session (Run 20 Security). Matches // the short-circuit `MaybeUserUnverified` already applies. let mut user = user; let Ok(Some(tracking_id)) = session.get::(SESSION_TRACKING_KEY).await else { let _ = session.flush().await; return Err(AppError::Unauthorized); }; // Validate the tracking row. Uses an in-memory cache to avoid hitting // the DB on every request, if this session was validated within // SESSION_TOUCH_CACHE_SECS, skip the query. let cache_ttl = std::time::Duration::from_secs(constants::SESSION_TOUCH_CACHE_SECS); let cached = state .caches .session_cache .get(&tracking_id) .is_some_and(|entry| entry.elapsed() < cache_ttl); if !cached { let result = match db::sessions::touch_session(&state.db, tracking_id).await { Ok(r) => r, Err(e) => { tracing::warn!(error = ?e, "session touch failed, invalidating"); db::sessions::TouchResult { valid: false, suspended: false, can_create_projects: false, is_fan_plus: false, creator_tier: None, } } }; if !result.valid { state.caches.session_cache.remove(&tracking_id); let _ = session.flush().await; return Err(AppError::Unauthorized); } // If the user's live DB state differs from the session, update it. // touch_session returns suspended, can_create_projects, is_fan_plus, // and creator_tier in a single query (no extra round-trips). let live_tier: Option = result.creator_tier.as_deref().and_then(|s| s.parse().ok()); if user.suspended != result.suspended || user.is_fan_plus != result.is_fan_plus || user.can_create_projects != result.can_create_projects || user.creator_tier != live_tier { user.suspended = result.suspended; user.is_fan_plus = result.is_fan_plus; user.can_create_projects = result.can_create_projects; user.creator_tier = live_tier; if let Err(e) = session.insert(USER_SESSION_KEY, user.clone()).await { tracing::warn!(user_id = %user.id, error = ?e, "failed to update session with refreshed user state"); } } state .caches .session_cache .insert(tracking_id, Instant::now()); } // Record user_id in the current span so all downstream logs // (DB queries, error handlers, etc.) include it automatically. tracing::Span::current().record("user_id", tracing::field::display(&user.id)); Ok(user) } } /// Extractor for optional authenticated users, returns None if not logged in. /// /// **DANGER, this extractor does NOT validate the session against the database.** /// A revoked session (user clicked "log out everywhere", account suspended, /// session row deleted) will still resolve to `Some(SessionUser)` here until /// the cookie naturally expires. The name carries the warning: any handler /// that uses this type accepts that consequence. /// /// Use ONLY for cheap anonymous-or-logged-in rendering on public read-only /// pages where displaying stale identity is acceptable (blog views, docs, /// discover feed). For any handler that: /// - modifies data, /// - gates paid content or downloads, /// - issues OAuth tokens / grants, /// - exposes account-private information, /// /// use [`AuthUser`] (required login) or [`MaybeUserVerified`] (optional login /// with revocation check) instead. pub struct MaybeUserUnverified(pub Option); impl FromRequestParts for MaybeUserUnverified where S: Send + Sync, { type Rejection = AppError; async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { let session = parts .extensions .get::() .ok_or(AppError::Internal(anyhow::anyhow!("Session not found")))?; let user: Option = session .get(USER_SESSION_KEY) .await .context("session error")?; // Short-circuit legacy sessions (USER_SESSION_KEY present without a // SESSION_TRACKING_KEY) to anonymous. Without this, a pre-tracking // session quietly survives `/logout-everywhere`, that sweep deletes // user_sessions rows, but a legacy session has no row to delete and // would keep rendering as logged-in on every Unverified extractor // until the cookie naturally expires. if user.is_some() { let tracking: Option = session.get(SESSION_TRACKING_KEY).await.ok().flatten(); if tracking.is_none() { return Ok(MaybeUserUnverified(None)); } } Ok(MaybeUserUnverified(user)) } } /// Extractor for optional authenticated users WITH revocation check. /// /// Like [`MaybeUserUnverified`] but runs the same session-tracking validation /// as [`AuthUser`]: if the tracking row has been deleted (revoked) or the /// account is suspended, the session is flushed and `None` is returned (the /// request continues as anonymous rather than 401, since the handler chose /// "optional auth"). Legacy sessions without a tracking ID pass through. /// /// Costs one cached `touch_session` query per request (TTL = `SESSION_TOUCH_CACHE_SECS`). /// Prefer this over `MaybeUserUnverified` anywhere the identity actually gates /// behavior, paid content access, OAuth flows, download grants, comments, /// or anything that writes to the DB on behalf of the user. pub struct MaybeUserVerified(pub Option); impl FromRequestParts for MaybeUserVerified { type Rejection = AppError; async fn from_request_parts( parts: &mut Parts, state: &crate::AppState, ) -> Result { let session = parts .extensions .get::() .ok_or(AppError::Internal(anyhow::anyhow!("Session not found")))?; let Some(mut user): Option = session .get(USER_SESSION_KEY) .await .context("session error")? else { return Ok(MaybeUserVerified(None)); }; // A session with USER_SESSION_KEY but no SESSION_TRACKING_KEY is a legacy // pre-tracking session that can't be revoked; treat it as anonymous // rather than trust it (Run 20 Security), matching `AuthUser` and // `MaybeUserUnverified`. let Ok(Some(tracking_id)) = session.get::(SESSION_TRACKING_KEY).await else { let _ = session.flush().await; return Ok(MaybeUserVerified(None)); }; let cache_ttl = std::time::Duration::from_secs(constants::SESSION_TOUCH_CACHE_SECS); let cached = state .caches .session_cache .get(&tracking_id) .is_some_and(|entry| entry.elapsed() < cache_ttl); if !cached { let result = match db::sessions::touch_session(&state.db, tracking_id).await { Ok(r) => r, Err(e) => { tracing::warn!(error = ?e, "session touch failed in MaybeUserVerified, treating as anonymous"); db::sessions::TouchResult { valid: false, suspended: false, can_create_projects: false, is_fan_plus: false, creator_tier: None, } } }; if !result.valid { state.caches.session_cache.remove(&tracking_id); let _ = session.flush().await; return Ok(MaybeUserVerified(None)); } let live_tier: Option = result.creator_tier.as_deref().and_then(|s| s.parse().ok()); if user.suspended != result.suspended || user.is_fan_plus != result.is_fan_plus || user.can_create_projects != result.can_create_projects || user.creator_tier != live_tier { user.suspended = result.suspended; user.is_fan_plus = result.is_fan_plus; user.can_create_projects = result.can_create_projects; user.creator_tier = live_tier; if let Err(e) = session.insert(USER_SESSION_KEY, user.clone()).await { tracing::warn!(user_id = %user.id, error = ?e, "failed to update session with refreshed user state"); } } state .caches .session_cache .insert(tracking_id, Instant::now()); } tracing::Span::current().record("user_id", tracing::field::display(&user.id)); Ok(MaybeUserVerified(Some(user))) } } /// Proof that an admin identity was established by the [`AdminUser`] extractor. /// /// The inner `UserId` is private to this module and the only public constructor /// is [`AdminUser::admin_id`], so an `AdminId` cannot exist without having passed /// the `require_admin` gate. DB writers that stamp an actor (`moderation_actions`, /// `report.resolved_by`) take `AdminId` instead of a bare `UserId`, making a /// forged or caller-supplied admin id un-constructible at the type level rather /// than relying on every route to remember the guard. #[derive(Clone, Copy, Debug)] pub struct AdminId(UserId); impl AdminId { /// The witnessed admin user id, for binding into a query. pub fn get(self) -> UserId { self.0 } /// Mint an `AdminId` from the configured `ADMIN_USER_ID` for out-of-band admin /// contexts that have no HTTP session, specifically the `mnw-admin` CLI, which /// loads the same server env. Returns `None` when no admin is configured. /// /// This is the only constructor besides [`AdminUser::admin_id`], and it is /// gated on the exact same config value that [`require_admin`] checks, so it /// cannot attribute a moderation action to a non-admin, preserving the /// forgery-proof invariant while letting a headless admin tool stamp the audit /// trail with the real actor instead of skipping it. pub fn from_config(config: &crate::config::Config) -> Option { config.admin_user_id.map(AdminId) } } /// Extractor for admin users - returns NotFound (hides admin routes) if not admin. /// /// Combines `AuthUser` session check with `require_admin` config check into a /// single type-safe extractor, eliminating per-handler `require_admin()` calls. pub struct AdminUser(pub SessionUser); impl AdminUser { /// Mint the [`AdminId`] witness for this verified admin. The only way to /// obtain an `AdminId`, its private field can't be constructed elsewhere. pub fn admin_id(&self) -> AdminId { AdminId(self.0.id) } /// The admin's plain `UserId`, for tracing/display (not a write witness). pub fn id(&self) -> UserId { self.0.id } } impl FromRequestParts for AdminUser { type Rejection = AppError; async fn from_request_parts( parts: &mut Parts, state: &crate::AppState, ) -> Result { let AuthUser(user) = AuthUser::from_request_parts(parts, state).await?; require_admin(&user, &state.config)?; Ok(AdminUser(user)) } } /// Extractor for internal service-to-service auth (CLI SSH server → MNW API). /// /// Validates `Authorization: Bearer {token}` against `config.integrations.cli_service_token`. /// Returns 401 if the token is missing/invalid, 503 if the token is not configured. pub struct ServiceAuth; impl FromRequestParts for ServiceAuth { type Rejection = AppError; async fn from_request_parts( parts: &mut Parts, state: &crate::AppState, ) -> Result { let expected = state .config .integrations .cli_service_token .as_deref() .ok_or_else(|| { AppError::ServiceUnavailable("Internal API not configured".to_string()) })?; let header = parts .headers .get("authorization") .and_then(|v| v.to_str().ok()) .and_then(|v| v.strip_prefix("Bearer ")) .ok_or(AppError::Unauthorized)?; if !constant_time_compare(header, expected) { return Err(AppError::Unauthorized); } Ok(ServiceAuth) } } /// Extractor for inbound infra-alert ingestion (PoM / MT monitoring agents → /// `POST /api/internal/alerts`). /// /// Validates `Authorization: Bearer {token}` against /// `config.integrations.alerts_ingest_token`. Returns 401 if the token is /// missing/invalid, 503 if the token is not configured. Deliberately keyed on a /// token separate from `ServiceAuth`'s `cli_service_token`: these agents run on /// other hosts (astra, prod), so a leak there must not grant CLI internal-API /// access. pub struct AlertsAuth; impl FromRequestParts for AlertsAuth { type Rejection = AppError; async fn from_request_parts( parts: &mut Parts, state: &crate::AppState, ) -> Result { let expected = state .config .integrations .alerts_ingest_token .as_deref() .ok_or_else(|| { AppError::ServiceUnavailable("Alert ingestion not configured".to_string()) })?; let header = parts .headers .get("authorization") .and_then(|v| v.to_str().ok()) .and_then(|v| v.strip_prefix("Bearer ")) .ok_or(AppError::Unauthorized)?; if !constant_time_compare(header, expected) { return Err(AppError::Unauthorized); } Ok(AlertsAuth) } } /// The SSH-authenticated user identity behind an internal-API request. /// /// Sourced from the `X-MNW-Actor` header, a signed assertion the server mints /// during `ssh-key-lookup` (after authenticating the user by SSH key) and the /// CLI forwards. Because the assertion is keyed by the server-only /// `signing_secret`, a leaked `ServiceAuth` token cannot forge one for another /// user, so internal handlers derive identity from this, never from a /// caller-supplied `user_id` field. Handlers use `actor.user_id()` for scoping /// and `actor.ensure_owns(resource.user_id)?` for ownership checks. pub struct InternalActor(pub UserId); impl InternalActor { pub fn user_id(&self) -> UserId { self.0 } /// Reject with 403 unless the asserted identity owns the resource. pub fn ensure_owns(&self, owner: UserId) -> Result<(), AppError> { if self.0 == owner { Ok(()) } else { Err(AppError::Forbidden) } } } impl FromRequestParts for InternalActor { type Rejection = AppError; async fn from_request_parts( parts: &mut Parts, state: &crate::AppState, ) -> Result { let token = parts .headers .get("x-mnw-actor") .and_then(|v| v.to_str().ok()) .ok_or(AppError::Unauthorized)?; let now = chrono::Utc::now().timestamp(); let user_id = crate::crypto::verify_internal_actor_token(token, &state.config.signing_secret, now) .ok_or(AppError::Unauthorized)?; Ok(InternalActor(user_id)) } } /// Synchronous Argon2id hash. /// /// Production: 46 MiB, 2 iterations (~600ms). With `fast-tests` feature: 8 MiB, 1 iteration (~10ms). /// Verification auto-detects params from the hash string, so no feature flag needed there. /// /// CPU-bound (hundreds of ms); do NOT call from an /// async handler, use [`hash_password_async`], which runs this on a blocking /// thread so a burst of signups can't starve the Tokio worker pool. The sync /// form remains `pub` only for one-time `DUMMY_HASH` initializers and /// test/integration fixtures that seed password hashes off the request path. /// /// This is the only remaining consumer of `fast-tests`, deliberately: it is a /// cost knob rather than a behaviour knob, so unlike the rate limits the feature /// swapped before, there is nothing here a feature-gated build fails to /// exercise. See the `[features]` comment in Cargo.toml. pub fn hash_password(password: &str) -> Result { let salt = SaltString::generate(&mut OsRng); #[cfg(feature = "fast-tests")] let params = Params::new(8 * 1024, 1, 1, None) .map_err(|e| AppError::Internal(anyhow::anyhow!("argon2 params: {e}")))?; #[cfg(not(feature = "fast-tests"))] let params = Params::new(46 * 1024, 2, 1, None) .map_err(|e| AppError::Internal(anyhow::anyhow!("argon2 params: {e}")))?; let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); let hash = argon2 .hash_password(password.as_bytes(), &salt) .map_err(|e| AppError::Internal(anyhow::anyhow!("password hashing: {e}")))?; Ok(hash.to_string()) } /// Verify a password against a stored PHC-encoded Argon2 hash. /// /// Derives the verifier from the stored hash's own algorithm/version/params /// rather than relying on `Argon2::default()`. The two are functionally /// equivalent today (the `PasswordVerifier` trait reads parameters from /// the parsed hash, not the instance) but explicit derivation pins our /// boundary: this function only verifies Argon2 family hashes, anything /// else fails out at `Algorithm::try_from`. Forward-compatible with a /// future algorithm migration, when one lands, add a dispatch table /// instead of swapping the default instance under the verifier's feet. /// /// CPU-bound (hundreds of ms); do NOT call from an async handler, use /// [`verify_password_async`], which runs this on a blocking thread so concurrent /// logins can't starve the Tokio worker pool. Kept `pub(crate)` for the async /// wrapper, the timing-equalizer dummy verifies, and tests. pub(crate) fn verify_password(password: &str, hash: &str) -> Result { // A stored hash that won't parse (corruption, or a non-Argon2 algorithm we // don't verify) is a server-side data problem, not a 500 for the user: treat // it as a non-match so login fails, and log it for ops. Returning an // Internal error here would also be a (third-order) account oracle, it // distinguishes "valid account, bad stored hash" from "valid account, wrong // password" by status code (SEC minor, Run #23). let reject = |what: &str, e: &dyn std::fmt::Display| { tracing::error!(event = "password_hash_unverifiable", reason = %what, error = %e, "stored password hash could not be parsed; treating as non-match"); Ok(false) }; let parsed_hash = match PasswordHash::new(hash) { Ok(h) => h, Err(e) => return reject("parse", &e), }; let algorithm = match Algorithm::try_from(parsed_hash.algorithm) { Ok(a) => a, Err(e) => return reject("algorithm", &e), }; let version = match parsed_hash.version.map(Version::try_from).transpose() { Ok(v) => v.unwrap_or(Version::V0x13), Err(e) => return reject("version", &e), }; let params = match Params::try_from(&parsed_hash) { Ok(p) => p, Err(e) => return reject("params", &e), }; Ok(Argon2::new(algorithm, version, params) .verify_password(password.as_bytes(), &parsed_hash) .is_ok()) } /// Async wrapper for [`hash_password`]: runs the CPU-bound Argon2id work on the /// blocking thread pool so it never occupies a Tokio worker. This is the form /// handlers must use. pub async fn hash_password_async(password: String) -> Result { tokio::task::spawn_blocking(move || hash_password(&password)) .await .map_err(|e| AppError::Internal(anyhow::anyhow!("argon2 hash task join: {e}")))? } /// Async wrapper for [`verify_password`]: runs the CPU-bound Argon2id verify on /// the blocking thread pool so concurrent logins can't starve the async runtime. /// This is the form handlers (including the timing-equalizer dummy verifies) /// must use. pub async fn verify_password_async(password: String, hash: String) -> Result { tokio::task::spawn_blocking(move || verify_password(&password, &hash)) .await .map_err(|e| AppError::Internal(anyhow::anyhow!("argon2 verify task join: {e}")))? } /// Outcome of [`relying_party_login_gate`]. pub enum LoginGate { /// Password correct and the account may complete a relying-party login. Allow, /// Login refused. `just_locked` is true when *this* attempt tripped the /// lockout (so an interactive caller can show a one-time lockout notice); /// every other refusal reason is indistinguishable by design. Deny { just_locked: bool }, } /// Uniform password + account-status gate for relying-party logins (OAuth /// authorize, SyncKit auth), the flows that reject 2FA accounts outright. /// /// Runs Argon2 first, then folds *every* refusal reason (wrong password, /// suspended, deactivated, locked, 2FA-enabled) into a single accounted /// decision: a denial always increments the failed-login counter, a success /// always resets it. Collapsing the blocked-account cases into the wrong-password /// path is what stops the counter from becoming a confirmed-password oracle, a /// correct guess against a 2FA/suspended account must be indistinguishable from a /// wrong one. Both relying parties call this /// instead of open-coding the ordering, so the invariant lives in one place. pub async fn relying_party_login_gate( pool: &sqlx::PgPool, user: &db::DbUser, password: &str, ) -> Result { let valid = verify_password_async(password.to_string(), user.password_hash.clone()).await?; let locked = user .locked_until .is_some_and(|locked_until| locked_until > chrono::Utc::now()); let denied = !valid || user.is_suspended() || user.is_deactivated() || locked || user.totp_enabled; if denied { let result = db::auth::increment_failed_login( pool, user.id, constants::MAX_LOGIN_ATTEMPTS, constants::LOCKOUT_MINUTES, ) .await?; return Ok(LoginGate::Deny { just_locked: result.just_locked, }); } db::auth::reset_failed_login(pool, user.id).await?; Ok(LoginGate::Allow) } /// Store user in session with session regeneration to prevent fixation attacks #[tracing::instrument(skip_all, fields(user_id = %user.id))] pub async fn login_user(session: &Session, user: SessionUser) -> Result<(), AppError> { // Regenerate session ID to prevent session fixation attacks // This creates a new session ID while preserving session data session.cycle_id().await.context("session cycle")?; // Regenerate CSRF token so pre-auth tokens can't be used post-auth let new_csrf = crate::csrf::generate_token(); session .insert(crate::csrf::CSRF_SESSION_KEY, &new_csrf) .await .context("csrf token insert")?; session .insert(USER_SESSION_KEY, user) .await .context("session insert")?; Ok(()) } /// Destroy entire session on logout to prevent session reuse #[tracing::instrument(skip_all)] pub async fn logout_user(session: &Session) -> Result<(), AppError> { // Flush the entire session to destroy all data and invalidate session ID session.flush().await.context("session flush")?; Ok(()) } /// Record a new session in `user_sessions` and store the tracking ID in session data. /// Call this after `login_user()` in every login path. #[tracing::instrument(skip_all, fields(user_id = %user_id))] pub async fn track_session( session: &Session, pool: &PgPool, user_id: UserId, headers: &HeaderMap, ) -> Result<(), AppError> { let user_agent = headers .get("user-agent") .and_then(|v| v.to_str().ok()) .map(|s| { s.chars() .take(constants::USER_AGENT_MAX_LENGTH) .collect::() }); let ip = crate::helpers::extract_client_ip(headers); let tracking_id = db::sessions::create_user_session(pool, user_id, user_agent.as_deref(), ip.as_deref()) .await?; // Cap the user's active sessions so repeated logins can't grow the table // unboundedly. Best-effort: a prune failure must not break the login (the row // we just created is the newest and is never the one evicted). match db::sessions::prune_user_sessions_over_cap( pool, user_id, constants::MAX_SESSIONS_PER_USER, ) .await { Ok(pruned) if pruned > 0 => { tracing::info!(pruned, "pruned oldest sessions over the per-user cap"); } Ok(_) => {} Err(e) => tracing::warn!(error = ?e, "failed to prune sessions over the per-user cap"), } session .insert(SESSION_TRACKING_KEY, tracking_id) .await .context("session insert")?; Ok(()) } /// Send a new-device login notification if the user has other active sessions. /// /// Fire-and-forget, spawns a background task. Only sends if the user has more /// than one active session (meaning this is a new device). Whether they want the /// notification at all is the send path's question, not this helper's. #[allow(clippy::too_many_arguments)] pub async fn maybe_send_login_notification( db: &sqlx::PgPool, mailer: &crate::email::EmailClient, bg: &crate::background::BackgroundTx, config: &crate::config::Config, user_id: UserId, email: &str, display_name: Option<&str>, headers: &HeaderMap, ) { let session_count = match db::sessions::count_user_sessions(db, user_id).await { Ok(n) => n, Err(e) => { tracing::warn!("Failed to count sessions for login notification: {e}"); return; } }; if session_count <= 1 { return; } let user_agent = headers .get("user-agent") .and_then(|v| v.to_str().ok()) .map(|s| { s.chars() .take(constants::USER_AGENT_MAX_LENGTH) .collect::() }); let ip = crate::helpers::extract_client_ip(headers); let unsub_url = crate::email::generate_unsubscribe_url( &config.host_url, user_id, crate::email::UnsubscribeAction::Login, &user_id.to_string(), &config.signing_secret, ); let email = email.to_string(); let display_name = display_name.map(String::from); // Inlined `spawn_email!`: the macro reads `.email`/`.bg` off AppState; this // helper now holds the EmailClient + BackgroundTx slices directly. let email_client = mailer.clone(); bg.spawn("login notification", async move { if let Err(e) = email_client .send_new_login_notification( user_id, &email, display_name.as_deref(), user_agent.as_deref(), ip.as_deref(), Some(&unsub_url), ) .await { tracing::error!(error = ?e, "failed to send login notification"); } }); } /// Check if a password appears in the HaveIBeenPwned breached passwords database. /// Uses k-anonymity: only the first 5 characters of the SHA-1 hash are sent. /// Returns Some(count) if breached, None if clean or API unavailable. /// /// This check is advisory (it never blocks a password change), so a lookup /// failure fails open, but it must not fail *silently*. A network blip or /// HIBP outage that disables breach checking is logged at WARN so the gap is /// visible in observability rather than disappearing into a bare `?`. pub async fn check_password_breach(password: &str) -> Option { use sha1::{Digest, Sha1}; let hash = hex::encode(Sha1::digest(password.as_bytes())).to_uppercase(); let (prefix, suffix) = hash.split_at(5); let url = format!("https://api.pwnedpasswords.com/range/{prefix}"); let response = match crate::helpers::HTTP_CLIENT .get(&url) .header("User-Agent", "Makenotwork-Security-Check") .header("Add-Padding", "true") .timeout(std::time::Duration::from_secs(3)) .send() .await { Ok(resp) => resp, Err(e) => { tracing::warn!(error = %e, "HIBP breach lookup failed (network/timeout); breach check skipped (fail-open)"); return None; } }; let response = match response.text().await { Ok(body) => body, Err(e) => { tracing::warn!(error = %e, "HIBP breach lookup: could not read response body; breach check skipped (fail-open)"); return None; } }; for line in response.lines() { let mut parts = line.splitn(2, ':'); if let (Some(hash_suffix), Some(count)) = (parts.next(), parts.next()) && hash_suffix.trim() == suffix { return count.trim().parse().ok(); } } None } /// Check if a user is the admin. Returns NotFound to hide admin routes from non-admins. pub fn require_admin(user: &SessionUser, config: &Config) -> Result<(), AppError> { match config.admin_user_id { Some(admin_id) if admin_id == user.id => Ok(()), _ => Err(AppError::NotFound), } } #[cfg(test)] mod tests { use super::*; use crate::config::{BuildConfig, CreatorTierPricing, EmailWebhookConfig, IntegrationsConfig}; #[test] fn hash_password_produces_valid_hash() { let hash = hash_password("test_password_123").unwrap(); // Argon2 hashes start with $argon2 assert!(hash.starts_with("$argon2")); } #[test] fn verify_password_correct() { let hash = hash_password("correct_horse").unwrap(); assert!(verify_password("correct_horse", &hash).unwrap()); } #[test] fn verify_password_wrong() { let hash = hash_password("correct_horse").unwrap(); assert!(!verify_password("wrong_horse", &hash).unwrap()); } #[test] fn verify_password_unparseable_hash_is_non_match_not_error() { // A corrupt / non-Argon2 stored hash must fail login cleanly (Ok(false)), // not 500, avoids an availability bug and an account oracle (SEC, Run #23). for bad in [ "", "not-a-phc-string", "$argon2id$garbage", "$2y$10$abcdefghijklmnopqrstuv", ] { assert!( !verify_password("any", bad).unwrap(), "hash {bad:?} should be a non-match" ); } } #[test] fn hash_password_different_each_time() { let h1 = hash_password("same_password").unwrap(); let h2 = hash_password("same_password").unwrap(); // Different salts should produce different hashes assert_ne!(h1, h2); } #[test] fn require_admin_with_admin_id() { let user = SessionUser { id: "00000000-0000-0000-0000-000000000001" .parse::() .unwrap(), username: Username::from_trusted("admin".to_string()), email: "admin@example.com".to_string(), display_name: None, can_create_projects: true, suspended: false, is_admin: true, is_fan_plus: false, creator_tier: None, deactivated: false, is_sandbox: false, settlement_currency: crate::currency::SettlementCurrency::Usd, conversion_preference: crate::currency::ConversionChoice::AtCheckout, }; let config = Config { host: "127.0.0.1".parse().unwrap(), port: 3000, database_url: "postgres://test".to_string(), host_url: std::sync::Arc::from("http://localhost:3000"), signing_secret: "secret".to_string(), storage: None, synckit_storage: None, public_storage: None, rpm_storage: None, rpm_base_url: None, stripe: None, admin_user_id: Some(user.id), synckit_jwt_secret: None, scan: None, cdn_base_url: "https://cdn.localhost".to_string(), user_pages_host: std::sync::Arc::from("u.localhost"), access_gate: crate::config::AccessGate::Open, sso: None, rate_limits: crate::constants::RateLimits::production(), build: BuildConfig { trigger_token: None, host_linux: None, host_darwin: None, git_repos_path: None, git_ssh_host: None, }, email_webhooks: EmailWebhookConfig { webhook_token: None, broadcast_webhook_token: None, inbound_webhook_token: None, enforce_sender_auth: true, }, creator_pricing: CreatorTierPricing { fan_plus_price_id: None, tier_prices: std::collections::HashMap::new(), tier_annual_prices: std::collections::HashMap::new(), tier_founder_prices: std::collections::HashMap::new(), tier_founder_annual_prices: std::collections::HashMap::new(), founder_window_open: false, }, integrations: IntegrationsConfig { mt_base_url: None, wam_url: None, internal_shared_secret: None, cli_service_token: None, alerts_ingest_token: None, }, }; assert!(require_admin(&user, &config).is_ok()); } #[tokio::test] #[ignore = "requires network access, run manually"] async fn check_password_breach_known_breached() { let result = check_password_breach("password").await; assert!(result.is_some()); assert!(result.unwrap() > 0); } #[tokio::test] #[ignore = "requires network access, run manually"] async fn check_password_breach_unknown() { // A random 64-char string should not appear in any breach database let random_pw = "xK9m2Qp7vL4nR8wJ3sY6dF1gH5bT0cU9eA2iO7lN4mP8qW3rX6zV1yB5jD0fG"; let result = check_password_breach(random_pw).await; assert!(result.is_none()); } #[test] fn require_admin_without_admin_id() { let user = SessionUser { id: UserId::new(), username: Username::from_trusted("notadmin".to_string()), email: "user@example.com".to_string(), display_name: None, can_create_projects: false, suspended: false, is_admin: false, is_fan_plus: false, creator_tier: None, deactivated: false, is_sandbox: false, settlement_currency: crate::currency::SettlementCurrency::Usd, conversion_preference: crate::currency::ConversionChoice::AtCheckout, }; let config = Config { host: "127.0.0.1".parse().unwrap(), port: 3000, database_url: "postgres://test".to_string(), host_url: std::sync::Arc::from("http://localhost:3000"), signing_secret: "secret".to_string(), storage: None, synckit_storage: None, public_storage: None, rpm_storage: None, rpm_base_url: None, stripe: None, admin_user_id: None, synckit_jwt_secret: None, scan: None, cdn_base_url: "https://cdn.localhost".to_string(), user_pages_host: std::sync::Arc::from("u.localhost"), access_gate: crate::config::AccessGate::Open, sso: None, rate_limits: crate::constants::RateLimits::production(), build: BuildConfig { trigger_token: None, host_linux: None, host_darwin: None, git_repos_path: None, git_ssh_host: None, }, email_webhooks: EmailWebhookConfig { webhook_token: None, broadcast_webhook_token: None, inbound_webhook_token: None, enforce_sender_auth: true, }, creator_pricing: CreatorTierPricing { fan_plus_price_id: None, tier_prices: std::collections::HashMap::new(), tier_annual_prices: std::collections::HashMap::new(), tier_founder_prices: std::collections::HashMap::new(), tier_founder_annual_prices: std::collections::HashMap::new(), founder_window_open: false, }, integrations: IntegrationsConfig { mt_base_url: None, wam_url: None, internal_shared_secret: None, cli_service_token: None, alerts_ingest_token: None, }, }; assert!(require_admin(&user, &config).is_err()); } // ── Guard function tests ── fn make_user(is_sandbox: bool, suspended: bool, deactivated: bool) -> SessionUser { SessionUser { id: UserId::new(), username: Username::from_trusted("testuser".to_string()), email: "test@example.com".to_string(), display_name: None, can_create_projects: false, suspended, is_admin: false, is_fan_plus: false, creator_tier: None, deactivated, is_sandbox, settlement_currency: crate::currency::SettlementCurrency::Usd, conversion_preference: crate::currency::ConversionChoice::AtCheckout, } } #[test] fn check_not_sandbox_allows_normal_user() { let user = make_user(false, false, false); assert!(user.check_not_sandbox().is_ok()); } #[test] fn check_not_sandbox_blocks_sandbox() { let user = make_user(true, false, false); assert!(user.check_not_sandbox().is_err()); } #[test] fn check_not_suspended_allows_normal_user() { let user = make_user(false, false, false); assert!(user.check_not_suspended().is_ok()); } #[test] fn check_not_suspended_blocks_suspended() { let user = make_user(false, true, false); assert!(user.check_not_suspended().is_err()); } #[test] fn check_not_suspended_blocks_deactivated() { let user = make_user(false, false, true); assert!(user.check_not_suspended().is_err()); } #[test] fn check_not_suspended_blocks_both() { let user = make_user(false, true, true); assert!(user.check_not_suspended().is_err()); } }