Tahinli/forge
public0 watching
★ 0
| 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 | |
| 6 | use std::time::Instant; |
| 7 | |
| 8 | use forge_core::AuditAction; |
| 9 | use serde::Deserialize; |
| 10 | use topcoat::Result; |
| 11 | use topcoat::context::{Cx, app_context}; |
| 12 | use topcoat::router::content::Form; |
| 13 | use topcoat::router::error::forbidden; |
| 14 | use topcoat::router::{page, path_param_segment}; |
| 15 | use topcoat::view::{View, component, view}; |
| 16 | |
| 17 | use crate::audit::{self, Actor, Target}; |
| 18 | use crate::auth::{check_pre_csrf, hash_password_async, pre_csrf_token}; |
| 19 | use crate::client_ip::client_ip; |
| 20 | use crate::i18n::{self, Key, Lang, t}; |
| 21 | use crate::mail::account; |
| 22 | use crate::pages::register::check_new_password; |
| 23 | use crate::state::AppState; |
| 24 | use crate::throttle; |
| 25 | use crate::views::{alert, shell}; |
| 26 | |
| 27 | /// Reset requests allowed per client IP inside the throttle window. |
| 28 | const FORGOT_IP_LIMIT: usize = 10; |
| 29 | /// Reset requests allowed per typed address inside the throttle window. |
| 30 | const FORGOT_ADDRESS_LIMIT: usize = 3; |
| 31 | |
| 32 | #[derive(Deserialize)] |
| 33 | struct ForgotForm { |
| 34 | #[serde(default)] |
| 35 | email: String, |
| 36 | #[serde(default)] |
| 37 | precsrf: String, |
| 38 | } |
| 39 | |
| 40 | #[derive(Deserialize)] |
| 41 | struct 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)] |
| 52 | enum ResetMode { |
| 53 | Form, |
| 54 | Invalid, |
| 55 | Done, |
| 56 | } |
| 57 | |
| 58 | #[component] |
| 59 | async 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] |
| 77 | async 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")] |
| 89 | async 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. |
| 99 | async 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")] |
| 110 | async 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] |
| 147 | async 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}")] |
| 189 | async 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}")] |
| 205 | async 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)] |
| 251 | mod 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 | } |