//! Login, logout, passkey assertion, session lookup, and username validation. //! //! Password reset and email verification live in `routes::pages::email_actions`. use axum::{ Form, extract::State, handler::Handler, http::{StatusCode, header::HeaderMap}, response::{Html, IntoResponse, Redirect, Response}, routing::{get, post}, }; use serde::Deserialize; use tower_governor::GovernorLayer; use tower_sessions::{Expiry, Session}; use crate::{ AppCaches, AppState, auth::{ AuthUser, SESSION_TRACKING_KEY, SessionUser, login_user, logout_user, track_session, verify_password_async, }, config::Config, constants::{self, LOCKOUT_MINUTES, MAX_LOGIN_ATTEMPTS}, csrf::{CsrfRouter, post_csrf, with_csrf, with_csrf_manual, with_csrf_skip}, db::{self, UserSessionId, Username}, email, error::{AppError, Result, ResultExt}, helpers::{is_htmx_request, rate_limiter_ms, rate_limiter_per_sec}, templates::{LoginErrorTemplate, LoginTemplate, SaveStatusTemplate, UsernameStatusTemplate}, }; use sqlx::PgPool; use webauthn_rs::prelude::*; /// Pre-computed Argon2id hash for timing-safe user-not-found responses. /// verify_password() against this takes the same time as a real hash check. static DUMMY_HASH: std::sync::LazyLock = std::sync::LazyLock::new(|| { crate::auth::hash_password("anti-timing-dummy").expect("dummy hash") }); /// Register authentication routes with rate limiting. pub fn auth_routes(limits: constants::RateLimits) -> CsrfRouter { let auth_rate_limit = rate_limiter_ms(limits.auth_ms, limits.auth_burst); let validate_rate_limit = rate_limiter_per_sec( constants::VALIDATE_RATE_LIMIT_PER_SEC, constants::VALIDATE_RATE_LIMIT_BURST, ); CsrfRouter::new() // GET /login is NOT rate-limited (page render for CSRF tokens). // POST /login and passkey routes ARE rate-limited. .route("/login", with_csrf_manual( "POST validates via validate_token_consuming (defense-in-depth on top of SameSite=Lax)", get(crate::routes::pages::public::landing::login_page) .post(login_handler.layer(GovernorLayer::new(auth_rate_limit.clone()))), )) .route("/auth/passkey/start", with_csrf_skip( "pre-auth WebAuthn challenge", post(passkey_auth_start) .layer(GovernorLayer::new(auth_rate_limit.clone())), )) .route("/auth/passkey/finish", with_csrf_skip( "pre-auth WebAuthn assertion", post(passkey_auth_finish) .layer(GovernorLayer::new(auth_rate_limit)), )) // Routes without auth rate limiting .route("/logout", post_csrf(logout_handler)) .route_get("/auth/me", get(me_handler)) // Username validation with its own rate limit .route( "/api/validate/username", with_csrf(post(validate_username).layer(GovernorLayer::new(validate_rate_limit))), ) } /// Form input for login (accepts username or email). #[derive(Debug, Deserialize)] pub struct LoginForm { pub login: String, // Can be username or email pub password: String, #[serde(default)] pub remember_me: Option, #[serde(default, rename = "_csrf")] pub csrf: Option, } /// Authenticate a user via username/email and password with lockout protection. #[tracing::instrument(skip_all, name = "auth::login")] async fn login_handler( State(db): State, State(config): State, State(mailer): State, State(bg): State, headers: HeaderMap, session: Session, Form(form): Form, ) -> Result { let is_htmx = is_htmx_request(&headers); // Manual-posture CSRF: defense-in-depth over the SameSite=Lax cookie. Run // before any state-changing work (lockout increments, login-link emails, // session creation). Match the standard validator's header-then-form // precedence so HTMX callers and vanilla form posts both pass. let token = crate::csrf::token_from_header_or_field(&headers, form.csrf.as_deref()).unwrap_or_default(); let _validated = crate::csrf::validate_token_consuming(&session, &token).await?; let submitted_login = form.login.clone(); // Pre-fetch the CSRF token so the sync error closure can recall it without // awaiting (closures can't be async). On the happy path this is a single // session read; on the error path it's already paid for. let recall_csrf_token = if is_htmx { None } else { crate::helpers::get_csrf_token(&session).await }; let sso_enabled = config.sso.is_some(); // Every failed login leaves through here, whatever the reason, and none of // them is visible in the response status: a wrong password re-renders the // form with a 200. Counting at the exit is what makes a stuffing run // countable at all. let failure_ip = crate::helpers::extract_client_ip(&headers); let return_error = |msg: &str| -> Result { crate::security_signals::note_auth_failure(failure_ip.as_deref()); if is_htmx { Ok(Html( LoginErrorTemplate { message: msg.to_string(), } .render_string()?, ) .into_response()) } else { // Full-page POST: re-render the login form with the username/email // value preserved and the error inlined, instead of bouncing the // user to the global error page (which loses every field). Ok(LoginTemplate { csrf_token: recall_csrf_token.clone(), prefill_login: submitted_login.clone(), error: Some(msg.to_string()), notice: None, sso_enabled, } .into_response()) } }; let user = if form.login.contains('@') { // Login form accepts username OR email. If '@' is present, try email lookup; // a malformed value just fails the lookup (None), same generic error as wrong creds. let Ok(email) = db::Email::new(&form.login) else { // Run the DUMMY_HASH equalizer before returning so this branch's // timing matches the valid-email-unknown-user path below. Without // it, a malformed-email submit completes ~2 orders of magnitude // faster and lets an attacker distinguish "you typed something // not email-shaped" from "valid email, unknown account." let _ = verify_password_async("dummy".to_string(), DUMMY_HASH.clone()).await; return return_error("Invalid username or password"); }; db::users::get_user_by_email(&db, &email) .await .context("lookup user by email for login")? } else { // Validate username format; if invalid, return the same generic error // as invalid credentials to avoid leaking that the format was wrong. let Ok(username) = Username::new(&form.login) else { let _ = verify_password_async("dummy".to_string(), DUMMY_HASH.clone()).await; return return_error("Invalid username/email or password"); }; db::users::get_user_by_username(&db, &username) .await .context("lookup user by username for login")? }; let Some(user) = user else { // Run a dummy Argon2 verify to equalize timing with the "wrong password" // path, preventing user enumeration via response time differences. let _ = verify_password_async("dummy".to_string(), DUMMY_HASH.clone()).await; tracing::info!(login = %form.login, event = "login_unknown_user", "Login attempt for non-existent account"); return return_error("Invalid username/email or password"); }; if let Some(locked_until) = user.locked_until && locked_until > chrono::Utc::now() { let remaining = (locked_until - chrono::Utc::now()).num_minutes() + 1; tracing::warn!(user_id = %user.id, event = "login_locked_account", "Login attempt on locked account"); return return_error(&format!( "Account is locked. Try again in {remaining} minute(s), or use the login link sent to your email." )); } // Cap password length to match signup validation (prevents Argon2 DoS with // huge inputs). MUST use the same char-count metric as signup, or a valid // multibyte password (<=128 chars, >128 bytes) is silently rejected here. if crate::validation::password_too_long(&form.password) { return return_error("Invalid username/email or password"); } if !verify_password_async(form.password.clone(), user.password_hash.clone()).await? { // Atomically increment failed attempts and lock if threshold reached let result = db::auth::increment_failed_login(&db, user.id, MAX_LOGIN_ATTEMPTS, LOCKOUT_MINUTES) .await .context("increment failed login attempts")?; tracing::warn!(user_id = %user.id, attempts = result.attempts, event = "login_failed", "Failed login attempt"); if result.just_locked { tracing::warn!(user_id = %user.id, attempts = result.attempts, lockout_minutes = LOCKOUT_MINUTES, event = "account_locked", "Account locked after repeated failures"); // Generate and send one-time login link let (token, token_hash) = email::generate_login_token(); let expires_at = chrono::Utc::now() + chrono::Duration::minutes(LOCKOUT_MINUTES); db::auth::create_login_token(&db, user.id, &token_hash, expires_at) .await .context("create login token after lockout")?; let login_url = email::generate_login_link_url(&config.host_url, &token); // Send lockout notification with login link let user_email = user.email.clone(); let user_display_name = user.display_name.clone(); // Inlined `spawn_email!` (the macro reads .email/.bg off AppState). let email_client = mailer.clone(); bg.spawn("lockout notification", async move { if let Err(e) = email_client .send_lockout_notification( &user_email, user_display_name.as_deref(), Some(&login_url), ) .await { tracing::error!(error = ?e, "failed to send lockout notification"); } }); return return_error(&format!( "Too many failed attempts. Account locked for {LOCKOUT_MINUTES} minutes. A login link has been sent to your email." )); } return return_error("Invalid username/email or password"); } db::auth::reset_failed_login(&db, user.id) .await .context("reset failed login attempts")?; let remember = form.remember_me.as_deref() == Some("on"); // Check if user has 2FA enabled, redirect to verification page if so if user.totp_enabled { session.cycle_id().await.context("session cycle")?; session .insert("pending_2fa_user_id", user.id) .await .context("session insert")?; session .insert("pending_2fa_started_at", chrono::Utc::now().timestamp()) .await .context("session insert")?; session .insert("pending_2fa_notify_email", &user.email) .await .context("session insert")?; session .insert("pending_2fa_notify_name", &user.display_name) .await .context("session insert")?; session .insert("pending_2fa_remember_me", remember) .await .context("session insert")?; // Insert a `pending_2fa` user_sessions row so the intermediate state // is visible to `delete_all_sessions_for_user` ("log out everywhere"). // Without this, a phisher mid-TOTP-prompt holds an authenticated // session-storage entry the sweep can't see. let ua = 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_pending_2fa_session(&db, user.id, ua.as_deref(), ip.as_deref()) .await?; session .insert("pending_2fa_tracking_id", tracking_id) .await .context("session insert")?; tracing::info!(user_id = %user.id, event = "login_2fa_pending", "User requires 2FA verification"); if is_htmx { return Ok((StatusCode::OK, [("HX-Redirect", "/auth/2fa")], "").into_response()); } return Ok(Redirect::to("/auth/2fa").into_response()); } // Capture notification fields before moving user into session let user_id = user.id; let notify_email = user.email.clone(); let notify_name = user.display_name.clone(); let session_user = SessionUser::from_db_user(user, &db, config.admin_user_id).await; login_user(&session, session_user).await?; if !remember { session.set_expiry(Some(Expiry::OnSessionEnd)); } track_session(&session, &db, user_id, &headers).await?; tracing::info!(user_id = %user_id, event = "login_success", "User logged in"); crate::auth::maybe_send_login_notification( &db, &mailer, &bg, &config, user_id, ¬ify_email, notify_name.as_deref(), &headers, ) .await; // For HTMX requests, return redirect header if is_htmx { return Ok((StatusCode::OK, [("HX-Redirect", "/dashboard")], "").into_response()); } Ok(Redirect::to("/dashboard").into_response()) } /// Log out the current user and redirect to the landing page. #[tracing::instrument(skip_all, name = "auth::logout")] async fn logout_handler( State(db): State, State(caches): State, session: Session, ) -> Result { // Clean up tracking row before flushing session. We require the // SessionUser to derive the user_id for the scoped delete; if it's // gone (already-stale session), skip the row delete, the cache // remove below is still safe and the row will get pruned by the // expired-session sweeper. let session_user = session .get::("user") .await .ok() .flatten(); if let Ok(Some(tracking_id)) = session.get::(SESSION_TRACKING_KEY).await { if let Some(ref u) = session_user && let Err(e) = db::sessions::delete_session_by_id(&db, tracking_id, u.id).await { tracing::warn!(tracking_id = %tracking_id, error = ?e, "failed to delete session tracking row on logout"); } caches.session_cache.remove(&tracking_id); } logout_user(&session).await?; Ok(Redirect::to("/")) } /// Return the current session user as JSON, or 401 if not authenticated. /// Uses `AuthUser` to validate session tracking (revocation check). #[tracing::instrument(skip_all, name = "auth::me")] async fn me_handler(AuthUser(user): AuthUser) -> Result { Ok(axum::Json(user)) } /// Form input for live username availability validation. #[derive(Debug, Deserialize)] pub struct ValidateUsernameForm { pub username: String, } /// Check username availability and format, returning an HTMX status snippet. #[tracing::instrument(skip_all, name = "auth::validate_username")] async fn validate_username( State(db): State, Form(form): Form, ) -> crate::error::Result> { // Count characters, not bytes, Username::new uses chars().count() too, // and `len()` on a multi-byte UTF-8 string over-counts (a 3-char ñ-bearing // username trips the "too long" branch erroneously, and a 1-char emoji // satisfies the `< 3` typing-guard with a single grapheme). let char_count = form.username.chars().count(); if char_count < 3 { return Ok(Html(String::new())); } // Check if username is too long if char_count > 50 { return Ok(Html( SaveStatusTemplate { success: false, message: "Username too long".to_string(), } .render_string()?, )); } // Check if username has invalid characters if !form .username .chars() .all(|c| c.is_ascii_alphanumeric() || c == '_') { return Ok(Html( SaveStatusTemplate { success: false, message: "Only letters, numbers, and underscores".to_string(), } .render_string()?, )); } // Anti-enumeration delay: every response takes >= 400ms regardless of outcome tokio::time::sleep(std::time::Duration::from_millis( constants::USERNAME_CHECK_DELAY_MS, )) .await; // Validate and wrap the username (manual checks above cover most cases, // but Username::new is the canonical validation boundary). let Ok(username) = Username::new(&form.username) else { return Ok(Html( SaveStatusTemplate { success: false, message: "Invalid username format".to_string(), } .render_string()?, )); }; // Treat a DB error as "unavailable, retry" rather than "available". Failing // open here previously let users proceed past a transient lookup error and // hit a confusing signup-side rejection or race. match db::users::get_user_by_username(&db, &username).await { Ok(Some(_)) => Ok(Html( UsernameStatusTemplate { available: false }.render_string()?, )), Ok(None) => Ok(Html( UsernameStatusTemplate { available: true }.render_string()?, )), Err(e) => { tracing::warn!(error = ?e, "username availability lookup failed"); Ok(Html( SaveStatusTemplate { success: false, message: "Couldn't check availability, please try again".to_string(), } .render_string()?, )) } } } // --- Passkey / WebAuthn authentication --- /// Session key for in-flight passkey authentication challenge state. const PASSKEY_AUTH_STATE_KEY: &str = "passkey_auth_state"; /// Start passkey authentication: discoverable flow (no username needed). #[tracing::instrument(skip_all, name = "auth::passkey_start")] async fn passkey_auth_start( State(webauthn): State>, session: Session, ) -> Result { let (rcr, auth_state) = webauthn .start_discoverable_authentication() .context("webauthn auth start")?; session .insert(PASSKEY_AUTH_STATE_KEY, &auth_state) .await .context("session error")?; Ok(axum::Json(rcr).into_response()) } /// Finish passkey authentication: verify assertion, create session. #[tracing::instrument(skip_all, name = "auth::passkey_finish")] #[allow(clippy::too_many_arguments)] async fn passkey_auth_finish( State(db): State, State(config): State, State(mailer): State, State(bg): State, State(webauthn): State>, headers: HeaderMap, session: Session, axum::Json(auth): axum::Json, ) -> Result { let auth_state: DiscoverableAuthentication = session .get(PASSKEY_AUTH_STATE_KEY) .await .context("session error")? .ok_or_else(|| AppError::BadRequest("No pending passkey authentication".to_string()))?; // Clean up session state session .remove::(PASSKEY_AUTH_STATE_KEY) .await .ok(); // Identify which credential responded (extracts user UUID + credential ID) let (_user_uuid, cred_id_ref) = webauthn .identify_discoverable_authentication(&auth) .map_err(|e| AppError::BadRequest(format!("Passkey identification failed: {e}")))?; let cred_id_bytes = cred_id_ref.to_vec(); // Look up user by credential ID let (user_id, cred_json) = db::passkeys::find_user_by_credential_id(&db, &cred_id_bytes) .await .context("lookup user by passkey credential")? .ok_or_else(|| AppError::BadRequest("Unknown credential".to_string()))?; // Parse credential and convert for discoverable verification let mut passkey: Passkey = serde_json::from_value(cred_json).context("deserialize passkey credential")?; let discoverable_key = DiscoverableKey::from(&passkey); // Verify the authentication response. webauthn-rs rejects a non-zero // signature-counter regression (CredentialPossibleCompromise) here, before // we ever persist, surface that as a distinct, auditable security event // rather than a generic verification failure (SEC-S2, Run #23). let auth_result = webauthn .finish_discoverable_authentication(&auth, auth_state, &[discoverable_key]) .map_err(|e| { // A rejected assertion is a failed auth attempt, and it leaves as a // 400, so nothing about the response says so either. crate::security_signals::note_auth_failure( crate::helpers::extract_client_ip(&headers).as_deref(), ); if matches!(e, WebauthnError::CredentialPossibleCompromise) { tracing::warn!( user_id = %user_id, event = "passkey_counter_regression", "passkey sign-count regressed; authenticator may be cloned, authentication rejected" ); } AppError::BadRequest(format!("Passkey verification failed: {e}")) })?; // Persist any counter/backup-state advance. update_credential only ever // raises the stored counter (never downgrades), and the regression case was // already rejected above, so this cannot record a cloned counter. passkey.update_credential(&auth_result); let updated_json = serde_json::to_value(&passkey).context("serialize passkey credential")?; db::passkeys::update_passkey_after_auth(&db, &cred_id_bytes, &updated_json) .await .context("update passkey counter after auth")?; // Load full user data for session let user = db::users::get_user_by_id(&db, user_id) .await .with_context(|| format!("fetch user {user_id} for passkey session"))? .ok_or(AppError::Unauthorized)?; if let Some(locked_until) = user.locked_until && locked_until > chrono::Utc::now() { return Err(AppError::BadRequest("Account is locked".to_string())); } // Reset failed login attempts (successful passkey auth) db::auth::reset_failed_login(&db, user.id) .await .context("reset failed login after passkey auth")?; // Create session, passkeys skip TOTP (inherently two-factor) let passkey_user_id = user.id; let notify_email = user.email.clone(); let notify_name = user.display_name.clone(); let session_user = SessionUser::from_db_user(user, &db, config.admin_user_id).await; login_user(&session, session_user).await?; track_session(&session, &db, passkey_user_id, &headers).await?; tracing::info!(user_id = %passkey_user_id, event = "login_passkey_success", "User logged in via passkey"); crate::auth::maybe_send_login_notification( &db, &mailer, &bg, &config, passkey_user_id, ¬ify_email, notify_name.as_deref(), &headers, ) .await; Ok(axum::Json(serde_json::json!({"redirect": "/dashboard"})).into_response()) }