//! Pages: password reset. `/forgot` takes an address and `/reset/{token}` sets the new password. //! `/forgot` always gives the same answer. Its lookup and send run in a spawned task, so the //! response time does not reveal whether an address has an account, and an address that is not //! verified gets no mail. use std::time::Instant; use forge_core::AuditAction; use serde::Deserialize; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::forbidden; use topcoat::router::{page, path_param_segment}; use topcoat::view::{View, component, view}; use crate::audit::{self, Actor, Target}; use crate::auth::{check_pre_csrf, hash_password_async, pre_csrf_token}; use crate::client_ip::client_ip; use crate::i18n::{self, Key, Lang, t}; use crate::mail::account; use crate::pages::register::check_new_password; use crate::state::AppState; use crate::throttle; use crate::views::{alert, shell}; /// Reset requests allowed per client IP inside the throttle window. const FORGOT_IP_LIMIT: usize = 10; /// Reset requests allowed per typed address inside the throttle window. const FORGOT_ADDRESS_LIMIT: usize = 3; #[derive(Deserialize)] struct ForgotForm { #[serde(default)] email: String, #[serde(default)] precsrf: String, } #[derive(Deserialize)] struct ResetForm { #[serde(default)] password: String, #[serde(default)] confirm: String, #[serde(default)] precsrf: String, } /// Which state the reset page is in. #[derive(Clone, Copy, PartialEq, Eq)] enum ResetMode { Form, Invalid, Done, } #[component] async fn forgot_form(lang: Lang, precsrf: &str) -> Result { Ok(view! {

(t(lang, Key::ForgotTitle))

(t(lang, Key::ForgotSub))

}) } #[component] async fn forgot_sent(lang: Lang) -> Result { Ok(view! {

(t(lang, Key::ForgotTitle))

(t(lang, Key::ForgotSent))

}) } #[page("/forgot")] async fn forgot_page(cx: &Cx) -> Result { let lang = i18n::lang(cx, None); let precsrf = pre_csrf_token(cx); Ok(view! { shell(title: t(lang, Key::ForgotTitle), viewer: None, forgot_form(lang: lang, precsrf: precsrf.as_str())) }) } /// Queues a reset link when `address` belongs to a verified account. Anything else queues /// nothing, and the caller never learns which case applied. async fn send_reset_if_verified(state: &AppState, address: &str) -> forge_core::Result<()> { if forge_core::store::validate_email(address).is_err() { return Ok(()); } if let Some(user) = state.store.verified_user_by_email(address).await? { account::send_reset(state, &user).await?; } Ok(()) } #[page(POST "/forgot")] async fn forgot_submit(cx: &Cx, Form(form): Form) -> Result { // Login CSRF: the pre-session token must match the cookie before anything else runs. if !check_pre_csrf(cx, &form.precsrf) { return Err(forbidden().into()); } let state = app_context::(cx); let lang = i18n::lang(cx, None); let address = form.email.trim().to_owned(); let now = Instant::now(); let ip_key = throttle::ip_key("forgot-ip", client_ip(cx)); let address_key = format!("forgot-address:{}", address.to_ascii_lowercase()); let limited = state .throttle .check(&ip_key, FORGOT_IP_LIMIT, now) .is_some() || state .throttle .check(&address_key, FORGOT_ADDRESS_LIMIT, now) .is_some(); // Every request counts toward the limits, so a flood stays limited. Limited requests get // the same answer as the rest, so the limit does not show either. state.throttle.record_failure(&ip_key, now); state.throttle.record_failure(&address_key, now); if !limited { let task = state.clone(); tokio::spawn(async move { if let Err(e) = send_reset_if_verified(&task, &address).await { tracing::warn!(error = %e, "password reset mail was not queued"); } }); } Ok(view! { shell(title: t(lang, Key::ForgotTitle), viewer: None, forgot_sent(lang: lang)) }) } #[component] async fn reset_body( lang: Lang, token: &str, precsrf: &str, mode: ResetMode, error: Option, ) -> Result { Ok(view! {

(t(lang, Key::ResetTitle))

