OXYGENHUB
◆ clear-waiting oxygenhub / crates / hub / src / web / recovery.rs
9.3 KiB · Rust · last changed Admins disable, enable and delete accounts 1 month ago source blame history raw
34beb1a7 Andy McCall ↶ 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 {
34
    fn new(app: &Shared, shell: Shell, mode: &'static str) -> RecoveryPage {
35
        RecoveryPage { shell, mode, mail: crate::mail::enabled(app), error: None, username: String::new(), secret: String::new() }
36
    }
37
}
38
39
/// Send a verification link for the account's current email.
40
pub async fn send_verification(app: &Shared, account: &accounts::Account) -> Result<(), String> {
41
    if account.email.is_empty() || account.email_verified {
42
        return Ok(());
43
    }
44
    if !crate::mail::enabled(app) {
45
        return Err("This hub cannot send mail, so addresses cannot be verified yet.".into());
46
    }
47
    let now = db::now_ms();
48
    let recent = accounts::last_link_ms(&app.db, account.id, "verify", Some(&account.email)).await.map_err(|e| e.to_string())?;
49
    if recent.is_some_and(|t| now - t < MAIL_EVERY_MS) {
50
        return Err("A verification link was sent less than two minutes ago.".into());
51
    }
52
    if accounts::links_since(&app.db, account.id, "verify", now - 60 * MINUTE).await.map_err(|e| e.to_string())? >= VERIFY_PER_HOUR {
53
        return Err("Too many verification links this hour; try again later.".into());
54
    }
55
    let secret =
56
        accounts::create_link(&app.db, account.id, "verify", &account.email, None, VERIFY_TTL_MS).await.map_err(|e| e.to_string())?;
57
    let url = format!("{}/verify/{secret}", app.config.base_url());
58
    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,
62
        app.config.site.name
63
    );
64
    crate::mail::send(app, account.name(), &account.email, "Confirm your email address", &body).await
