//! Forgot-password and reset-password handlers. use crate::extractors::ValidatedQuery; use axum::{ Form, extract::State, http::header::HeaderMap, response::{Html, IntoResponse, Redirect, Response}, }; use serde::Deserialize; use sqlx::PgPool; use tower_sessions::Session; use crate::{ AppCaches, auth::hash_password_async, config::Config, db::{self}, email::EmailClient, error::Result, helpers::{get_csrf_token, is_htmx_request}, quasi::auth_pages, }; /// Render the forgot-password form page. #[tracing::instrument(skip_all, name = "email_actions::forgot_password_page")] pub(super) async fn forgot_password_page(session: Session) -> impl IntoResponse { let csrf_token = get_csrf_token(&session).await; Html(auth_pages::document( csrf_token.as_deref(), &auth_pages::forgot_password(), )) } /// Form input for the forgot-password request. #[derive(Debug, Deserialize)] pub(super) struct ForgotPasswordForm { pub email: String, } /// Handle forgot-password submission and send a reset link email. #[tracing::instrument(skip_all, name = "email_actions::forgot_password_handler")] pub(super) async fn forgot_password_handler( State(db): State, State(config): State, State(email): State, headers: HeaderMap, Form(form): Form, ) -> Result { let is_htmx = is_htmx_request(&headers); // Always return success to prevent email enumeration // Into the region the described form aims at, keeping its id: see // `auth_pages::answered`. let success_alert = || { Html(auth_pages::answered( auth_pages::FEEDBACK, makeover_layout::Tone::Success, "If an account exists with that email, we've sent a password reset link.", None, )) .into_response() }; // Look up user by email let Ok(parsed_email) = db::Email::new(&form.email) else { // Same generic response as "email exists but no account" to avoid leaking validity. return Ok(success_alert()); }; let Some(user) = db::users::get_user_by_email(&db, &parsed_email).await? else { // Don't reveal that email doesn't exist tracing::info!( event = "password_reset_unknown_email", "Password reset for non-existent email" ); if is_htmx { return Ok(success_alert()); } return Ok(Redirect::to("/login").into_response()); }; // Mint a single-use reset token, persist its hash, and email the link. The // raw token lives only in the URL; only its hash is stored, and the row is // consumed atomically on submit so the link cannot be replayed. let (token, token_hash) = crate::email::generate_password_reset_token(); let expires_at = chrono::Utc::now() + chrono::Duration::seconds(crate::constants::PASSWORD_RESET_EXPIRY_SECS); if let Err(e) = db::auth::create_password_reset_token(&db, user.id, &token_hash, expires_at).await { tracing::error!(error = ?e, "failed to persist password reset token"); // Still return the generic success response to avoid enumeration. if is_htmx { return Ok(success_alert()); } return Ok(Redirect::to("/login").into_response()); } let reset_url = crate::email::generate_reset_link_url(&config.host_url, &token); // Send email if let Err(e) = email .send_password_reset(&user.email, user.display_name.as_deref(), &reset_url) .await { tracing::error!(error = ?e, "failed to send password reset email"); // Still return success to prevent enumeration } else { tracing::info!(user_id = %user.id, event = "password_reset_sent", "Password reset email sent"); } if is_htmx { return Ok(success_alert()); } Ok(Redirect::to("/login").into_response()) } /// Query parameters for the password reset link. #[derive(Debug, Deserialize)] pub(super) struct ResetPasswordQuery { pub token: Option, } /// Render the password reset form after checking the token is still valid. /// /// This only *peeks*, the token is spent on submit, not on viewing the form, /// so a prefetch (link scanner, browser preview) can't burn the user's link. #[tracing::instrument(skip_all, name = "email_actions::reset_password_page")] pub(super) async fn reset_password_page( State(db): State, session: Session, ValidatedQuery(query): ValidatedQuery, ) -> impl IntoResponse { let csrf_token = get_csrf_token(&session).await; let page = |valid: bool, token: &str| { Html(auth_pages::document( csrf_token.as_deref(), &auth_pages::reset_password(valid, token, None), )) }; let Some(token) = query.token.filter(|t| !t.is_empty()) else { return page(false, ""); }; let token_hash = crate::email::hash_opaque_token(&token); let valid = matches!( db::auth::peek_password_reset_token(&db, &token_hash).await, Ok(Some(_)) ); page(valid, if valid { &token } else { "" }) } /// Form input for submitting a new password via the reset flow. #[derive(Debug, Deserialize)] pub(super) struct ResetPasswordForm { pub token: String, pub password: String, pub password_confirm: String, } /// Verify the reset signature and update the user's password. #[tracing::instrument(skip_all, name = "email_actions::reset_password_handler")] pub(super) async fn reset_password_handler( State(db): State, State(caches): State, session: Session, headers: HeaderMap, Form(form): Form, ) -> Result { let is_htmx = is_htmx_request(&headers); // Pre-fetch the CSRF token so the sync error closure can recall it. let recall_csrf_token = if is_htmx { None } else { get_csrf_token(&session).await }; let recall_token = form.token.clone(); // Helper to return error. Non-HTMX path re-renders the reset form with the // token field intact + the error inlined so the user can fix their input // without losing the email-delivered token. These errors fire *before* the // token is consumed, so retrying still works. let return_error = |msg: &str| -> Result { if is_htmx { Ok(Html(auth_pages::answered( auth_pages::FEEDBACK, makeover_layout::Tone::Danger, msg, None, )) .into_response()) } else { Ok(Html(auth_pages::document( recall_csrf_token.as_deref(), &auth_pages::reset_password(true, &recall_token, Some(msg)), )) .into_response()) } }; // Validate passwords match if form.password != form.password_confirm { return return_error("Passwords do not match"); } // Validate password length let password_len = form.password.chars().count(); if password_len < crate::validation::limits::PASSWORD_MIN { return return_error("Password must be at least 8 characters"); } if crate::validation::password_too_long(&form.password) { return return_error("Password must be 128 characters or fewer"); } // Atomically consume the single-use token. A replay, an expired link, or a // forged token all fail here; the UPDATE...WHERE used_at IS NULL guarantees // a concurrent double-submit can never both succeed. Done only after the // cheap form validations so a mistyped confirmation doesn't burn the link. let token_hash = crate::email::hash_opaque_token(&form.token); let Some(user_id) = db::auth::consume_password_reset_token(&db, &token_hash).await? else { // Token is gone, re-rendering the form would be a dead end, so show the // expired/invalid state with a path to request a fresh link. if is_htmx { return Ok(Html(auth_pages::answered( auth_pages::FEEDBACK, makeover_layout::Tone::Danger, "This reset link has expired or has already been used. Please request a new one.", Some(("/forgot-password", "Request New Link")), )) .into_response()); } return Ok(Html(auth_pages::document( recall_csrf_token.as_deref(), &auth_pages::reset_password(false, "", None), )) .into_response()); }; // Check for breached password (advisory only, don't block) if let Some(count) = crate::auth::check_password_breach(&form.password).await { tracing::warn!(user_id = %user_id, event = "breached_password_reset", breach_count = count, "Password reset to breached password"); session .insert( "password_warning", format!( "This password has appeared in {count} known data breach(es). Consider changing it." ), ) .await .ok(); } // Hash new password and update let new_password_hash = hash_password_async(form.password.clone()).await?; db::users::update_user_password(&db, user_id, &new_password_hash).await?; // Kill any other outstanding reset links for this user (e.g. a double // request): completing one reset invalidates them all. db::auth::invalidate_password_reset_tokens(&db, user_id).await?; // Invalidate all sessions so stolen sessions can't survive a password reset let revoked = db::sessions::delete_all_sessions_for_user(&db, user_id).await?; for sid in &revoked { caches.session_cache.remove(sid); } if !revoked.is_empty() { tracing::info!(user_id = %user_id, revoked = revoked.len(), event = "password_reset_revoke_sessions", "Revoked sessions on password reset"); } tracing::info!(user_id = %user_id, event = "password_reset_complete", "Password reset completed"); // Return success if is_htmx { return Ok(Html(auth_pages::answered( auth_pages::FEEDBACK, makeover_layout::Tone::Success, "Password updated successfully.", Some(("/login", "Log in")), )) .into_response()); } Ok(Redirect::to("/login").into_response()) }