| 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 | 15× | fn new(app: &Shared, shell: Shell, mode: &'static str) -> RecoveryPage { |
| 35 | 15× | RecoveryPage { shell, mode, mail: crate::mail::enabled(app), error: None, username: String::new(), secret: String::new() } |
| 36 | 15× | } |
| 37 | | } |
| 38 | |
|
| 39 | |
|
| 40 | 129× | pub async fn send_verification(app: &Shared, account: &accounts::Account) -> Result<(), String> { |
| 41 | 129× | if account.email.is_empty() || account.email_verified { |
| 42 | 1× | return Ok(()); |
| 43 | 128× | } |
| 44 | 128× | if !crate::mail::enabled(app) { |
| 45 | 123× | return Err("This hub cannot send mail, so addresses cannot be verified yet.".into()); |
| 46 | 5× | } |
| 47 | 5× | let now = db::now_ms(); |
| 48 | 5× | let recent = accounts::last_link_ms(&app.db, account.id, "verify", Some(&account.email)).await.map_err(|e| e.to_string())?; |
| 49 | 5× | if recent.is_some_and(|t| now - t < MAIL_EVERY_MS) { |
| 50 | 1× | return Err("A verification link was sent less than two minutes ago.".into()); |
| 51 | 4× | } |
| 52 | 4× | if accounts::links_since(&app.db, account.id, "verify", now - 60 * MINUTE).await.map_err(|e| e.to_string())? >= VERIFY_PER_HOUR { |
| 53 | 0× | return Err("Too many verification links this hour; try again later.".into()); |
| 54 | 4× | } |
| 55 | 4× | let secret = |
| 56 | 4× | accounts::create_link(&app.db, account.id, "verify", &account.email, None, VERIFY_TTL_MS).await.map_err(|e| e.to_string())?; |
| 57 | 4× | let url = format!("{}/verify/{secret}", app.config.base_url()); |
| 58 | 4× | 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 | 4× | app.config.site.name |
| 63 | | ); |
| 64 | 4× | let action = crate::mail::Action { label: "Confirm my email address", url: &url }; |
| 65 | 4× | crate::mail::send_letter(app, account.name(), &account.email, "✉️ Confirm your email address", &body, Some(action), None).await |
| 66 | 129× | } |
| 67 | |
|
| 68 | |
|
| 69 | 5× | pub async fn verify(State(app): State<Shared>, viewer: Viewer, Path(secret): Path<String>) -> Result<Page<RecoveryPage>, Error> { |
| 70 | 5× | let shell = Shell::new(&app, "/verify", &viewer); |
| 71 | 5× | let e = |e| Error::from_db(shell.clone(), e); |
| 72 | 5× | let link = accounts::live_link(&app.db, "verify", &secret).await.map_err(e)?; |
| 73 | 5× | let account = match &link { |
| 74 | 3× | Some(l) => accounts::account_by_id(&app.db, l.account_id).await.map_err(e)?, |
| 75 | 2× | None => None, |
| 76 | | }; |
| 77 | 5× | match (link, account) { |
| 78 | | |
| 79 | 3× | (Some(l), Some(a)) if a.email == l.email && accounts::use_link(&app.db, l.id).await.map_err(e)? => { |
| 80 | 2× | accounts::set_email_verified(&app.db, a.id, true).await.map_err(e)?; |
| 81 | 2× | accounts::audit(&app.db, Some(&a), "email.verified", &a.username, &a.email).await.ok(); |
| 82 | 2× | let mut page = RecoveryPage::new(&app, shell, "verified"); |
| 83 | 2× | page.username = a.email; |
| 84 | 2× | Ok(Page(page)) |
| 85 | | } |
| 86 | 3× | _ => Ok(Page(RecoveryPage::new(&app, shell, "verify-invalid"))), |
| 87 | | } |
| 88 | 5× | } |
| 89 | |
|
| 90 | |
|
| 91 | 1× | pub async fn request_form(State(app): State<Shared>, viewer: Viewer) -> Page<RecoveryPage> { |
| 92 | 1× | Page(RecoveryPage::new(&app, Shell::new(&app, "/login/reset", &viewer), "request")) |
| 93 | 1× | } |
| 94 | |
|
| 95 | | #[derive(Deserialize)] |
| 96 | | pub struct RequestForm { |
| 97 | | who: String, |
| 98 | | } |
| 99 | |
|
| 100 | |
|
| 101 | 5× | pub async fn request( |
| 102 | 5× | State(app): State<Shared>, |
| 103 | 5× | viewer: Viewer, |
| 104 | 5× | headers: axum::http::HeaderMap, |
| 105 | 5× | extensions: axum::http::Extensions, |
| 106 | 5× | Form(f): Form<RequestForm>, |
| 107 | 5× | ) -> Result<Page<RecoveryPage>, Error> { |
| 108 | 5× | let shell = Shell::new(&app, "/login/reset", &viewer); |
| 109 | 5× | if !crate::mail::enabled(&app) { |
| 110 | 1× | return Ok(Page(RecoveryPage::new(&app, shell, "request"))); |
| 111 | 4× | } |
| 112 | | |
| 113 | | |
| 114 | 4× | let addr = crate::api::client_addr(&app, &headers, &extensions); |
| 115 | 4× | let keys = [crate::throttle::Key::address("reset", &addr)]; |
| 116 | 4× | let now = db::now_ms(); |
| 117 | 4× | let waiting = app.throttle.lock().unwrap().wait(&keys, now); |
| 118 | 4× | if let Some(wait) = waiting { |
| 119 | 0× | let mut page = RecoveryPage::new(&app, shell, "request"); |
| 120 | 0× | page.error = Some(crate::throttle::message(wait)); |
| 121 | 0× | return Ok(Page(page)); |
| 122 | 4× | } |
| 123 | 4× | let started = app.throttle.lock().unwrap().failed(&keys, now); |
| 124 | 4× | if let Some(wait) = started { |
| 125 | 0× | accounts::audit(&app.db, None, "password.reset_throttled", &addr, &format!("for {}s", wait / 1000)).await.ok(); |
| 126 | 4× | } |
| 127 | 4× | let who = f.who.trim(); |
| 128 | 4× | let e = |e| Error::from_db(shell.clone(), e); |
| 129 | 4× | let account = if who.contains('@') { |
| 130 | 1× | accounts::account_by_verified_email(&app.db, who).await.map_err(e)? |
| 131 | | } else { |
| 132 | 3× | accounts::account_by_name(&app.db, who).await.map_err(e)? |
| 133 | | }; |
| 134 | 4× | if let Some(a) = account.filter(|a| a.email_verified && !a.email.is_empty() && !a.blocked()) { |
| 135 | 2× | let recent = accounts::last_link_ms(&app.db, a.id, "reset", None).await.map_err(e)?; |
| 136 | 2× | if !recent.is_some_and(|t| db::now_ms() - t < MAIL_EVERY_MS) { |
| 137 | 1× | let secret = accounts::create_link(&app.db, a.id, "reset", "", None, RESET_TTL_MS).await.map_err(e)?; |
| 138 | 1× | let url = format!("{}/login/reset/{secret}", app.config.base_url()); |
| 139 | 1× | 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 | 1× | a.username, app.config.site.name |
| 142 | | ); |
| 143 | | |
| 144 | 1× | let (mailer, name, addr, user) = (app.clone(), a.name().to_string(), a.email.clone(), a.username.clone()); |
| 145 | 1× | tokio::spawn(async move { |
| 146 | 1× | let action = crate::mail::Action { label: "Choose a new password", url: &url }; |
| 147 | 1× | if let Err(err) = crate::mail::send_letter(&mailer, &name, &addr, "🔑 Reset your password", &body, Some(action), None).await |
| 148 | | { |
| 149 | 0× | tracing::warn!("reset mail for {user}: {err}"); |
| 150 | 1× | } |
| 151 | 1× | }); |
| 152 | 1× | accounts::audit(&app.db, Some(&a), "password.reset_requested", &a.username, "").await.ok(); |
| 153 | 1× | } |
| 154 | 2× | } |
| 155 | 4× | Ok(Page(RecoveryPage::new(&app, shell, "sent"))) |
| 156 | 5× | } |
| 157 | |
|
| 158 | 6× | async fn reset_target(app: &Shared, secret: &str) -> sqlx::Result<Option<(accounts::AccountLink, accounts::Account)>> { |
| 159 | 6× | let Some(link) = accounts::live_link(&app.db, "reset", secret).await? else { return Ok(None) }; |
| 160 | 5× | let account = accounts::account_by_id(&app.db, link.account_id).await?; |
| 161 | 5× | Ok(account.filter(|a| !a.blocked()).map(|a| (link, a))) |
| 162 | 6× | } |
| 163 | |
|
| 164 | |
|
| 165 | 2× | pub async fn new_password_form(State(app): State<Shared>, viewer: Viewer, Path(secret): Path<String>) -> Result<Page<RecoveryPage>, Error> { |
| 166 | 2× | let shell = Shell::new(&app, "/login/reset", &viewer); |
| 167 | 2× | match reset_target(&app, &secret).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
| 168 | 1× | Some((_, a)) => { |
| 169 | 1× | let mut page = RecoveryPage::new(&app, shell, "new"); |
| 170 | 1× | page.username = a.username; |
| 171 | 1× | page.secret = secret; |
| 172 | 1× | Ok(Page(page)) |
| 173 | | } |
| 174 | 1× | None => Ok(Page(RecoveryPage::new(&app, shell, "invalid"))), |
| 175 | | } |
| 176 | 2× | } |
| 177 | |
|
| 178 | | #[derive(Deserialize)] |
| 179 | | pub struct NewPasswordForm { |
| 180 | | password: String, |
| 181 | | confirm: String, |
| 182 | | } |
| 183 | |
|
| 184 | |
|
| 185 | 4× | pub async fn set_new_password( |
| 186 | 4× | State(app): State<Shared>, |
| 187 | 4× | viewer: Viewer, |
| 188 | 4× | Path(secret): Path<String>, |
| 189 | 4× | Form(f): Form<NewPasswordForm>, |
| 190 | 4× | ) -> Result<Response, Error> { |
| 191 | 4× | let shell = Shell::new(&app, "/login/reset", &viewer); |
| 192 | 4× | let e = |e| Error::from_db(shell.clone(), e); |
| 193 | 4× | let Some((link, account)) = reset_target(&app, &secret).await.map_err(e)? else { |
| 194 | 0× | return Ok(Page(RecoveryPage::new(&app, shell, "invalid")).into_response()); |
| 195 | | }; |
| 196 | 4× | let problem = if f.password != f.confirm { |
| 197 | 1× | Some("The two passwords differ.".to_string()) |
| 198 | | } else { |
| 199 | 3× | auth::password_ok(&f.password).err().map(String::from) |
| 200 | | }; |
| 201 | 4× | if let Some(msg) = problem { |
| 202 | 2× | let mut page = RecoveryPage::new(&app, shell, "new"); |
| 203 | 2× | page.username = account.username; |
| 204 | 2× | page.secret = secret; |
| 205 | 2× | page.error = Some(msg); |
| 206 | 2× | return Ok(Page(page).into_response()); |
| 207 | 2× | } |
| 208 | 2× | if !accounts::use_link(&app.db, link.id).await.map_err(e)? { |
| 209 | 0× | return Ok(Page(RecoveryPage::new(&app, shell, "invalid")).into_response()); |
| 210 | 2× | } |
| 211 | 2× | let hash = auth::hash_password(&f.password).map_err(|err| Error::bad(shell.clone(), err.to_string()))?; |
| 212 | | |
| 213 | 2× | accounts::set_password(&app.db, account.id, &hash, &auth::random_hex(16)).await.map_err(e)?; |
| 214 | 2× | accounts::void_links(&app.db, account.id, "reset").await.map_err(e)?; |
| 215 | 2× | let by = if link.created_by.is_some() { "administrator's link" } else { "emailed link" }; |
| 216 | 2× | accounts::audit(&app.db, Some(&account), "password.reset", &account.username, by).await.ok(); |
| 217 | 2× | Ok(Redirect::to("/login?reset=1").into_response()) |
| 218 | 4× | } |
| 219 | |
|
| 220 | |
|
| 221 | 1× | pub async fn admin_reset_link(app: &Shared, admin: &accounts::Account, target: &accounts::Account) -> sqlx::Result<String> { |
| 222 | 1× | let secret = accounts::create_link(&app.db, target.id, "reset", "", Some(admin.id), ADMIN_RESET_TTL_MS).await?; |
| 223 | 1× | accounts::audit(&app.db, Some(admin), "admin.account.reset_link", &target.username, "valid 24 hours").await.ok(); |
| 224 | 1× | Ok(format!("{}/login/reset/{secret}", app.config.base_url())) |
| 225 | 1× | } |