OXYGENHUB

Coverage / crates/hub/src/web/recovery.rs 90.3%

149 of 165 lines run, 23 of 62 functions · run #528 on main · run missed

1
//! Email verification and password reset (hub spec §5.1.1).
2
3
use super::{Error, Page, Shell};
4
use crate::auth::{self, Viewer};
5
use crate::db::{self, accounts};
6
use crate::Shared;
7
use askama::Template;
8
use axum::extract::{Form, Path, State};
9
use axum::response::{IntoResponse, Redirect, Response};
10
use serde::Deserialize;
11
12
const MINUTE: i64 = 60_000;
13
pub const VERIFY_TTL_MS: i64 = 48 * 60 * MINUTE;
14
pub const RESET_TTL_MS: i64 = 60 * MINUTE;
15
pub const ADMIN_RESET_TTL_MS: i64 = 24 * 60 * MINUTE;
16
/// At most one mailed link of a kind per account (and address) in this time.
17
pub const MAIL_EVERY_MS: i64 = 2 * MINUTE;
18
/// At most this many verification mails per account per hour, whatever the addresses.
19
pub const VERIFY_PER_HOUR: i64 = 10;
20
21
#[derive(Template)]
22
#[template(path = "reset.html")]
23
pub struct RecoveryPage {
24
    shell: Shell,
25
    /// `request`, `sent`, `new`, `invalid`, `verified`, `verify-invalid`.
26
    mode: &'static str,
27
    mail: bool,
28
    error: Option<String>,
29
    username: String,
30
    secret: String,
31
}
32
33
impl RecoveryPage {
3415×
    fn new(app: &Shared, shell: Shell, mode: &'static str) -> RecoveryPage {
3515×
        RecoveryPage { shell, mode, mail: crate::mail::enabled(app), error: None, username: String::new(), secret: String::new() }
3615×
    }
37
}
38
39
/// Send a verification link for the account's current email.
40138×
pub async fn send_verification(app: &Shared, account: &accounts::Account) -> Result<(), String> {
41138×
    if account.email.is_empty() || account.email_verified {
421×
        return Ok(());
43137×
    }
44137×
    if !crate::mail::enabled(app) {
45132×
        return Err("This hub cannot send mail, so addresses cannot be verified yet.".into());
465×
    }
475×
    let now = db::now_ms();
485×
    let recent = accounts::last_link_ms(&app.db, account.id, "verify", Some(&account.email)).await.map_err(|e| e.to_string())?;
495×
    if recent.is_some_and(|t| now - t < MAIL_EVERY_MS) {
501×
        return Err("A verification link was sent less than two minutes ago.".into());
514×
    }
524×
    if accounts::links_since(&app.db, account.id, "verify", now - 60 * MINUTE).await.map_err(|e| e.to_string())? >= VERIFY_PER_HOUR {
530×
        return Err("Too many verification links this hour; try again later.".into());
544×
    }
554×
    let secret =
564×
        accounts::create_link(&app.db, account.id, "verify", &account.email, None, VERIFY_TTL_MS).await.map_err(|e| e.to_string())?;
574×
    let url = format!("{}/verify/{secret}", app.config.base_url());
584×
    let body = format!(
59
        "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",
60
        account.email,
61
        account.username,
624×
        app.config.site.name
63
    );
644×
    let action = crate::mail::Action { label: "Confirm my email address", url: &url };
654×
    crate::mail::send_letter(app, account.name(), &account.email, "✉️ Confirm your email address", &body, Some(action), None).await
66138×
}
67
68
/// `GET /verify/{secret}`.
695×
pub async fn verify(State(app): State<Shared>, viewer: Viewer, Path(secret): Path<String>) -> Result<Page<RecoveryPage>, Error> {
705×
    let shell = Shell::new(&app, "/verify", &viewer);
715×
    let e = |e| Error::from_db(shell.clone(), e);
725×
    let link = accounts::live_link(&app.db, "verify", &secret).await.map_err(e)?;
735×
    let account = match &link {
743×
        Some(l) => accounts::account_by_id(&app.db, l.account_id).await.map_err(e)?,
752×
        None => None,
76
    };
775×
    match (link, account) {
78
        // Only the address the link was sent to, and only while it is still the account's.
793×
        (Some(l), Some(a)) if a.email == l.email && accounts::use_link(&app.db, l.id).await.map_err(e)? => {
802×
            accounts::set_email_verified(&app.db, a.id, true).await.map_err(e)?;
812×
            accounts::audit(&app.db, Some(&a), "email.verified", &a.username, &a.email).await.ok();
822×
            let mut page = RecoveryPage::new(&app, shell, "verified");
832×
            page.username = a.email;
842×
            Ok(Page(page))
85
        }
863×
        _ => Ok(Page(RecoveryPage::new(&app, shell, "verify-invalid"))),
87
    }
885×
}
89
90
/// `GET /login/reset`.
911×
pub async fn request_form(State(app): State<Shared>, viewer: Viewer) -> Page<RecoveryPage> {
921×
    Page(RecoveryPage::new(&app, Shell::new(&app, "/login/reset", &viewer), "request"))
931×
}
94
95
#[derive(Deserialize)]
96
pub struct RequestForm {
97
    who: String,
98
}
99
100
/// `POST /login/reset`: the same answer whether or not anything matched.
1015×
pub async fn request(
1025×
    State(app): State<Shared>,
1035×
    viewer: Viewer,
1045×
    headers: axum::http::HeaderMap,
1055×
    extensions: axum::http::Extensions,
1065×
    Form(f): Form<RequestForm>,
1075×
) -> Result<Page<RecoveryPage>, Error> {
1085×
    let shell = Shell::new(&app, "/login/reset", &viewer);
1095×
    if !crate::mail::enabled(&app) {
1101×
        return Ok(Page(RecoveryPage::new(&app, shell, "request")));
1114×
    }
112
    // Every request counts against its address (oxygenhub#221): the answer
113
    // is the same whether or not anything matched, so this says nothing.
1144×
    let addr = crate::api::client_addr(&app, &headers, &extensions);
1154×
    let keys = [crate::throttle::Key::address("reset", &addr)];
1164×
    let now = db::now_ms();
1174×
    let waiting = app.throttle.lock().unwrap().wait(&keys, now);
1184×
    if let Some(wait) = waiting {
1190×
        let mut page = RecoveryPage::new(&app, shell, "request");
1200×
        page.error = Some(crate::throttle::message(wait));
1210×
        return Ok(Page(page));
1224×
    }
1234×
    let started = app.throttle.lock().unwrap().failed(&keys, now);
1244×
    if let Some(wait) = started {
1250×
        accounts::audit(&app.db, None, "password.reset_throttled", &addr, &format!("for {}s", wait / 1000)).await.ok();
1264×
    }
1274×
    let who = f.who.trim();
1284×
    let e = |e| Error::from_db(shell.clone(), e);
1294×
    let account = if who.contains('@') {
1301×
        accounts::account_by_verified_email(&app.db, who).await.map_err(e)?
131
    } else {
1323×
        accounts::account_by_name(&app.db, who).await.map_err(e)?
133
    };
1344×
    if let Some(a) = account.filter(|a| a.email_verified && !a.email.is_empty() && !a.blocked()) {
1352×
        let recent = accounts::last_link_ms(&app.db, a.id, "reset", None).await.map_err(e)?;
1362×
        if !recent.is_some_and(|t| db::now_ms() - t < MAIL_EVERY_MS) {
1371×
            let secret = accounts::create_link(&app.db, a.id, "reset", "", None, RESET_TTL_MS).await.map_err(e)?;
1381×
            let url = format!("{}/login/reset/{secret}", app.config.base_url());
1391×
            let body = format!(
140
                "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",
1411×
                a.username, app.config.site.name
142
            );
143
            // Sent in the background, so the answer takes as long either way.
1441×
            let (mailer, name, addr, user) = (app.clone(), a.name().to_string(), a.email.clone(), a.username.clone());
1451×
            tokio::spawn(async move {
1461×
                let action = crate::mail::Action { label: "Choose a new password", url: &url };
1471×
                if let Err(err) = crate::mail::send_letter(&mailer, &name, &addr, "🔑 Reset your password", &body, Some(action), None).await
148
                {
1490×
                    tracing::warn!("reset mail for {user}: {err}");
1501×
                }
1511×
            });
1521×
            accounts::audit(&app.db, Some(&a), "password.reset_requested", &a.username, "").await.ok();
1531×
        }
1542×
    }
1554×
    Ok(Page(RecoveryPage::new(&app, shell, "sent")))
1565×
}
157
1586×
async fn reset_target(app: &Shared, secret: &str) -> sqlx::Result<Option<(accounts::AccountLink, accounts::Account)>> {
1596×
    let Some(link) = accounts::live_link(&app.db, "reset", secret).await? else { return Ok(None) };
1605×
    let account = accounts::account_by_id(&app.db, link.account_id).await?;
1615×
    Ok(account.filter(|a| !a.blocked()).map(|a| (link, a)))
1626×
}
163
164
/// `GET /login/reset/{secret}`.
1652×
pub async fn new_password_form(State(app): State<Shared>, viewer: Viewer, Path(secret): Path<String>) -> Result<Page<RecoveryPage>, Error> {
1662×
    let shell = Shell::new(&app, "/login/reset", &viewer);
1672×
    match reset_target(&app, &secret).await.map_err(|e| Error::from_db(shell.clone(), e))? {
1681×
        Some((_, a)) => {
1691×
            let mut page = RecoveryPage::new(&app, shell, "new");
1701×
            page.username = a.username;
1711×
            page.secret = secret;
1721×
            Ok(Page(page))
173
        }
1741×
        None => Ok(Page(RecoveryPage::new(&app, shell, "invalid"))),
175
    }
1762×
}
177
178
#[derive(Deserialize)]
179
pub struct NewPasswordForm {
180
    password: String,
181
    confirm: String,
182
}
183
184
/// `POST /login/reset/{secret}`.
1854×
pub async fn set_new_password(
1864×
    State(app): State<Shared>,
1874×
    viewer: Viewer,
1884×
    Path(secret): Path<String>,
1894×
    Form(f): Form<NewPasswordForm>,
1904×
) -> Result<Response, Error> {
1914×
    let shell = Shell::new(&app, "/login/reset", &viewer);
1924×
    let e = |e| Error::from_db(shell.clone(), e);
1934×
    let Some((link, account)) = reset_target(&app, &secret).await.map_err(e)? else {
1940×
        return Ok(Page(RecoveryPage::new(&app, shell, "invalid")).into_response());
195
    };
1964×
    let problem = if f.password != f.confirm {
1971×
        Some("The two passwords differ.".to_string())
198
    } else {
1993×
        auth::password_ok(&f.password).err().map(String::from)
200
    };
2014×
    if let Some(msg) = problem {
2022×
        let mut page = RecoveryPage::new(&app, shell, "new");
2032×
        page.username = account.username;
2042×
        page.secret = secret;
2052×
        page.error = Some(msg);
2062×
        return Ok(Page(page).into_response());
2072×
    }
2082×
    if !accounts::use_link(&app.db, link.id).await.map_err(e)? {
2090×
        return Ok(Page(RecoveryPage::new(&app, shell, "invalid")).into_response());
2102×
    }
2112×
    let hash = auth::hash_password(&f.password).map_err(|err| Error::bad(shell.clone(), err.to_string()))?;
212
    // A new session salt ends every session; other reset links die with it.
2132×
    accounts::set_password(&app.db, account.id, &hash, &auth::random_hex(16)).await.map_err(e)?;
2142×
    accounts::void_links(&app.db, account.id, "reset").await.map_err(e)?;
2152×
    let by = if link.created_by.is_some() { "administrator's link" } else { "emailed link" };
2162×
    accounts::audit(&app.db, Some(&account), "password.reset", &account.username, by).await.ok();
2172×
    Ok(Redirect::to("/login?reset=1").into_response())
2184×
}
219
220
/// An administrator's reset link for an account (the admin page shows it once).
2211×
pub async fn admin_reset_link(app: &Shared, admin: &accounts::Account, target: &accounts::Account) -> sqlx::Result<String> {
2221×
    let secret = accounts::create_link(&app.db, target.id, "reset", "", Some(admin.id), ADMIN_RESET_TTL_MS).await?;
2231×
    accounts::audit(&app.db, Some(admin), "admin.account.reset_link", &target.username, "valid 24 hours").await.ok();
2241×
    Ok(format!("{}/login/reset/{secret}", app.config.base_url()))
2251×
}