Skip to main content

max / makenotwork

9.7 KB · 277 lines History Blame Raw
1 //! Forgot-password and reset-password handlers.
2
3 use axum::{
4 Form,
5 extract::{Query, State},
6 http::header::HeaderMap,
7 response::{IntoResponse, Redirect, Response},
8 };
9 use serde::Deserialize;
10 use sqlx::PgPool;
11 use tower_sessions::Session;
12
13 use crate::{
14 AppCaches,
15 auth::hash_password_async,
16 config::Config,
17 db::{self},
18 email::EmailClient,
19 error::Result,
20 helpers::{get_csrf_token, is_htmx_request},
21 templates::{AlertTemplate, ForgotPasswordTemplate, ResetPasswordTemplate},
22 };
23
24 /// Render the forgot-password form page.
25 #[tracing::instrument(skip_all, name = "email_actions::forgot_password_page")]
26 pub(super) async fn forgot_password_page(session: Session) -> impl IntoResponse {
27 ForgotPasswordTemplate {
28 csrf_token: get_csrf_token(&session).await,
29 }
30 }
31
32 /// Form input for the forgot-password request.
33 #[derive(Debug, Deserialize)]
34 pub(super) struct ForgotPasswordForm {
35 pub email: String,
36 }
37
38 /// Handle forgot-password submission and send a reset link email.
39 #[tracing::instrument(skip_all, name = "email_actions::forgot_password_handler")]
40 pub(super) async fn forgot_password_handler(
41 State(db): State<PgPool>,
42 State(config): State<Config>,
43 State(email): State<EmailClient>,
44 headers: HeaderMap,
45 Form(form): Form<ForgotPasswordForm>,
46 ) -> Result<Response> {
47 let is_htmx = is_htmx_request(&headers);
48
49 // Always return success to prevent email enumeration
50 let success_alert = AlertTemplate::new(
51 "success",
52 "If an account exists with that email, we've sent a password reset link.",
53 );
54
55 // Look up user by email
56 let Ok(parsed_email) = db::Email::new(&form.email) else {
57 // Same generic response as "email exists but no account" to avoid leaking validity.
58 return Ok(success_alert.into_response());
59 };
60 let Some(user) = db::users::get_user_by_email(&db, &parsed_email).await? else {
61 // Don't reveal that email doesn't exist
62 tracing::info!(
63 event = "password_reset_unknown_email",
64 "Password reset for non-existent email"
65 );
66 if is_htmx {
67 return Ok(success_alert.into_response());
68 }
69 return Ok(Redirect::to("/login").into_response());
70 };
71
72 // Mint a single-use reset token, persist its hash, and email the link. The
73 // raw token lives only in the URL; only its hash is stored, and the row is
74 // consumed atomically on submit so the link cannot be replayed.
75 let (token, token_hash) = crate::email::generate_password_reset_token();
76 let expires_at = chrono::Utc::now()
77 + chrono::Duration::seconds(crate::constants::PASSWORD_RESET_EXPIRY_SECS);
78 if let Err(e) =
79 db::auth::create_password_reset_token(&db, user.id, &token_hash, expires_at).await
80 {
81 tracing::error!(error = ?e, "failed to persist password reset token");
82 // Still return the generic success response to avoid enumeration.
83 if is_htmx {
84 return Ok(success_alert.into_response());
85 }
86 return Ok(Redirect::to("/login").into_response());
87 }
88 let reset_url = crate::email::generate_reset_link_url(&config.host_url, &token);
89
90 // Send email
91 if let Err(e) = email
92 .send_password_reset(&user.email, user.display_name.as_deref(), &reset_url)
93 .await
94 {
95 tracing::error!(error = ?e, "failed to send password reset email");
96 // Still return success to prevent enumeration
97 } else {
98 tracing::info!(user_id = %user.id, event = "password_reset_sent", "Password reset email sent");
99 }
100
101 if is_htmx {
102 return Ok(success_alert.into_response());
103 }
104
105 Ok(Redirect::to("/login").into_response())
106 }
107
108 /// Query parameters for the password reset link.
109 #[derive(Debug, Deserialize)]
110 pub(super) struct ResetPasswordQuery {
111 pub token: Option<String>,
112 }
113
114 /// Render the password reset form after checking the token is still valid.
115 ///
116 /// This only *peeks*, the token is spent on submit, not on viewing the form,
117 /// so a prefetch (link scanner, browser preview) can't burn the user's link.
118 #[tracing::instrument(skip_all, name = "email_actions::reset_password_page")]
119 pub(super) async fn reset_password_page(
120 State(db): State<PgPool>,
121 session: Session,
122 Query(query): Query<ResetPasswordQuery>,
123 ) -> impl IntoResponse {
124 let csrf_token = get_csrf_token(&session).await;
125
126 let invalid = |csrf_token| ResetPasswordTemplate {
127 csrf_token,
128 valid: false,
129 token: String::new(),
130 error: None,
131 };
132
133 let Some(token) = query.token.filter(|t| !t.is_empty()) else {
134 return invalid(csrf_token);
135 };
136
137 let token_hash = crate::email::hash_opaque_token(&token);
138 let valid = matches!(
139 db::auth::peek_password_reset_token(&db, &token_hash).await,
140 Ok(Some(_))
141 );
142
143 ResetPasswordTemplate {
144 csrf_token,
145 valid,
146 token: if valid { token } else { String::new() },
147 error: None,
148 }
149 }
150
151 /// Form input for submitting a new password via the reset flow.
152 #[derive(Debug, Deserialize)]
153 pub(super) struct ResetPasswordForm {
154 pub token: String,
155 pub password: String,
156 pub password_confirm: String,
157 }
158
159 /// Verify the reset signature and update the user's password.
160 #[tracing::instrument(skip_all, name = "email_actions::reset_password_handler")]
161 pub(super) async fn reset_password_handler(
162 State(db): State<PgPool>,
163 State(caches): State<AppCaches>,
164 session: Session,
165 headers: HeaderMap,
166 Form(form): Form<ResetPasswordForm>,
167 ) -> Result<Response> {
168 let is_htmx = is_htmx_request(&headers);
169 // Pre-fetch the CSRF token so the sync error closure can recall it.
170 let recall_csrf_token = if is_htmx {
171 None
172 } else {
173 get_csrf_token(&session).await
174 };
175 let recall_token = form.token.clone();
176
177 // Helper to return error. Non-HTMX path re-renders the reset form with the
178 // token field intact + the error inlined so the user can fix their input
179 // without losing the email-delivered token. These errors fire *before* the
180 // token is consumed, so retrying still works.
181 let return_error = |msg: &str| -> Result<Response> {
182 if is_htmx {
183 Ok(AlertTemplate::new("error", msg).into_response())
184 } else {
185 Ok(ResetPasswordTemplate {
186 csrf_token: recall_csrf_token.clone(),
187 valid: true,
188 token: recall_token.clone(),
189 error: Some(msg.to_string()),
190 }
191 .into_response())
192 }
193 };
194
195 // Validate passwords match
196 if form.password != form.password_confirm {
197 return return_error("Passwords do not match");
198 }
199
200 // Validate password length
201 let password_len = form.password.chars().count();
202 if password_len < crate::validation::limits::PASSWORD_MIN {
203 return return_error("Password must be at least 8 characters");
204 }
205 if crate::validation::password_too_long(&form.password) {
206 return return_error("Password must be 128 characters or fewer");
207 }
208
209 // Atomically consume the single-use token. A replay, an expired link, or a
210 // forged token all fail here; the UPDATE...WHERE used_at IS NULL guarantees
211 // a concurrent double-submit can never both succeed. Done only after the
212 // cheap form validations so a mistyped confirmation doesn't burn the link.
213 let token_hash = crate::email::hash_opaque_token(&form.token);
214 let Some(user_id) = db::auth::consume_password_reset_token(&db, &token_hash).await? else {
215 // Token is gone, re-rendering the form would be a dead end, so show the
216 // expired/invalid state with a path to request a fresh link.
217 if is_htmx {
218 return Ok(AlertTemplate::new(
219 "error",
220 "This reset link has expired or has already been used. Please request a new one.",
221 )
222 .into_response());
223 }
224 return Ok(ResetPasswordTemplate {
225 csrf_token: recall_csrf_token,
226 valid: false,
227 token: String::new(),
228 error: None,
229 }
230 .into_response());
231 };
232
233 // Check for breached password (advisory only, don't block)
234 if let Some(count) = crate::auth::check_password_breach(&form.password).await {
235 tracing::warn!(user_id = %user_id, event = "breached_password_reset", breach_count = count, "Password reset to breached password");
236 session
237 .insert(
238 "password_warning",
239 format!(
240 "This password has appeared in {count} known data breach(es). Consider changing it."
241 ),
242 )
243 .await
244 .ok();
245 }
246
247 // Hash new password and update
248 let new_password_hash = hash_password_async(form.password.clone()).await?;
249 db::users::update_user_password(&db, user_id, &new_password_hash).await?;
250
251 // Kill any other outstanding reset links for this user (e.g. a double
252 // request): completing one reset invalidates them all.
253 db::auth::invalidate_password_reset_tokens(&db, user_id).await?;
254
255 // Invalidate all sessions so stolen sessions can't survive a password reset
256 let revoked = db::sessions::delete_all_sessions_for_user(&db, user_id).await?;
257 for sid in &revoked {
258 caches.session_cache.remove(sid);
259 }
260 if !revoked.is_empty() {
261 tracing::info!(user_id = %user_id, revoked = revoked.len(), event = "password_reset_revoke_sessions", "Revoked sessions on password reset");
262 }
263
264 tracing::info!(user_id = %user_id, event = "password_reset_complete", "Password reset completed");
265
266 // Return success
267 if is_htmx {
268 return Ok(
269 AlertTemplate::new("success", "Password updated successfully.")
270 .with_link("/login", "Log in")
271 .into_response(),
272 );
273 }
274
275 Ok(Redirect::to("/login").into_response())
276 }
277