Skip to main content

max / makenotwork

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