OXYGENHUB
# a0a1601eb5a5 oxygenhub / crates / hub / src / web / recovery.rs
10.4 KiB · Rust · last changed Sign-in, 2FA codes, device codes and reset requests slow guessing 20 days ago source blame history raw
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
    let action = crate::mail::Action { label: "Confirm my email address", url: &url };
65
    crate::mail::send_letter(app, account.name(), &account.email, "Confirm your email address", &body, Some(action), None).await
66
}
67
68
/// `GET /verify/{secret}`.
69
pub async fn verify(State(app): State<Shared>, viewer: Viewer, Path(secret): Path<String>) -> Result<Page<RecoveryPage>, Error> {
70
    let shell = Shell::new(&app, "/verify", &viewer);
71
    let e = |e| Error::from_db(shell.clone(), e);
72
    let link = accounts::live_link(&app.db, "verify", &secret).await.map_err(e)?;
73
    let account = match &link {
74
        Some(l) => accounts::account_by_id(&app.db, l.account_id).await.map_err(e)?,
75
        None => None,
76
    };
77
    match (link, account) {
78
        // Only the address the link was sent to, and only while it is still the account's.
79
        (Some(l), Some(a)) if a.email == l.email && accounts::use_link(&app.db, l.id).await.map_err(e)? => {
80
            accounts::set_email_verified(&app.db, a.id, true).await.map_err(e)?;
81
            accounts::audit(&app.db, Some(&a), "email.verified", &a.username, &a.email).await.ok();
82
            let mut page = RecoveryPage::new(&app, shell, "verified");
83
            page.username = a.email;
84
            Ok(Page(page))
85
        }
86
        _ => Ok(Page(RecoveryPage::new(&app, shell, "verify-invalid"))),
87
    }
88
}
89
90
/// `GET /login/reset`.
91
pub async fn request_form(State(app): State<Shared>, viewer: Viewer) -> Page<RecoveryPage> {
92
    Page(RecoveryPage::new(&app, Shell::new(&app, "/login/reset", &viewer), "request"))
93
}
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.
101
pub async fn request(
102
    State(app): State<Shared>,
103
    viewer: Viewer,
104
    headers: axum::http::HeaderMap,
105
    extensions: axum::http::Extensions,
106
    Form(f): Form<RequestForm>,
107
) -> Result<Page<RecoveryPage>, Error> {
108
    let shell = Shell::new(&app, "/login/reset", &viewer);
109
    if !crate::mail::enabled(&app) {
110
        return Ok(Page(RecoveryPage::new(&app, shell, "request")));
111
    }
112
    // Every request counts against its address (oxygenhub#221): the answer
113
    // is the same whether or not anything matched, so this says nothing.
114
    let addr = crate::api::client_addr(&app, &headers, &extensions);
115
    let keys = [crate::throttle::Key::address("reset", &addr)];
116
    let now = db::now_ms();
117
    let waiting = app.throttle.lock().unwrap().wait(&keys, now);
118
    if let Some(wait) = waiting {
119
        let mut page = RecoveryPage::new(&app, shell, "request");
120
        page.error = Some(crate::throttle::message(wait));
121
        return Ok(Page(page));
122
    }
123
    let started = app.throttle.lock().unwrap().failed(&keys, now);
124
    if let Some(wait) = started {
125
        accounts::audit(&app.db, None, "password.reset_throttled", &addr, &format!("for {}s", wait / 1000)).await.ok();
126
    }
127
    let who = f.who.trim();
128
    let e = |e| Error::from_db(shell.clone(), e);
129
    let account = if who.contains('@') {
130
        accounts::account_by_verified_email(&app.db, who).await.map_err(e)?
131
    } else {
132
        accounts::account_by_name(&app.db, who).await.map_err(e)?
133
    };
134
    if let Some(a) = account.filter(|a| a.email_verified && !a.email.is_empty() && !a.blocked()) {
135
        let recent = accounts::last_link_ms(&app.db, a.id, "reset", None).await.map_err(e)?;
136
        if !recent.is_some_and(|t| db::now_ms() - t < MAIL_EVERY_MS) {
137
            let secret = accounts::create_link(&app.db, a.id, "reset", "", None, RESET_TTL_MS).await.map_err(e)?;
138
            let url = format!("{}/login/reset/{secret}", app.config.base_url());
139
            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",
141
                a.username, app.config.site.name
142
            );
143
            // Sent in the background, so the answer takes as long either way.
144
            let (mailer, name, addr, user) = (app.clone(), a.name().to_string(), a.email.clone(), a.username.clone());
145
            tokio::spawn(async move {
146
                let action = crate::mail::Action { label: "Choose a new password", url: &url };
147
                if let Err(err) = crate::mail::send_letter(&mailer, &name, &addr, "Reset your password", &body, Some(action), None).await {
148
                    tracing::warn!("reset mail for {user}: {err}");
149
                }
150
            });
151
            accounts::audit(&app.db, Some(&a), "password.reset_requested", &a.username, "").await.ok();
152
        }
153
    }