65
}
66
67
/// `GET /verify/{secret}`.
68
pub async fn verify(State(app): State<Shared>, viewer: Viewer, Path(secret): Path<String>) -> Result<Page<RecoveryPage>, Error> {
69
    let shell = Shell::new(&app, "/verify", &viewer);
70
    let e = |e| Error::from_db(shell.clone(), e);
71
    let link = accounts::live_link(&app.db, "verify", &secret).await.map_err(e)?;
72
    let account = match &link {
73
        Some(l) => accounts::account_by_id(&app.db, l.account_id).await.map_err(e)?,
74
        None => None,
75
    };
76
    match (link, account) {
77
        // Only the address the link was sent to, and only while it is still the account's.
78
        (Some(l), Some(a)) if a.email == l.email && accounts::use_link(&app.db, l.id).await.map_err(e)? => {
79
            accounts::set_email_verified(&app.db, a.id, true).await.map_err(e)?;
80
            accounts::audit(&app.db, Some(&a), "email.verified", &a.username, &a.email).await.ok();
81
            let mut page = RecoveryPage::new(&app, shell, "verified");
82
            page.username = a.email;
83
            Ok(Page(page))
84
        }
85
        _ => Ok(Page(RecoveryPage::new(&app, shell, "verify-invalid"))),
86
    }
87
}
88
89
/// `GET /login/reset`.
90
pub async fn request_form(State(app): State<Shared>, viewer: Viewer) -> Page<RecoveryPage> {
91
    Page(RecoveryPage::new(&app, Shell::new(&app, "/login/reset", &viewer), "request"))
92
}
93
94
#[derive(Deserialize)]
95
pub struct RequestForm {
96
    who: String,
97
}
98
99
/// `POST /login/reset`: the same answer whether or not anything matched.
100
pub async fn request(State(app): State<Shared>, viewer: Viewer, Form(f): Form<RequestForm>) -> Result<Page<RecoveryPage>, Error> {
101
    let shell = Shell::new(&app, "/login/reset", &viewer);
102
    if !crate::mail::enabled(&app) {
103
        return Ok(Page(RecoveryPage::new(&app, shell, "request")));
104
    }
105
    let who = f.who.trim();
106
    let e = |e| Error::from_db(shell.clone(), e);
107
    let account = if who.contains('@') {
108
        accounts::account_by_verified_email(&app.db, who).await.map_err(e)?
109
    } else {
110
        accounts::account_by_name(&app.db, who).await.map_err(e)?
111
    };
6a178055 Andy McCall ↶ 112
    if let Some(a) = account.filter(|a| a.email_verified && !a.email.is_empty() && !a.blocked()) {
34beb1a7 Andy McCall ↶ 113
        let recent = accounts::last_link_ms(&app.db, a.id, "reset", None).await.map_err(e)?;
114
        if !recent.is_some_and(|t| db::now_ms() - t < MAIL_EVERY_MS) {
115
            let secret = accounts::create_link(&app.db, a.id, "reset", "", None, RESET_TTL_MS).await.map_err(e)?;
116
            let url = format!("{}/login/reset/{secret}", app.config.base_url());
117
            let body = format!(
118
                "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",
119
                a.username, app.config.site.name
120
            );
121
            // Sent in the background, so the answer takes as long either way.
122
            let (mailer, name, addr, user) = (app.clone(), a.name().to_string(), a.email.clone(), a.username.clone());
123
            tokio::spawn(async move {
124
                if let Err(err) = crate::mail::send(&mailer, &name, &addr, "Reset your password", &body).await {
125
                    tracing::warn!("reset mail for {user}: {err}");
126
                }
127
            });
128
            accounts::audit(&app.db, Some(&a), "password.reset_requested", &a.username, "").await.ok();
129
        }
130
    }
131
    Ok(Page(RecoveryPage::new(&app, shell, "sent")))
132
}
133
134
async fn reset_target(app: &Shared, secret: &str) -> sqlx::Result<Option<(accounts::AccountLink, accounts::Account)>> {
135
    let Some(link) = accounts::live_link(&app.db, "reset", secret).await? else { return Ok(None) };
136
    let account = accounts::account_by_id(&app.db, link.account_id).await?;
6a178055 Andy McCall ↶ 137
    Ok(account.filter(|a| !a.blocked()).map(|a| (link, a)))
34beb1a7 Andy McCall ↶ 138
}
139
140
/// `GET /login/reset/{secret}`.
141
pub async fn new_password_form(State(app): State<Shared>, viewer: Viewer, Path(secret): Path<String>) -> Result<Page<RecoveryPage>, Error> {
142
    let shell = Shell::new(&app, "/login/reset", &viewer);
143
    match reset_target(&app, &secret).await.map_err(|e| Error::from_db(shell.clone(), e))? {
144
        Some((_, a)) => {
145
            let mut page = RecoveryPage::new(&app, shell, "new");
146
            page.username = a.username;
147
            page.secret = secret;
148
            Ok(Page(page))
149
        }
150
        None => Ok(Page(RecoveryPage::new(&app, shell, "invalid"))),
151
    }
152
}
153
154
#[derive(Deserialize)]
155
pub struct NewPasswordForm {
156
    password: String,
157
    confirm: String,
158
}
159
160
/// `POST /login/reset/{secret}`.
161
pub async fn set_new_password(
162
    State(app): State<Shared>,
163
    viewer: Viewer,
164
    Path(secret): Path<String>,
165
    Form(f): Form<NewPasswordForm>,
166
) -> Result<Response, Error> {
167
    let shell = Shell::new(&app, "/login/reset", &viewer);
168
    let e = |e| Error::from_db(shell.clone(), e);
169
    let Some((link, account)) = reset_target(&app, &secret).await.map_err(e)? else {
170
        return Ok(Page(RecoveryPage::new(&app, shell, "invalid")).into_response());
171
    };
172
    let problem = if f.password != f.confirm {
173
        Some("The two passwords differ.".to_string())
174
    } else {
175
        auth::password_ok(&f.password).err().map(String::from)
176
    };
177
    if let Some(msg) = problem {
178
        let mut page = RecoveryPage::new(&app, shell, "new");
179
        page.username = account.username;
180
        page.secret = secret;
181
        page.error = Some(msg);
182
        return Ok(Page(page).into_response());
183
    }
184
    if !accounts::use_link(&app.db, link.id).await.map_err(e)? {
185
        return Ok(Page(RecoveryPage::new(&app, shell, "invalid")).into_response());
186
    }
187
    let hash = auth::hash_password(&f.password).map_err(|err| Error::bad(shell.clone(), err.to_string()))?;
188
    // A new session salt ends every session; other reset links die with it.
189
    accounts::set_password(&app.db, account.id, &hash, &auth::random_hex(16)).await.map_err(e)?;
190
    accounts::void_links(&app.db, account.id, "reset").await.map_err(e)?;
191
    let by = if link.created_by.is_some() { "administrator's link" } else { "emailed link" };
192
    accounts::audit(&app.db, Some(&account), "password.reset", &account.username, by).await.ok();
193
    Ok(Redirect::to("/login?reset=1").into_response())
194
}
195
196
/// An administrator's reset link for an account (the admin page shows it once).
197
pub async fn admin_reset_link(app: &Shared, admin: &accounts::Account, target: &accounts::Account) -> sqlx::Result<String> {
198
    let secret = accounts::create_link(&app.db, target.id, "reset", "", Some(admin.id), ADMIN_RESET_TTL_MS).await?;
199
    accounts::audit(&app.db, Some(admin), "admin.account.reset_link", &target.username, "valid 24 hours").await.ok();
200
    Ok(format!("{}/login/reset/{secret}", app.config.base_url()))
201
}