if mode == ResetMode::Form {

(t(lang, Key::ResetSub))

if let Some(message) = error { alert(message: message.as_str()) }
} if mode == ResetMode::Invalid {

(t(lang, Key::ResetInvalid))

(t(lang, Key::ForgotTitle))

} if mode == ResetMode::Done {

(t(lang, Key::ResetDone))

(t(lang, Key::LogIn))

}
}) } #[page("/reset/{token}")] async fn reset_page(cx: &Cx) -> Result { let state = app_context::(cx); let lang = i18n::lang(cx, None); let token = path_param_segment(cx, "token").to_owned(); let mode = if state.store.reset_token_is_live(&token).await? { ResetMode::Form } else { ResetMode::Invalid }; let precsrf = pre_csrf_token(cx); Ok(view! { shell(title: t(lang, Key::ResetTitle), viewer: None, reset_body(lang: lang, token: token.as_str(), precsrf: precsrf.as_str(), mode: mode, error: None)) }) } #[page(POST "/reset/{token}")] async fn reset_submit(cx: &Cx, Form(form): Form) -> Result { if !check_pre_csrf(cx, &form.precsrf) { return Err(forbidden().into()); } let state = app_context::(cx); let lang = i18n::lang(cx, None); let token = path_param_segment(cx, "token").to_owned(); let mut mode = ResetMode::Form; let mut error = None; match check_new_password(lang, &form.password, &form.confirm) { // A weak password does not spend the link. Some(message) => error = Some(message), None => match state.store.redeem_reset_token(&token).await? { None => mode = ResetMode::Invalid, Some(user_id) => { // The link is spent before the slow hash, so two redemptions cannot both pass. let hash = hash_password_async(form.password.clone()).await?; state.store.set_password_hash(&user_id, &hash).await?; // Every session of the account ends, including the one that asked for the reset. state.store.delete_user_sessions(&user_id).await?; if let Some(user) = state.store.user_by_id(&user_id).await? { audit::record( &state.store, Some(cx), Actor::User(&user), AuditAction::PasswordResetCompleted, Target { kind: "user", id: &user.id, name: &user.username, }, serde_json::Value::Null, ) .await; } mode = ResetMode::Done; } }, } let precsrf = form.precsrf.clone(); Ok(view! { shell(title: t(lang, Key::ResetTitle), viewer: None, reset_body(lang: lang, token: token.as_str(), precsrf: precsrf.as_str(), mode: mode, error: error)) }) } #[cfg(test)] mod tests { use std::path::Path; use std::time::Duration; use super::*; use crate::pages::register::test_helpers::{link_in, precsrf_for, send}; async fn app(dir: &Path) -> (topcoat::router::Router, AppState) { let state = crate::test_support::test_state(dir).await; (crate::test_support::app_router(state.clone()), state) } /// Waits until a mail with `marker` in its body is in the outbox, and returns that body. async fn wait_for_mail(state: &AppState, marker: &str) -> String { for _ in 0..100 { let mail = state.store.recent_mail(20).await.unwrap(); if let Some(found) = mail.into_iter().find(|m| m.body.contains(marker)) { return found.body; } tokio::time::sleep(Duration::from_millis(20)).await; } panic!("no mail with {marker}"); } #[tokio::test] async fn forgot_answers_the_same_for_unknown_and_unverified_addresses() { let tmp = tempfile::tempdir().expect("tempdir"); let (router, state) = app(tmp.path()).await; state .store .create_user("frank", Some("frank@example.com"), "hash", false) .await .unwrap(); for address in ["nobody@example.com", "frank@example.com"] { let (cookie, precsrf) = precsrf_for(&router, "/forgot").await; let form = format!("precsrf={precsrf}&email={}", address.replace('@', "%40")); let (status, _, html) = send(&router, "POST", "/forgot", Some(&cookie), Some(&form)).await; assert_eq!(status, 200, "{address}"); assert!(html.contains("a reset link is on its way"), "{address}"); } // Give the spawned lookups time to finish, then check that nothing was queued: Frank has // no verified address, so his account gets no mail. tokio::time::sleep(Duration::from_millis(200)).await; let mail = state.store.recent_mail(20).await.unwrap(); assert!(mail.iter().all(|m| !m.body.contains("/reset/"))); } #[tokio::test] async fn reset_link_works_once_and_sets_the_password() { let tmp = tempfile::tempdir().expect("tempdir"); let (router, state) = app(tmp.path()).await; let user = state .store .create_user("gina", Some("gina@example.com"), "old-hash", false) .await .unwrap(); let verify = state .store .create_verify_token(&user.id, "gina@example.com") .await .unwrap(); assert!( state .store .redeem_verify_token(&verify) .await .unwrap() .is_some() ); let (cookie, precsrf) = precsrf_for(&router, "/forgot").await; let form = format!("precsrf={precsrf}&email=gina%40example.com"); let (status, _, _) = send(&router, "POST", "/forgot", Some(&cookie), Some(&form)).await; assert_eq!(status, 200); let body = wait_for_mail(&state, "/reset/").await; let path = link_in(&body, "/reset/"); let (status, _, html) = send(&router, "GET", &path, None, None).await; assert_eq!(status, 200); assert!(html.contains("Set a new password")); let (cookie, precsrf) = precsrf_for(&router, &path).await; let form = format!("precsrf={precsrf}&password=brand-new-password-1&confirm=brand-new-password-1"); let (status, _, html) = send(&router, "POST", &path, Some(&cookie), Some(&form)).await; assert_eq!(status, 200); assert!(html.contains("Your password is changed")); let fresh = state.store.user_by_id(&user.id).await.unwrap().unwrap(); assert!( crate::auth::verify_password_async("brand-new-password-1".into(), fresh.password_hash) .await .unwrap() ); // The same link is spent now. let (cookie, precsrf) = precsrf_for(&router, &path).await; let form = format!("precsrf={precsrf}&password=another-password-22&confirm=another-password-22"); let (status, _, html) = send(&router, "POST", &path, Some(&cookie), Some(&form)).await; assert_eq!(status, 200); assert!(html.contains("This reset link is invalid")); } #[tokio::test] async fn a_weak_password_keeps_the_link_live() { let tmp = tempfile::tempdir().expect("tempdir"); let (router, state) = app(tmp.path()).await; let user = state .store .create_user("hal", Some("hal@example.com"), "old-hash", false) .await .unwrap(); let verify = state .store .create_verify_token(&user.id, "hal@example.com") .await .unwrap(); state.store.redeem_verify_token(&verify).await.unwrap(); let token = state.store.create_reset_token(&user.id).await.unwrap(); let path = format!("/reset/{token}"); let (cookie, precsrf) = precsrf_for(&router, &path).await; let form = format!("precsrf={precsrf}&password=short&confirm=short"); let (status, _, html) = send(&router, "POST", &path, Some(&cookie), Some(&form)).await; assert_eq!(status, 200); assert!( html.contains("Set a new password"), "the form is shown again" ); assert!(state.store.reset_token_is_live(&token).await.unwrap()); } }