//! Authentication routes for login, signup, logout, and password reset use axum::{ extract::State, handler::Handler, http::{header::HeaderMap, StatusCode}, response::{Html, IntoResponse, Redirect, Response}, routing::{get, post}, Form, }; use serde::Deserialize; use tower_governor::GovernorLayer; use tower_sessions::{Expiry, Session}; use crate::{ auth::{login_user, logout_user, track_session, verify_password, AuthUser, SessionUser, SESSION_TRACKING_KEY}, csrf::{post_csrf, with_csrf, with_csrf_manual, with_csrf_skip, CsrfRouter}, constants::{self, MAX_LOGIN_ATTEMPTS, LOCKOUT_MINUTES}, db::{self, UserSessionId, Username}, email, error::{AppError, Result, ResultExt}, helpers::{is_htmx_request, rate_limiter_ms, rate_limiter_per_sec, spawn_email}, templates::*, AppState, }; 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() -> CsrfRouter { let auth_rate_limit = rate_limiter_ms(constants::AUTH_RATE_LIMIT_MS, constants::AUTH_RATE_LIMIT_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 { config: auth_rate_limit.clone() })), )) .route("/auth/passkey/start", with_csrf_skip( "pre-auth WebAuthn challenge", post(passkey_auth_start) .layer(GovernorLayer { config: auth_rate_limit.clone() }), )) .route("/auth/passkey/finish", with_csrf_skip( "pre-auth WebAuthn assertion", post(passkey_auth_finish) .layer(GovernorLayer { config: 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 { config: 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(state): 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::extract_token_from_request(&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 = state.config.sso.is_some(); let return_error = |msg: &str| -> Result { 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("dummy", &DUMMY_HASH); return return_error("Invalid username or password"); }; db::users::get_user_by_email(&state.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 username = match Username::new(&form.login) { Ok(u) => u, Err(_) => { let _ = verify_password("dummy", &DUMMY_HASH); return return_error("Invalid username/email or password"); } }; db::users::get_user_by_username(&state.db, &username) .await .context("lookup user by username for login")? }; let user = match user { Some(u) => u, None => { // Run a dummy Argon2 verify to equalize timing with the "wrong password" // path, preventing user enumeration via response time differences. let _ = verify_password("dummy", &DUMMY_HASH); 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 {} minute(s), or use the login link sent to your email.", remaining )); } // Cap password length to match signup validation (prevents Argon2 DoS with huge inputs) if form.password.len() > 128 { return return_error("Invalid username/email or password"); } if !verify_password(&form.password, &user.password_hash)? { // Atomically increment failed attempts and lock if threshold reached let result = db::auth::increment_failed_login( &state.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(&state.db, user.id, &token_hash, expires_at) .await .context("create login token after lockout")?; let login_url = email::generate_login_link_url(&state.config.host_url, &token); // Send lockout notification with login link let user_email = user.email.clone(); let user_display_name = user.display_name.clone(); spawn_email!(state, "lockout notification", |email| { email.send_lockout_notification(&user_email, user_display_name.as_deref(), Some(&login_url)) }); return return_error(&format!( "Too many failed attempts. Account locked for {} minutes. A login link has been sent to your email.", LOCKOUT_MINUTES )); } return return_error("Invalid username/email or password"); } db::auth::reset_failed_login(&state.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_enabled", user.login_notification_enabled).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( &state.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 notify_enabled = user.login_notification_enabled; let session_user = SessionUser::from_db_user(user, &state.db, state.config.admin_user_id).await; login_user(&session, session_user).await?; if !remember { session.set_expiry(Some(Expiry::OnSessionEnd)); } track_session(&session, &state.db, user_id, &headers).await?; tracing::info!(user_id = %user_id, event = "login_success", "User logged in"); crate::auth::maybe_send_login_notification( &state, user_id, ¬ify_email, notify_name.as_deref(), notify_enabled, &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(state): 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(&state.db, tracking_id, u.id).await { tracing::warn!(tracking_id = %tracking_id, error = ?e, "failed to delete session tracking row on logout"); } state.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(state): State, Form(form): Form, ) -> impl IntoResponse { // 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 Html(String::new()); } // Check if username is too long if char_count > 50 { return 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 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 username = match Username::new(&form.username) { Ok(u) => u, Err(_) => { return 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(&state.db, &username).await { Ok(Some(_)) => Html(UsernameStatusTemplate { available: false }.render_string()), Ok(None) => Html(UsernameStatusTemplate { available: true }.render_string()), Err(e) => { tracing::warn!(error = ?e, "username availability lookup failed"); 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(state): State, session: Session, ) -> Result { let (rcr, auth_state) = 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")] async fn passkey_auth_finish( State(state): 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) = state .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(&state.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 let auth_result = state .webauthn .finish_discoverable_authentication(&auth, auth_state, &[discoverable_key]) .map_err(|e| AppError::BadRequest(format!("Passkey verification failed: {}", e)))?; // Update the credential counter to prevent cloning attacks passkey.update_credential(&auth_result); let updated_json = serde_json::to_value(&passkey) .context("serialize passkey credential")?; db::passkeys::update_passkey_after_auth(&state.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(&state.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(&state.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 notify_enabled = user.login_notification_enabled; let session_user = SessionUser::from_db_user(user, &state.db, state.config.admin_user_id).await; login_user(&session, session_user).await?; track_session(&session, &state.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( &state, passkey_user_id, ¬ify_email, notify_name.as_deref(), notify_enabled, &headers, ).await; Ok(axum::Json(serde_json::json!({"redirect": "/dashboard"})).into_response()) }