Skip to main content

max / makenotwork

9.5 KB · 304 lines History Blame Raw
1 //! Forgot-password and reset-password handlers.
2
3 use axum::{
4 extract::{Query, State},
5 http::header::HeaderMap,
6 response::{IntoResponse, Redirect, Response},
7 Form,
8 };
9 use serde::Deserialize;
10 use tower_sessions::Session;
11
12 use crate::{
13 auth::hash_password,
14 db::{self, UserId},
15 error::{AppError, Result},
16 helpers::{get_csrf_token, is_htmx_request},
17 templates::*,
18 AppState,
19 };
20
21 /// Render the forgot-password form page.
22 #[tracing::instrument(skip_all, name = "email_actions::forgot_password_page")]
23 pub(super) async fn forgot_password_page(session: Session) -> impl IntoResponse {
24 ForgotPasswordTemplate {
25 csrf_token: get_csrf_token(&session).await,
26 }
27 }
28
29 /// Form input for the forgot-password request.
30 #[derive(Debug, Deserialize)]
31 pub struct ForgotPasswordForm {
32 pub email: String,
33 }
34
35 /// Handle forgot-password submission and send a reset link email.
36 #[tracing::instrument(skip_all, name = "email_actions::forgot_password_handler")]
37 pub(super) async fn forgot_password_handler(
38 State(state): State<AppState>,
39 headers: HeaderMap,
40 Form(form): Form<ForgotPasswordForm>,
41 ) -> Result<Response> {
42 let is_htmx = is_htmx_request(&headers);
43
44 // Always return success to prevent email enumeration
45 let success_alert = AlertTemplate::new(
46 "success",
47 "If an account exists with that email, we've sent a password reset link.",
48 );
49
50 // Look up user by email
51 let Ok(email) = db::Email::new(&form.email) else {
52 // Same generic response as "email exists but no account" to avoid leaking validity.
53 return Ok(success_alert.into_response());
54 };
55 let user = match db::users::get_user_by_email(&state.db, &email).await? {
56 Some(u) => u,
57 None => {
58 // Don't reveal that email doesn't exist
59 tracing::info!(
60 event = "password_reset_unknown_email",
61 "Password reset for non-existent email"
62 );
63 if is_htmx {
64 return Ok(success_alert.into_response());
65 }
66 return Ok(Redirect::to("/login").into_response());
67 }
68 };
69
70 // Generate password reset URL
71 let reset_url = crate::email::generate_password_reset_url(
72 &state.config.host_url,
73 user.id,
74 &user.password_hash,
75 &state.config.signing_secret,
76 );
77
78 // Send email
79 if let Err(e) = state
80 .email
81 .send_password_reset(&user.email, user.display_name.as_deref(), &reset_url)
82 .await
83 {
84 tracing::error!(error = ?e, "failed to send password reset email");
85 // Still return success to prevent enumeration
86 } else {
87 tracing::info!(user_id = %user.id, event = "password_reset_sent", "Password reset email sent");
88 }
89
90 if is_htmx {
91 return Ok(success_alert.into_response());
92 }
93
94 Ok(Redirect::to("/login").into_response())
95 }
96
97 /// Query parameters for the password reset link.
98 #[derive(Debug, Deserialize)]
99 pub struct ResetPasswordQuery {
100 pub user: Option<String>,
101 pub expires: Option<i64>,
102 pub sig: Option<String>,
103 }
104
105 /// Render the password reset form after validating the signed link.
106 #[tracing::instrument(skip_all, name = "email_actions::reset_password_page")]
107 pub(super) async fn reset_password_page(
108 State(state): State<AppState>,
109 session: Session,
110 Query(query): Query<ResetPasswordQuery>,
111 ) -> impl IntoResponse {
112 let csrf_token = get_csrf_token(&session).await;
113
114 // Validate all required parameters are present
115 let (user_id_str, expires, sig) = match (&query.user, query.expires, &query.sig) {
116 (Some(u), Some(e), Some(s)) => (u.clone(), e, s.clone()),
117 _ => {
118 return ResetPasswordTemplate {
119 csrf_token,
120 valid: false,
121 user_id: String::new(),
122 expires: String::new(),
123 sig: String::new(),
124 error: None,
125 };
126 }
127 };
128
129 // Parse user ID
130 let user_id: UserId = match user_id_str.parse() {
131 Ok(id) => id,
132 Err(_) => {
133 return ResetPasswordTemplate {
134 csrf_token,
135 valid: false,
136 user_id: String::new(),
137 expires: String::new(),
138 sig: String::new(),
139 error: None,
140 };
141 }
142 };
143
144 // Get user to verify signature against their password hash
145 let user = match db::users::get_user_by_id(&state.db, user_id).await {
146 Ok(Some(u)) => u,
147 _ => {
148 return ResetPasswordTemplate {
149 csrf_token,
150 valid: false,
151 user_id: String::new(),
152 expires: String::new(),
153 sig: String::new(),
154 error: None,
155 };
156 }
157 };
158
159 // Verify HMAC signature
160 let valid = crate::email::verify_password_reset_signature(
161 user_id,
162 expires,
163 &user.password_hash,
164 &sig,
165 &state.config.signing_secret,
166 );
167
168 ResetPasswordTemplate {
169 csrf_token,
170 valid,
171 user_id: user_id_str,
172 expires: expires.to_string(),
173 sig,
174 error: None,
175 }
176 }
177
178 /// Form input for submitting a new password via the reset flow.
179 #[derive(Debug, Deserialize)]
180 pub struct ResetPasswordForm {
181 pub user: String,
182 pub expires: String,
183 pub sig: String,
184 pub password: String,
185 pub password_confirm: String,
186 }
187
188 /// Verify the reset signature and update the user's password.
189 #[tracing::instrument(skip_all, name = "email_actions::reset_password_handler")]
190 pub(super) async fn reset_password_handler(
191 State(state): State<AppState>,
192 session: Session,
193 headers: HeaderMap,
194 Form(form): Form<ResetPasswordForm>,
195 ) -> Result<Response> {
196 let is_htmx = is_htmx_request(&headers);
197 // Pre-fetch the CSRF token so the sync error closure can recall it.
198 let recall_csrf_token = if is_htmx {
199 None
200 } else {
201 get_csrf_token(&session).await
202 };
203 let recall_user = form.user.clone();
204 let recall_expires = form.expires.clone();
205 let recall_sig = form.sig.clone();
206
207 // Helper to return error. Non-HTMX path re-renders the reset form with
208 // the signed link fields intact + the error inlined so the user can fix
209 // their input without losing the email-delivered token.
210 let return_error = |msg: &str| -> Result<Response> {
211 if is_htmx {
212 Ok(AlertTemplate::new("error", msg).into_response())
213 } else {
214 Ok(ResetPasswordTemplate {
215 csrf_token: recall_csrf_token.clone(),
216 valid: true,
217 user_id: recall_user.clone(),
218 expires: recall_expires.clone(),
219 sig: recall_sig.clone(),
220 error: Some(msg.to_string()),
221 }.into_response())
222 }
223 };
224
225 // Validate passwords match
226 if form.password != form.password_confirm {
227 return return_error("Passwords do not match");
228 }
229
230 // Validate password length
231 let password_len = form.password.chars().count();
232 if password_len < 8 {
233 return return_error("Password must be at least 8 characters");
234 }
235 if password_len > 128 {
236 return return_error("Password must be 128 characters or fewer");
237 }
238
239 // Parse user ID and expires
240 let user_id: UserId = form
241 .user
242 .parse()
243 .map_err(|_| AppError::BadRequest("Invalid user ID".to_string()))?;
244 let expires: i64 = form
245 .expires
246 .parse()
247 .map_err(|_| AppError::BadRequest("Invalid expiry".to_string()))?;
248
249 // Get user
250 let user = db::users::get_user_by_id(&state.db, user_id)
251 .await?
252 .ok_or_else(|| AppError::BadRequest("Invalid reset link".to_string()))?;
253
254 // Verify signature
255 if !crate::email::verify_password_reset_signature(
256 user_id,
257 expires,
258 &user.password_hash,
259 &form.sig,
260 &state.config.signing_secret,
261 ) {
262 return return_error("Reset link has expired or is invalid");
263 }
264
265 // Check for breached password (advisory only, don't block)
266 if let Some(count) = crate::auth::check_password_breach(&form.password).await {
267 tracing::warn!(user_id = %user_id, event = "breached_password_reset", breach_count = count, "Password reset to breached password");
268 session
269 .insert(
270 "password_warning",
271 format!(
272 "This password has appeared in {} known data breach(es). Consider changing it.",
273 count
274 ),
275 )
276 .await
277 .ok();
278 }
279
280 // Hash new password and update
281 let new_password_hash = hash_password(&form.password)?;
282 db::users::update_user_password(&state.db, user_id, &new_password_hash).await?;
283
284 // Invalidate all sessions so stolen sessions can't survive a password reset
285 let revoked = db::sessions::delete_all_sessions_for_user(&state.db, user_id).await?;
286 for sid in &revoked {
287 state.session_cache.remove(sid);
288 }
289 if !revoked.is_empty() {
290 tracing::info!(user_id = %user_id, revoked = revoked.len(), event = "password_reset_revoke_sessions", "Revoked sessions on password reset");
291 }
292
293 tracing::info!(user_id = %user_id, event = "password_reset_complete", "Password reset completed");
294
295 // Return success
296 if is_htmx {
297 return Ok(AlertTemplate::new("success", "Password updated successfully.")
298 .with_link("/login", "Log in")
299 .into_response());
300 }
301
302 Ok(Redirect::to("/login").into_response())
303 }
304