| 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 |
|
| 17 |
pub const MAIL_EVERY_MS: i64 = 2 * MINUTE; |
| 18 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
}; |
| 112 |
if let Some(a) = account.filter(|a| a.email_verified && !a.email.is_empty() && !a.banned) { |
| 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 |
|
| 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?; |
| 137 |
Ok(account.filter(|a| !a.banned).map(|a| (link, a))) |
| 138 |
} |
| 139 |
|
| 140 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
} |