Skip to main content

max / makenotwork

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