//! Forgot-password and reset-password handlers. use axum::{ extract::{Query, State}, http::header::HeaderMap, response::{IntoResponse, Redirect, Response}, Form, }; use serde::Deserialize; use tower_sessions::Session; use crate::{ auth::hash_password, db::{self, UserId}, error::{AppError, Result}, helpers::{get_csrf_token, is_htmx_request}, templates::*, AppState, }; /// 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 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(state): 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(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 user = match db::users::get_user_by_email(&state.db, &email).await? { Some(u) => u, None => { // 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()); } }; // Generate password reset URL let reset_url = crate::email::generate_password_reset_url( &state.config.host_url, user.id, &user.password_hash, &state.config.signing_secret, ); // Send email if let Err(e) = state .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 struct ResetPasswordQuery { pub user: Option, pub expires: Option, pub sig: Option, } /// Render the password reset form after validating the signed link. #[tracing::instrument(skip_all, name = "email_actions::reset_password_page")] pub(super) async fn reset_password_page( State(state): State, session: Session, Query(query): Query, ) -> impl IntoResponse { let csrf_token = get_csrf_token(&session).await; // Validate all required parameters are present let (user_id_str, expires, sig) = match (&query.user, query.expires, &query.sig) { (Some(u), Some(e), Some(s)) => (u.clone(), e, s.clone()), _ => { return ResetPasswordTemplate { csrf_token, valid: false, user_id: String::new(), expires: String::new(), sig: String::new(), error: None, }; } }; // Parse user ID let user_id: UserId = match user_id_str.parse() { Ok(id) => id, Err(_) => { return ResetPasswordTemplate { csrf_token, valid: false, user_id: String::new(), expires: String::new(), sig: String::new(), error: None, }; } }; // Get user to verify signature against their password hash let user = match db::users::get_user_by_id(&state.db, user_id).await { Ok(Some(u)) => u, _ => { return ResetPasswordTemplate { csrf_token, valid: false, user_id: String::new(), expires: String::new(), sig: String::new(), error: None, }; } }; // Verify HMAC signature let valid = crate::email::verify_password_reset_signature( user_id, expires, &user.password_hash, &sig, &state.config.signing_secret, ); ResetPasswordTemplate { csrf_token, valid, user_id: user_id_str, expires: expires.to_string(), sig, error: None, } } /// Form input for submitting a new password via the reset flow. #[derive(Debug, Deserialize)] pub struct ResetPasswordForm { pub user: String, pub expires: String, pub sig: 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(state): 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_user = form.user.clone(); let recall_expires = form.expires.clone(); let recall_sig = form.sig.clone(); // Helper to return error. Non-HTMX path re-renders the reset form with // the signed link fields intact + the error inlined so the user can fix // their input without losing the email-delivered token. 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, user_id: recall_user.clone(), expires: recall_expires.clone(), sig: recall_sig.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 < 8 { return return_error("Password must be at least 8 characters"); } if password_len > 128 { return return_error("Password must be 128 characters or fewer"); } // Parse user ID and expires let user_id: UserId = form .user .parse() .map_err(|_| AppError::BadRequest("Invalid user ID".to_string()))?; let expires: i64 = form .expires .parse() .map_err(|_| AppError::BadRequest("Invalid expiry".to_string()))?; // Get user let user = db::users::get_user_by_id(&state.db, user_id) .await? .ok_or_else(|| AppError::BadRequest("Invalid reset link".to_string()))?; // Verify signature if !crate::email::verify_password_reset_signature( user_id, expires, &user.password_hash, &form.sig, &state.config.signing_secret, ) { return return_error("Reset link has expired or is invalid"); } // 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 {} known data breach(es). Consider changing it.", count ), ) .await .ok(); } // Hash new password and update let new_password_hash = hash_password(&form.password)?; db::users::update_user_password(&state.db, user_id, &new_password_hash).await?; // Invalidate all sessions so stolen sessions can't survive a password reset let revoked = db::sessions::delete_all_sessions_for_user(&state.db, user_id).await?; for sid in &revoked { state.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()) }