154
    Ok(Page(RecoveryPage::new(&app, shell, "sent")))
155
}
156
157
async fn reset_target(app: &Shared, secret: &str) -> sqlx::Result<Option<(accounts::AccountLink, accounts::Account)>> {
158
    let Some(link) = accounts::live_link(&app.db, "reset", secret).await? else { return Ok(None) };
159
    let account = accounts::account_by_id(&app.db, link.account_id).await?;
160
    Ok(account.filter(|a| !a.blocked()).map(|a| (link, a)))
161
}
162
163
/// `GET /login/reset/{secret}`.
164
pub async fn new_password_form(State(app): State<Shared>, viewer: Viewer, Path(secret): Path<String>) -> Result<Page<RecoveryPage>, Error> {
165
    let shell = Shell::new(&app, "/login/reset", &viewer);
166
    match reset_target(&app, &secret).await.map_err(|e| Error::from_db(shell.clone(), e))? {
167
        Some((_, a)) => {
168
            let mut page = RecoveryPage::new(&app, shell, "new");
169
            page.username = a.username;
170
            page.secret = secret;
171
            Ok(Page(page))
172
        }
173
        None => Ok(Page(RecoveryPage::new(&app, shell, "invalid"))),
174
    }
175
}
176
177
#[derive(Deserialize)]
178
pub struct NewPasswordForm {
179
    password: String,
180
    confirm: String,
181
}
182
183
/// `POST /login/reset/{secret}`.
184
pub async fn set_new_password(
185
    State(app): State<Shared>,
186
    viewer: Viewer,
187
    Path(secret): Path<String>,
188
    Form(f): Form<NewPasswordForm>,
189
) -> Result<Response, Error> {
190
    let shell = Shell::new(&app, "/login/reset", &viewer);
191
    let e = |e| Error::from_db(shell.clone(), e);
192
    let Some((link, account)) = reset_target(&app, &secret).await.map_err(e)? else {
193
        return Ok(Page(RecoveryPage::new(&app, shell, "invalid")).into_response());
194
    };
195
    let problem = if f.password != f.confirm {
196
        Some("The two passwords differ.".to_string())
197
    } else {
198
        auth::password_ok(&f.password).err().map(String::from)
199
    };
200
    if let Some(msg) = problem {
201
        let mut page = RecoveryPage::new(&app, shell, "new");
202
        page.username = account.username;
203
        page.secret = secret;
204
        page.error = Some(msg);
205
        return Ok(Page(page).into_response());
206
    }
207
    if !accounts::use_link(&app.db, link.id).await.map_err(e)? {
208
        return Ok(Page(RecoveryPage::new(&app, shell, "invalid")).into_response());
209
    }
210
    let hash = auth::hash_password(&f.password).map_err(|err| Error::bad(shell.clone(), err.to_string()))?;
211
    // A new session salt ends every session; other reset links die with it.
212
    accounts::set_password(&app.db, account.id, &hash, &auth::random_hex(16)).await.map_err(e)?;
213
    accounts::void_links(&app.db, account.id, "reset").await.map_err(e)?;
214
    let by = if link.created_by.is_some() { "administrator's link" } else { "emailed link" };
215
    accounts::audit(&app.db, Some(&account), "password.reset", &account.username, by).await.ok();
216
    Ok(Redirect::to("/login?reset=1").into_response())
217
}
218
219
/// An administrator's reset link for an account (the admin page shows it once).
220
pub async fn admin_reset_link(app: &Shared, admin: &accounts::Account, target: &accounts::Account) -> sqlx::Result<String> {
221
    let secret = accounts::create_link(&app.db, target.id, "reset", "", Some(admin.id), ADMIN_RESET_TTL_MS).await?;
222
    accounts::audit(&app.db, Some(admin), "admin.account.reset_link", &target.username, "valid 24 hours").await.ok();
223
    Ok(format!("{}/login/reset/{secret}", app.config.base_url()))
224
}