//! Email verification and password reset (hub spec ยง5.1.1). use super::{Error, Page, Shell}; use crate::auth::{self, Viewer}; use crate::db::{self, accounts}; use crate::Shared; use askama::Template; use axum::extract::{Form, Path, State}; use axum::response::{IntoResponse, Redirect, Response}; use serde::Deserialize; const MINUTE: i64 = 60_000; pub const VERIFY_TTL_MS: i64 = 48 * 60 * MINUTE; pub const RESET_TTL_MS: i64 = 60 * MINUTE; pub const ADMIN_RESET_TTL_MS: i64 = 24 * 60 * MINUTE; /// At most one mailed link of a kind per account (and address) in this time. pub const MAIL_EVERY_MS: i64 = 2 * MINUTE; /// At most this many verification mails per account per hour, whatever the addresses. pub const VERIFY_PER_HOUR: i64 = 10; #[derive(Template)] #[template(path = "reset.html")] pub struct RecoveryPage { shell: Shell, /// `request`, `sent`, `new`, `invalid`, `verified`, `verify-invalid`. mode: &'static str, mail: bool, error: Option, username: String, secret: String, } impl RecoveryPage { fn new(app: &Shared, shell: Shell, mode: &'static str) -> RecoveryPage { RecoveryPage { shell, mode, mail: crate::mail::enabled(app), error: None, username: String::new(), secret: String::new() } } } /// Send a verification link for the account's current email. pub async fn send_verification(app: &Shared, account: &accounts::Account) -> Result<(), String> { if account.email.is_empty() || account.email_verified { return Ok(()); } if !crate::mail::enabled(app) { return Err("This hub cannot send mail, so addresses cannot be verified yet.".into()); } let now = db::now_ms(); let recent = accounts::last_link_ms(&app.db, account.id, "verify", Some(&account.email)).await.map_err(|e| e.to_string())?; if recent.is_some_and(|t| now - t < MAIL_EVERY_MS) { return Err("A verification link was sent less than two minutes ago.".into()); } if accounts::links_since(&app.db, account.id, "verify", now - 60 * MINUTE).await.map_err(|e| e.to_string())? >= VERIFY_PER_HOUR { return Err("Too many verification links this hour; try again later.".into()); } let secret = accounts::create_link(&app.db, account.id, "verify", &account.email, None, VERIFY_TTL_MS).await.map_err(|e| e.to_string())?; let url = format!("{}/verify/{secret}", app.config.base_url()); let body = format!( "Confirm that {} is the email address of {} on {}:\n\n{url}\n\nThe link works for 48 hours. If you did not ask for this, ignore it.\n", account.email, account.username, app.config.site.name ); crate::mail::send(app, account.name(), &account.email, "Confirm your email address", &body).await } /// `GET /verify/{secret}`. pub async fn verify(State(app): State, viewer: Viewer, Path(secret): Path) -> Result, Error> { let shell = Shell::new(&app, "/verify", &viewer); let e = |e| Error::from_db(shell.clone(), e); let link = accounts::live_link(&app.db, "verify", &secret).await.map_err(e)?; let account = match &link { Some(l) => accounts::account_by_id(&app.db, l.account_id).await.map_err(e)?, None => None, }; match (link, account) { // Only the address the link was sent to, and only while it is still the account's. (Some(l), Some(a)) if a.email == l.email && accounts::use_link(&app.db, l.id).await.map_err(e)? => { accounts::set_email_verified(&app.db, a.id, true).await.map_err(e)?; accounts::audit(&app.db, Some(&a), "email.verified", &a.username, &a.email).await.ok(); let mut page = RecoveryPage::new(&app, shell, "verified"); page.username = a.email; Ok(Page(page)) } _ => Ok(Page(RecoveryPage::new(&app, shell, "verify-invalid"))), } } /// `GET /login/reset`. pub async fn request_form(State(app): State, viewer: Viewer) -> Page { Page(RecoveryPage::new(&app, Shell::new(&app, "/login/reset", &viewer), "request")) } #[derive(Deserialize)] pub struct RequestForm { who: String, } /// `POST /login/reset`: the same answer whether or not anything matched. pub async fn request( State(app): State, viewer: Viewer, headers: axum::http::HeaderMap, extensions: axum::http::Extensions, Form(f): Form, ) -> Result, Error> { let shell = Shell::new(&app, "/login/reset", &viewer); if !crate::mail::enabled(&app) { return Ok(Page(RecoveryPage::new(&app, shell, "request"))); } // Every request counts against its address (oxygenhub#221): the answer // is the same whether or not anything matched, so this says nothing. let addr = crate::api::client_addr(&app, &headers, &extensions); let keys = [crate::throttle::Key::address("reset", &addr)]; let now = db::now_ms(); let waiting = app.throttle.lock().unwrap().wait(&keys, now); if let Some(wait) = waiting { let mut page = RecoveryPage::new(&app, shell, "request"); page.error = Some(crate::throttle::message(wait)); return Ok(Page(page)); } let started = app.throttle.lock().unwrap().failed(&keys, now); if let Some(wait) = started { accounts::audit(&app.db, None, "password.reset_throttled", &addr, &format!("for {}s", wait / 1000)).await.ok(); } let who = f.who.trim(); let e = |e| Error::from_db(shell.clone(), e); let account = if who.contains('@') { accounts::account_by_verified_email(&app.db, who).await.map_err(e)? } else { accounts::account_by_name(&app.db, who).await.map_err(e)? }; if let Some(a) = account.filter(|a| a.email_verified && !a.email.is_empty() && !a.blocked()) { let recent = accounts::last_link_ms(&app.db, a.id, "reset", None).await.map_err(e)?; if !recent.is_some_and(|t| db::now_ms() - t < MAIL_EVERY_MS) { let secret = accounts::create_link(&app.db, a.id, "reset", "", None, RESET_TTL_MS).await.map_err(e)?; let url = format!("{}/login/reset/{secret}", app.config.base_url()); let body = format!( "Someone asked to reset the password of {} on {}. To choose a new password, open:\n\n{url}\n\nThe link works once, for one hour. If it was not you, ignore this; your password stays as it is.\n", a.username, app.config.site.name ); // Sent in the background, so the answer takes as long either way. let (mailer, name, addr, user) = (app.clone(), a.name().to_string(), a.email.clone(), a.username.clone()); tokio::spawn(async move { if let Err(err) = crate::mail::send(&mailer, &name, &addr, "Reset your password", &body).await { tracing::warn!("reset mail for {user}: {err}"); } }); accounts::audit(&app.db, Some(&a), "password.reset_requested", &a.username, "").await.ok(); } } Ok(Page(RecoveryPage::new(&app, shell, "sent"))) } async fn reset_target(app: &Shared, secret: &str) -> sqlx::Result> { let Some(link) = accounts::live_link(&app.db, "reset", secret).await? else { return Ok(None) }; let account = accounts::account_by_id(&app.db, link.account_id).await?; Ok(account.filter(|a| !a.blocked()).map(|a| (link, a))) } /// `GET /login/reset/{secret}`. pub async fn new_password_form(State(app): State, viewer: Viewer, Path(secret): Path) -> Result, Error> { let shell = Shell::new(&app, "/login/reset", &viewer); match reset_target(&app, &secret).await.map_err(|e| Error::from_db(shell.clone(), e))? { Some((_, a)) => { let mut page = RecoveryPage::new(&app, shell, "new"); page.username = a.username; page.secret = secret; Ok(Page(page)) } None => Ok(Page(RecoveryPage::new(&app, shell, "invalid"))), } } #[derive(Deserialize)] pub struct NewPasswordForm { password: String, confirm: String, } /// `POST /login/reset/{secret}`. pub async fn set_new_password( State(app): State, viewer: Viewer, Path(secret): Path, Form(f): Form, ) -> Result { let shell = Shell::new(&app, "/login/reset", &viewer); let e = |e| Error::from_db(shell.clone(), e); let Some((link, account)) = reset_target(&app, &secret).await.map_err(e)? else { return Ok(Page(RecoveryPage::new(&app, shell, "invalid")).into_response()); }; let problem = if f.password != f.confirm { Some("The two passwords differ.".to_string()) } else { auth::password_ok(&f.password).err().map(String::from) }; if let Some(msg) = problem { let mut page = RecoveryPage::new(&app, shell, "new"); page.username = account.username; page.secret = secret; page.error = Some(msg); return Ok(Page(page).into_response()); } if !accounts::use_link(&app.db, link.id).await.map_err(e)? { return Ok(Page(RecoveryPage::new(&app, shell, "invalid")).into_response()); } let hash = auth::hash_password(&f.password).map_err(|err| Error::bad(shell.clone(), err.to_string()))?; // A new session salt ends every session; other reset links die with it. accounts::set_password(&app.db, account.id, &hash, &auth::random_hex(16)).await.map_err(e)?; accounts::void_links(&app.db, account.id, "reset").await.map_err(e)?; let by = if link.created_by.is_some() { "administrator's link" } else { "emailed link" }; accounts::audit(&app.db, Some(&account), "password.reset", &account.username, by).await.ok(); Ok(Redirect::to("/login?reset=1").into_response()) } /// An administrator's reset link for an account (the admin page shows it once). pub async fn admin_reset_link(app: &Shared, admin: &accounts::Account, target: &accounts::Account) -> sqlx::Result { let secret = accounts::create_link(&app.db, target.id, "reset", "", Some(admin.id), ADMIN_RESET_TTL_MS).await?; accounts::audit(&app.db, Some(admin), "admin.account.reset_link", &target.username, "valid 24 hours").await.ok(); Ok(format!("{}/login/reset/{secret}", app.config.base_url())) }