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