steid

@jamesgill /

Rust17.9 KBCode·Blame·History·Raw
1//! Forgetting a password and choosing a new one — `/auth/forgot` and
2//! `/auth/reset/{token}`.
3//!
4//! Both are anonymous and both are rate limited on the sign-in budget, because both are
5//! ways to make the server hash something for a stranger.
6//!
7//! **Every submission here redirects rather than re-rendering**, which is the opposite
8//! of the house rule for forms — and it costs nothing, because the fields are passwords
9//! and an address, none of which a browser should be handed back. The outcome travels in
10//! a query parameter and the page renders the message; what a redirect usually throws
11//! away is not there to throw away.
12
13use std::time::SystemTime;
14
15use serde::Deserialize;
16use topcoat::{
17 Result,
18 context::Cx,
19 router::{
20 IntoResponse, Response, StatusCode,
21 content::Form,
22 error::{redirect, see_other},
23 page, path_param, query_params, route,
24 },
25 view::{attributes, component, view},
26};
27
28use crate::{
29 application::{
30 Error, FORGOT_ACKNOWLEDGEMENT, find_live_reset, port::Mailer, request_password_reset,
31 reset_password,
32 },
33 components::{
34 button::button,
35 flash::{FlashKind, flash},
36 input::input,
37 label::label,
38 },
39 domain::{DomainError, Password},
40};
41
42use super::{
43 context::{claimed, mailer, public_origin, resets, server_error, sessions, users},
44 layout::narrow,
45 rate_limit::{reset_throttled, throttled},
46 urls::encode,
47};
48
49/// `{token}` from the path — the secret itself, which is why nothing logs it.
50#[path_param]
51struct Token(str);
52
53#[derive(Debug, Deserialize)]
54struct ForgotForm {
55 email: String,
56}
57
58#[derive(Debug, Deserialize)]
59struct ResetForm {
60 password: String,
61 confirmation: String,
62}
63
64/// Set after a submission so the acknowledgement survives the redirect.
65#[query_params(error = bad_request)]
66struct Sent {
67 sent: Option<String>,
68}
69
70/// What went wrong with the last attempt, when something did.
71#[query_params(error = bad_request)]
72struct Failed {
73 error: Option<String>,
74}
75
76/// The reason for a refusal, as it travels through a redirect.
77///
78/// A closed set spelled out rather than the message itself: a message in a URL is a
79/// message an attacker can put on the page, and this way the page owns its own words.
80fn refusal(code: Option<&str>) -> &'static str {
81 match code {
82 Some("mismatch") => "Those two passwords are different.",
83 // The one sentence, from the one place — see `Password`.
84 Some("short") => Password::RULE,
85 Some("long") => "That password is too long to hash.",
86 Some(_) => "That did not work. Try again.",
87 None => "",
88 }
89}
90
91/// Which refusal a validation error is, as a code the page can turn back into words.
92///
93/// Three outcomes and one place that decides which is which, shared with
94/// `/{handle}/settings` — the reset page and the settings page must not disagree about
95/// what a rejected password means.
96pub(super) fn refusal_code(reason: &str) -> &'static str {
97 if reason.contains("match") {
98 "mismatch"
99 } else if reason.contains("bytes") {
100 "long"
101 } else {
102 "short"
103 }
104}
105
106/// Asks for a link.
107///
108/// An unclaimed instance has no accounts to reset, so it goes to the claim page — the
109/// same guard `/auth/login` carries.
110#[page("/auth/forgot")]
111async fn forgot_page(cx: &Cx) -> Result {
112 if !claimed(cx).await? {
113 return Err(redirect("/auth/setup").into());
114 }
115
116 let sent = query_params::<Sent>(cx)?.sent.is_some();
117
118 view! {
119 forgot_view(can_mail: mailer(cx).configured(), sent: sent)
120 }
121}
122
123/// Sends one, or does exactly as much work as if it had.
124///
125/// Rate limited on the sign-in budget before anything else, for the reason
126/// `/auth/login` is: this route makes the server run Argon2 on demand, and it is the
127/// one place an address can be probed at all.
128///
129/// The reply is the same 303 to the same page whatever happened, which is the
130/// no-enumeration rule as the browser sees it: same status, same body, same URL.
131#[route(POST "/auth/forgot")]
132async fn forgot(cx: &Cx, Form(form): Form<ForgotForm>) -> Result<Response> {
133 if let Some(response) = throttled(cx) {
134 return Ok(response);
135 }
136
137 request_password_reset(
138 &form.email,
139 &public_origin(cx),
140 SystemTime::now(),
141 &users(cx),
142 &resets(cx),
143 &crate::infrastructure::password::Argon2Hasher::new(),
144 &mailer(cx),
145 )
146 .await
147 // Storage broke. Nothing else reaches here: an unknown address is a success.
148 .map_err(|error| server_error(std::io::Error::other(error.to_string())))?;
149
150 see_other("/auth/forgot?sent").into_response(cx)
151}
152
153/// Shows the form, for a link that is still worth something.
154///
155/// Rate limited like the POST, and on [`ResetLimiter`](super::rate_limit::ResetLimiter)
156/// rather than the sign-in budget: this is a page a person opens and reloads, and
157/// sharing the login allowance meant a few loads locked them out of `/auth/login`.
158#[page("/auth/reset/{token}")]
159async fn reset_page(cx: &Cx) -> Result {
160 if reset_throttled(cx).is_some() {
161 return view! {
162 (StatusCode::TOO_MANY_REQUESTS)
163 narrow(<p class="mt-10 text-sm">"Too many attempts. Try again shortly."</p>)
164 };
165 }
166
167 let token = path_param::<Token>(cx).to_owned();
168
169 match find_live_reset(&token, SystemTime::now(), &resets(cx)).await {
170 // Named `live`, not `reset`: the POST handler below is a `#[route]`, whose name
171 // becomes a unit struct in this module, and `Ok(reset)` would parse as that
172 // struct's pattern rather than as a new binding.
173 Ok(live) => view! {
174 reset_view(
175 token: token.as_str(),
176 expires_at: live.expires_at,
177 error: refusal(query_params::<Failed>(cx)?.error.as_deref()),
178 )
179 },
180 // Unknown, expired and already spent are one answer — see `find_live_reset`.
181 // Designed rather than a bare 404: the person holding a dead link needs to be
182 // told what to do next, which is ask for another.
183 Err(Error::Domain(DomainError::NotFound { .. })) => view! {
184 (StatusCode::NOT_FOUND)
185 dead_link_view()
186 },
187 Err(other) => Err(server_error(std::io::Error::other(other.to_string()))),
188 }
189}
190
191/// Spends the link.
192///
193/// Success sends the visitor to `/auth/login`, because
194/// [`reset_password`](crate::application::reset_password) has just ended every session
195/// on the account — signing in with the new password is the proof it took.
196#[route(POST "/auth/reset/{token}")]
197async fn reset(cx: &Cx, Form(form): Form<ResetForm>) -> Result<Response> {
198 if let Some(response) = reset_throttled(cx) {
199 return Ok(response);
200 }
201
202 let token = path_param::<Token>(cx).to_owned();
203
204 let outcome = reset_password(
205 &token,
206 &form.password,
207 &form.confirmation,
208 SystemTime::now(),
209 &users(cx),
210 &resets(cx),
211 &sessions(cx),
212 &crate::infrastructure::password::Argon2Hasher::new(),
213 )
214 .await;
215
216 let code = match outcome {
217 Ok(()) => return see_other("/auth/login?reset").into_response(cx),
218 // A dead link between the form and the submission. Back to the same URL, which
219 // renders the dead-link page and offers another.
220 Err(Error::Domain(DomainError::NotFound { .. })) => {
221 return see_other(&reset_url(&token, None)).into_response(cx);
222 }
223 Err(Error::Domain(DomainError::Validation { reason, .. })) => refusal_code(&reason),
224 Err(other) => return Err(server_error(std::io::Error::other(other.to_string()))),
225 };
226
227 see_other(&reset_url(&token, Some(code))).into_response(cx)
228}
229
230/// The URL a refused submission goes back to, with the token percent-encoded.
231///
232/// **The encoding is the whole point.** `{token}` is a path parameter, and Topcoat
233/// hands it over already percent-decoded — so `/auth/reset/a%0d%0ab` arrives here as a
234/// string containing a real carriage return and line feed. `see_other` **panics** on a
235/// value that is not a legal `Location` header, so interpolating the token raw meant an
236/// anonymous caller could kill the worker task with one request and get no response at
237/// all. Found by the 2026-09-10 security review.
238///
239/// Encoding rather than refusing a malformed token, because a real token is 64 hex
240/// characters and comes back from `encode` unchanged: the legitimate path is identical
241/// and there is no second shape rule to drift out of step with `ResetSecret`. Anything
242/// else round-trips to the same dead-link page it would have anyway.
243fn reset_url(token: &str, error: Option<&str>) -> String {
244 let path = format!("/auth/reset/{}", encode(token, false));
245
246 match error {
247 Some(code) => format!("{path}?error={code}"),
248 None => path,
249 }
250}
251
252/// The ask-for-a-link page, in its three states: a form, an acknowledgement, and an
253/// instance with nowhere to send mail.
254#[component]
255async fn forgot_view(can_mail: bool, sent: bool) -> Result {
256 view! {
257 narrow(
258 <div class="mx-auto mt-16 max-w-sm">
259 <h1 class="text-xl font-semibold tracking-tight">"Forgotten password"</h1>
260
261 if !can_mail {
262 <p class="mt-2 text-sm text-muted-foreground">
263 "This instance is not configured to send email, so there is no "
264 "link to ask for."
265 </p>
266 <div class="mt-6">
267 flash(
268 kind: FlashKind::Info,
269 "Anyone with a shell on the server can print a one-time link."
270 )
271 </div>
272 <pre class="mt-4 overflow-x-auto rounded-lg bg-surface px-3 py-2 \
273 font-mono text-xs"
274 >"steid reset-password <your handle>"</pre>
275 <p class="mt-3 text-xs text-muted-foreground">
276 "The operator can set STEID_SMTP_URL and STEID_MAIL_FROM to turn "
277 "this form on."
278 </p>
279 } else if sent {
280 <div class="mt-6">
281 flash(kind: FlashKind::Success, (FORGOT_ACKNOWLEDGEMENT))
282 </div>
283 <p class="mt-4 text-xs text-muted-foreground">
284 "Nothing arrived? Check the address and "
285 <a
286 href="/auth/forgot"
287 class="text-foreground underline underline-offset-2"
288 >"try again"</a>
289 "."
290 </p>
291 } else {
292 <p class="mt-2 text-sm text-muted-foreground">
293 "Give the address on the account and a one-time link comes back "
294 "by email."
295 </p>
296 <form method="post" action="/auth/forgot" class="mt-6 space-y-5">
297 <div class="space-y-2">
298 label(attrs: attributes! { for="email" }, "Email")
299 input(attrs: attributes! {
300 id="email"
301 name="email"
302 type="email"
303 autocomplete="email"
304 required=(true)
305 placeholder="you@example.com"
306 })
307 </div>
308 button(attrs: attributes! { type="submit" }, "Send a link")
309 </form>
310 }
311
312 <p class="mt-8 border-t border-border pt-4 text-xs text-muted-foreground">
313 <a href="/auth/login" class="hover:text-foreground">"Back to sign in"</a>
314 </p>
315 </div>
316 )
317 }
318}
319
320/// The choose-a-new-password form.
321#[component]
322async fn reset_view(token: &str, expires_at: SystemTime, error: &str) -> Result {
323 view! {
324 narrow(
325 <div class="mx-auto mt-16 max-w-sm">
326 <h1 class="text-xl font-semibold tracking-tight">"Choose a new password"</h1>
327 <p class="mt-2 text-sm text-muted-foreground">
328 "This link works once, and expires in " (remaining(expires_at)) "."
329 </p>
330
331 if !error.is_empty() {
332 <div class="mt-6">flash(kind: FlashKind::Error, (error))</div>
333 }
334
335 <form
336 method="post"
337 action=(format!("/auth/reset/{token}"))
338 class="mt-6 space-y-5"
339 >
340 <div class="space-y-2">
341 label(attrs: attributes! { for="password" }, "New password")
342 input(attrs: attributes! {
343 id="password"
344 name="password"
345 type="password"
346 autocomplete="new-password"
347 required=(true)
348 })
349 </div>
350 <div class="space-y-2">
351 label(attrs: attributes! { for="confirmation" }, "New password again")
352 input(attrs: attributes! {
353 id="confirmation"
354 name="confirmation"
355 type="password"
356 autocomplete="new-password"
357 required=(true)
358 })
359 </div>
360 <p class="text-xs text-muted-foreground">
361 (Password::RULE)
362 </p>
363 <p class="text-xs text-muted-foreground">
364 "Every device signed in to this account is signed out."
365 </p>
366 button(attrs: attributes! { type="submit" }, "Set password")
367 </form>
368 </div>
369 )
370 }
371}
372
373/// What a spent, expired or invented link gets.
374#[component]
375async fn dead_link_view() -> Result {
376 view! {
377 narrow(
378 <div class="mx-auto mt-16 max-w-sm">
379 <h1 class="text-xl font-semibold tracking-tight">"This link is no longer good"</h1>
380 <p class="mt-2 text-sm text-muted-foreground">
381 "A reset link works once and lasts thirty minutes. This one has been "
382 "used, has expired, or was never issued."
383 </p>
384 <p class="mt-6">
385 <a
386 href="/auth/forgot"
387 class="text-sm font-medium text-primary hover:underline"
388 >"Ask for another →"</a>
389 </p>
390 </div>
391 )
392 }
393}
394
395/// "28 minutes", for a deadline that is always under half an hour away.
396///
397/// Rounded up, so a link with forty seconds left says "1 minute" rather than "0
398/// minutes" — a countdown that reads zero while the form still works is worse than one
399/// that is a few seconds generous.
400fn remaining(expires_at: SystemTime) -> String {
401 let left = expires_at
402 .duration_since(SystemTime::now())
403 .unwrap_or_default();
404
405 // The nanoseconds are rounded up before the minutes are, because `as_secs` truncates
406 // and truncating twice turns 61 seconds into one minute.
407 let seconds = left.as_secs() + u64::from(left.subsec_nanos() > 0);
408
409 match seconds.div_ceil(60) {
410 0 | 1 => "1 minute".to_owned(),
411 minutes => format!("{minutes} minutes"),
412 }
413}
414
415#[cfg(test)]
416mod tests {
417 #[test]
418 fn a_real_token_survives_the_round_trip_unchanged() {
419 let token = "b2a9c17e4d5f80316a7c9e2b4d8f0135b2a9c17e4d5f80316a7c9e2b4d8f0135";
420
421 assert_eq!(reset_url(token, None), format!("/auth/reset/{token}"));
422 assert_eq!(
423 reset_url(token, Some("mismatch")),
424 format!("/auth/reset/{token}?error=mismatch")
425 );
426 }
427
428 /// The panic this closed: `see_other` refuses a `Location` carrying CR or LF, and
429 /// refuses it by panicking rather than by returning an error — so one anonymous
430 /// `POST /auth/reset/a%0d%0ab` killed the worker task and answered nothing at all.
431 #[test]
432 fn a_token_carrying_a_newline_cannot_reach_a_header() {
433 let url = reset_url("a\r\nb", None);
434
435 assert_eq!(url, "/auth/reset/a%0D%0Ab");
436 assert!(topcoat::router::HeaderValue::try_from(url).is_ok());
437 }
438
439 use std::time::Duration;
440
441 use super::*;
442
443 #[test]
444 fn a_refusal_code_becomes_words_this_module_owns() {
445 assert_eq!(
446 refusal(Some("mismatch")),
447 "Those two passwords are different."
448 );
449 assert_eq!(refusal(Some("short")), Password::RULE);
450 assert_eq!(refusal(None), "");
451 }
452
453 #[test]
454 fn every_refusal_a_use_case_can_give_has_a_code() {
455 // The codes and the reasons are written in two files; this is what notices if a
456 // reason is reworded and the page quietly starts saying "too short" for a
457 // mismatch.
458 assert_eq!(refusal_code("the two did not match"), "mismatch");
459 assert_eq!(refusal_code(Password::RULE), "short");
460 assert_eq!(refusal_code("must be at most 512 bytes"), "long");
461 }
462
463 #[test]
464 fn an_invented_refusal_code_cannot_put_words_on_the_page() {
465 // The code arrives in a URL, so it is a value an attacker can choose.
466 assert_eq!(
467 refusal(Some("<script>alert(1)</script>")),
468 "That did not work. Try again."
469 );
470 }
471
472 #[test]
473 fn a_deadline_is_rounded_up_so_a_live_link_never_reads_zero() {
474 let now = SystemTime::now();
475
476 assert_eq!(remaining(now + Duration::from_secs(40)), "1 minute");
477 assert_eq!(remaining(now + Duration::from_secs(61)), "2 minutes");
478 assert_eq!(remaining(now + Duration::from_secs(29 * 60)), "29 minutes");
479 assert_eq!(remaining(now - Duration::from_secs(10)), "1 minute");
480 }
481}