forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
14.5 KiB
1//! Pages: password reset. `/forgot` takes an address and `/reset/{token}` sets the new password.
2//! `/forgot` always gives the same answer. Its lookup and send run in a spawned task, so the
3//! response time does not reveal whether an address has an account, and an address that is not
4//! verified gets no mail.
5
6use std::time::Instant;
7
8use forge_core::AuditAction;
9use serde::Deserialize;
10use topcoat::Result;
11use topcoat::context::{Cx, app_context};
12use topcoat::router::content::Form;
13use topcoat::router::error::forbidden;
14use topcoat::router::{page, path_param_segment};
15use topcoat::view::{View, component, view};
16
17use crate::audit::{self, Actor, Target};
18use crate::auth::{check_pre_csrf, hash_password_async, pre_csrf_token};
19use crate::client_ip::client_ip;
20use crate::i18n::{self, Key, Lang, t};
21use crate::mail::account;
22use crate::pages::register::check_new_password;
23use crate::state::AppState;
24use crate::throttle;
25use crate::views::{alert, shell};
26
27/// Reset requests allowed per client IP inside the throttle window.
28const FORGOT_IP_LIMIT: usize = 10;
29/// Reset requests allowed per typed address inside the throttle window.
30const FORGOT_ADDRESS_LIMIT: usize = 3;
31
32#[derive(Deserialize)]
33struct ForgotForm {
34 #[serde(default)]
35 email: String,
36 #[serde(default)]
37 precsrf: String,
38}
39
40#[derive(Deserialize)]
41struct ResetForm {
42 #[serde(default)]
43 password: String,
44 #[serde(default)]
45 confirm: String,
46 #[serde(default)]
47 precsrf: String,
48}
49
50/// Which state the reset page is in.
51#[derive(Clone, Copy, PartialEq, Eq)]
52enum ResetMode {
53 Form,
54 Invalid,
55 Done,
56}
57
58#[component]
59async fn forgot_form(lang: Lang, precsrf: &str) -> Result<impl View> {
60 Ok(view! {
61 <section class="card narrow">
62 <h1>(t(lang, Key::ForgotTitle))</h1>
63 <p class="muted">(t(lang, Key::ForgotSub))</p>
64 <form data-hard="true" method="post" action="/forgot" class="stack">
65 <input type="hidden" name="precsrf" value=(precsrf)>
66 <label>
67 <span>(t(lang, Key::Email))</span>
68 <input type="email" name="email" autocomplete="email" maxlength="254" required=(true)>
69 </label>
70 <button type="submit" class="primary">(t(lang, Key::ForgotSubmit))</button>
71 </form>
72 </section>
73 })
74}
75
76#[component]
77async fn forgot_sent(lang: Lang) -> Result<impl View> {
78 Ok(view! {
79 <section class="card narrow">
80 <h1>(t(lang, Key::ForgotTitle))</h1>
81 <section class="card notice" role="status">
82 <p>(t(lang, Key::ForgotSent))</p>
83 </section>
84 </section>
85 })
86}
87
88#[page("/forgot")]
89async fn forgot_page(cx: &Cx) -> Result<impl View> {
90 let lang = i18n::lang(cx, None);
91 let precsrf = pre_csrf_token(cx);
92 Ok(view! {
93 shell(title: t(lang, Key::ForgotTitle), viewer: None, forgot_form(lang: lang, precsrf: precsrf.as_str()))
94 })
95}
96
97/// Queues a reset link when `address` belongs to a verified account. Anything else queues
98/// nothing, and the caller never learns which case applied.
99async fn send_reset_if_verified(state: &AppState, address: &str) -> forge_core::Result<()> {
100 if forge_core::store::validate_email(address).is_err() {
101 return Ok(());
102 }
103 if let Some(user) = state.store.verified_user_by_email(address).await? {
104 account::send_reset(state, &user).await?;
105 }
106 Ok(())
107}
108
109#[page(POST "/forgot")]
110async fn forgot_submit(cx: &Cx, Form(form): Form<ForgotForm>) -> Result<impl View> {
111 // Login CSRF: the pre-session token must match the cookie before anything else runs.
112 if !check_pre_csrf(cx, &form.precsrf) {
113 return Err(forbidden().into());
114 }
115 let state = app_context::<AppState>(cx);
116 let lang = i18n::lang(cx, None);
117 let address = form.email.trim().to_owned();
118 let now = Instant::now();
119 let ip_key = throttle::ip_key("forgot-ip", client_ip(cx));
120 let address_key = format!("forgot-address:{}", address.to_ascii_lowercase());
121 let limited = state
122 .throttle
123 .check(&ip_key, FORGOT_IP_LIMIT, now)
124 .is_some()
125 || state
126 .throttle
127 .check(&address_key, FORGOT_ADDRESS_LIMIT, now)
128 .is_some();
129 // Every request counts toward the limits, so a flood stays limited. Limited requests get
130 // the same answer as the rest, so the limit does not show either.
131 state.throttle.record_failure(&ip_key, now);
132 state.throttle.record_failure(&address_key, now);
133 if !limited {
134 let task = state.clone();
135 tokio::spawn(async move {
136 if let Err(e) = send_reset_if_verified(&task, &address).await {
137 tracing::warn!(error = %e, "password reset mail was not queued");
138 }
139 });
140 }
141 Ok(view! {
142 shell(title: t(lang, Key::ForgotTitle), viewer: None, forgot_sent(lang: lang))
143 })
144}
145
146#[component]
147async fn reset_body(
148 lang: Lang,
149 token: &str,
150 precsrf: &str,
151 mode: ResetMode,
152 error: Option<String>,
153) -> Result<impl View> {
154 Ok(view! {
155 <section class="card narrow">
156 <h1>(t(lang, Key::ResetTitle))</h1>
157 if mode == ResetMode::Form {
158 <p class="muted">(t(lang, Key::ResetSub))</p>
159 if let Some(message) = error {
160 alert(message: message.as_str())
161 }
162 <form data-hard="true" method="post" action=(format!("/reset/{token}")) class="stack">
163 <input type="hidden" name="precsrf" value=(precsrf)>
164 <label>
165 <span>(t(lang, Key::NewPassword))</span>
166 <input type="password" name="password" autocomplete="new-password" required=(true)>
167 <small class="muted">(t(lang, Key::AtLeast12Chars))</small>
168 </label>
169 <label>
170 <span>(t(lang, Key::ConfirmNewPassword))</span>
171 <input type="password" name="confirm" autocomplete="new-password" required=(true)>
172 </label>
173 <button type="submit" class="primary">(t(lang, Key::ResetSubmit))</button>
174 </form>
175 }
176 if mode == ResetMode::Invalid {
177 <p class="muted">(t(lang, Key::ResetInvalid))</p>
178 <p><a href="/forgot">(t(lang, Key::ForgotTitle))</a></p>
179 }
180 if mode == ResetMode::Done {
181 <p>(t(lang, Key::ResetDone))</p>
182 <p><a href="/login">(t(lang, Key::LogIn))</a></p>
183 }
184 </section>
185 })
186}
187
188#[page("/reset/{token}")]
189async fn reset_page(cx: &Cx) -> Result<impl View> {
190 let state = app_context::<AppState>(cx);
191 let lang = i18n::lang(cx, None);
192 let token = path_param_segment(cx, "token").to_owned();
193 let mode = if state.store.reset_token_is_live(&token).await? {
194 ResetMode::Form
195 } else {
196 ResetMode::Invalid
197 };
198 let precsrf = pre_csrf_token(cx);
199 Ok(view! {
200 shell(title: t(lang, Key::ResetTitle), viewer: None, reset_body(lang: lang, token: token.as_str(), precsrf: precsrf.as_str(), mode: mode, error: None))
201 })
202}
203
204#[page(POST "/reset/{token}")]
205async fn reset_submit(cx: &Cx, Form(form): Form<ResetForm>) -> Result<impl View> {
206 if !check_pre_csrf(cx, &form.precsrf) {
207 return Err(forbidden().into());
208 }
209 let state = app_context::<AppState>(cx);
210 let lang = i18n::lang(cx, None);
211 let token = path_param_segment(cx, "token").to_owned();
212 let mut mode = ResetMode::Form;
213 let mut error = None;
214 match check_new_password(lang, &form.password, &form.confirm) {
215 // A weak password does not spend the link.
216 Some(message) => error = Some(message),
217 None => match state.store.redeem_reset_token(&token).await? {
218 None => mode = ResetMode::Invalid,
219 Some(user_id) => {
220 // The link is spent before the slow hash, so two redemptions cannot both pass.
221 let hash = hash_password_async(form.password.clone()).await?;
222 state.store.set_password_hash(&user_id, &hash).await?;
223 // Every session of the account ends, including the one that asked for the reset.
224 state.store.delete_user_sessions(&user_id).await?;
225 if let Some(user) = state.store.user_by_id(&user_id).await? {
226 audit::record(
227 &state.store,
228 Some(cx),
229 Actor::User(&user),
230 AuditAction::PasswordResetCompleted,
231 Target {
232 kind: "user",
233 id: &user.id,
234 name: &user.username,
235 },
236 serde_json::Value::Null,
237 )
238 .await;
239 }
240 mode = ResetMode::Done;
241 }
242 },
243 }
244 let precsrf = form.precsrf.clone();
245 Ok(view! {
246 shell(title: t(lang, Key::ResetTitle), viewer: None, reset_body(lang: lang, token: token.as_str(), precsrf: precsrf.as_str(), mode: mode, error: error))
247 })
248}
249
250#[cfg(test)]
251mod tests {
252 use std::path::Path;
253 use std::time::Duration;
254
255 use super::*;
256 use crate::pages::register::test_helpers::{link_in, precsrf_for, send};
257
258 async fn app(dir: &Path) -> (topcoat::router::Router, AppState) {
259 let state = crate::test_support::test_state(dir).await;
260 (crate::test_support::app_router(state.clone()), state)
261 }
262
263 /// Waits until a mail with `marker` in its body is in the outbox, and returns that body.
264 async fn wait_for_mail(state: &AppState, marker: &str) -> String {
265 for _ in 0..100 {
266 let mail = state.store.recent_mail(20).await.unwrap();
267 if let Some(found) = mail.into_iter().find(|m| m.body.contains(marker)) {
268 return found.body;
269 }
270 tokio::time::sleep(Duration::from_millis(20)).await;
271 }
272 panic!("no mail with {marker}");
273 }
274
275 #[tokio::test]
276 async fn forgot_answers_the_same_for_unknown_and_unverified_addresses() {
277 let tmp = tempfile::tempdir().expect("tempdir");
278 let (router, state) = app(tmp.path()).await;
279 state
280 .store
281 .create_user("frank", Some("frank@example.com"), "hash", false)
282 .await
283 .unwrap();
284 for address in ["nobody@example.com", "frank@example.com"] {
285 let (cookie, precsrf) = precsrf_for(&router, "/forgot").await;
286 let form = format!("precsrf={precsrf}&email={}", address.replace('@', "%40"));
287 let (status, _, html) =
288 send(&router, "POST", "/forgot", Some(&cookie), Some(&form)).await;
289 assert_eq!(status, 200, "{address}");
290 assert!(html.contains("a reset link is on its way"), "{address}");
291 }
292 // Give the spawned lookups time to finish, then check that nothing was queued: Frank has
293 // no verified address, so his account gets no mail.
294 tokio::time::sleep(Duration::from_millis(200)).await;
295 let mail = state.store.recent_mail(20).await.unwrap();
296 assert!(mail.iter().all(|m| !m.body.contains("/reset/")));
297 }
298
299 #[tokio::test]
300 async fn reset_link_works_once_and_sets_the_password() {
301 let tmp = tempfile::tempdir().expect("tempdir");
302 let (router, state) = app(tmp.path()).await;
303 let user = state
304 .store
305 .create_user("gina", Some("gina@example.com"), "old-hash", false)
306 .await
307 .unwrap();
308 let verify = state
309 .store
310 .create_verify_token(&user.id, "gina@example.com")
311 .await
312 .unwrap();
313 assert!(
314 state
315 .store
316 .redeem_verify_token(&verify)
317 .await
318 .unwrap()
319 .is_some()
320 );
321
322 let (cookie, precsrf) = precsrf_for(&router, "/forgot").await;
323 let form = format!("precsrf={precsrf}&email=gina%40example.com");
324 let (status, _, _) = send(&router, "POST", "/forgot", Some(&cookie), Some(&form)).await;
325 assert_eq!(status, 200);
326 let body = wait_for_mail(&state, "/reset/").await;
327 let path = link_in(&body, "/reset/");
328
329 let (status, _, html) = send(&router, "GET", &path, None, None).await;
330 assert_eq!(status, 200);
331 assert!(html.contains("Set a new password"));
332
333 let (cookie, precsrf) = precsrf_for(&router, &path).await;
334 let form =
335 format!("precsrf={precsrf}&password=brand-new-password-1&confirm=brand-new-password-1");
336 let (status, _, html) = send(&router, "POST", &path, Some(&cookie), Some(&form)).await;
337 assert_eq!(status, 200);
338 assert!(html.contains("Your password is changed"));
339
340 let fresh = state.store.user_by_id(&user.id).await.unwrap().unwrap();
341 assert!(
342 crate::auth::verify_password_async("brand-new-password-1".into(), fresh.password_hash)
343 .await
344 .unwrap()
345 );
346
347 // The same link is spent now.
348 let (cookie, precsrf) = precsrf_for(&router, &path).await;
349 let form =
350 format!("precsrf={precsrf}&password=another-password-22&confirm=another-password-22");
351 let (status, _, html) = send(&router, "POST", &path, Some(&cookie), Some(&form)).await;
352 assert_eq!(status, 200);
353 assert!(html.contains("This reset link is invalid"));
354 }
355
356 #[tokio::test]
357 async fn a_weak_password_keeps_the_link_live() {
358 let tmp = tempfile::tempdir().expect("tempdir");
359 let (router, state) = app(tmp.path()).await;
360 let user = state
361 .store
362 .create_user("hal", Some("hal@example.com"), "old-hash", false)
363 .await
364 .unwrap();
365 let verify = state
366 .store
367 .create_verify_token(&user.id, "hal@example.com")
368 .await
369 .unwrap();
370 state.store.redeem_verify_token(&verify).await.unwrap();
371 let token = state.store.create_reset_token(&user.id).await.unwrap();
372 let path = format!("/reset/{token}");
373
374 let (cookie, precsrf) = precsrf_for(&router, &path).await;
375 let form = format!("precsrf={precsrf}&password=short&confirm=short");
376 let (status, _, html) = send(&router, "POST", &path, Some(&cookie), Some(&form)).await;
377 assert_eq!(status, 200);
378 assert!(
379 html.contains("Set a new password"),
380 "the form is shown again"
381 );
382 assert!(state.store.reset_token_is_live(&token).await.unwrap());
383 }
384}