//! Forgot-password and reset-password handlers. use crate::extractors::ValidatedQuery; use axum::{ Form, extract::State, http::header::HeaderMap, response::{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}, templates::{AlertTemplate, ForgotPasswordTemplate, ResetPasswordTemplate}, }; /// 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 { ForgotPasswordTemplate { csrf_token: get_csrf_token(&session).await, } } /// 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 let success_alert = AlertTemplate::new( "success", "If an account exists with that email, we've sent a password reset link.", ); // 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.into_response()); }; 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.into_response()); } 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.into_response()); } 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.into_response()); } 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 invalid = |csrf_token| ResetPasswordTemplate { csrf_token, valid: false, token: String::new(), error: None, }; let Some(token) = query.token.filter(|t| !t.is_empty()) else { return invalid(csrf_token); }; let token_hash = crate::email::hash_opaque_token(&token); let valid = matches!( db::auth::peek_password_reset_token(&db, &token_hash).await, Ok(Some(_)) ); ResetPasswordTemplate { csrf_token, valid, token: if valid { token } else { String::new() }, error: None, } } /// 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(AlertTemplate::new("error", msg).into_response()) } else { Ok(ResetPasswordTemplate { csrf_token: recall_csrf_token.clone(), valid: true, token: recall_token.clone(), error: Some(msg.to_string()), } .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(AlertTemplate::new( "error", "This reset link has expired or has already been used. Please request a new one.", ) .into_response()); } return Ok(ResetPasswordTemplate { csrf_token: recall_csrf_token, valid: false, token: String::new(), error: 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( AlertTemplate::new("success", "Password updated successfully.") .with_link("/login", "Log in") .into_response(), ); } Ok(Redirect::to("/login").into_response()) }