Password reset and email verification: single-use hashed links, reset by verified email or an administrator's link, verification on every address change, notification mail only to verified addresses, and a file:// mail transport for development 34beb1a7e376
closes #9
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-28 13:01 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
34beb1a7e37689fe3ebcff9b8a63e5f900047cbb38793e4d2bbf0324bc6c6bae- Depends on
- 101740454617 18e381ea88c2 19bb31ff1f5c 3d1e74cdac76 3f4b0ea6eabf 43e8ca33bc5d 64caed18236a 93be9ce4653e c40c2d3edb51 d23464c0ac60 de505df5cf8d
- Depended on by
- 1062a0f8e985 262ec9c51a5c 737de0fac722 2c47eecfeab6 c8684d44d549 3b1bc27fe805 ea14c4ec5c3f 5d90e0e045e2 0024ccb1b050 aa0bfbd11f02 810896d41be7 2bbdc0268c23 b91e2d81b3f1 bff9106edcba cd46e56b1709 17ca6cddc534 83940d53664f 89e89836cae7 3556d08e17f9 8a3334b7a364 6a1780558353 5e6d6484c28e a160b98fb922 eb0c44a9ad37
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 42 atoms, 5 tree operations, 17 files
Browse the tree at this change
added crates/hub/migrations/0012_account_links.sql +17 −0
| 1 | + | -- Email verification and password reset links (hub spec §5.1.1). Only the |
|
| 2 | + | -- BLAKE3 hash of a link's secret is stored. |
|
| 3 | + | ||
| 4 | + | CREATE TABLE account_links ( |
|
| 5 | + | id INTEGER PRIMARY KEY, |
|
| 6 | + | account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 7 | + | kind TEXT NOT NULL CHECK (kind IN ('reset', 'verify')), |
|
| 8 | + | hash TEXT NOT NULL UNIQUE, |
|
| 9 | + | -- The address a verification link verifies. |
|
| 10 | + | email TEXT NOT NULL DEFAULT '', |
|
| 11 | + | -- The administrator who issued a reset link, when one did. |
|
| 12 | + | created_by INTEGER REFERENCES accounts(id) ON DELETE SET NULL, |
|
| 13 | + | created_ms INTEGER NOT NULL, |
|
| 14 | + | expires_ms INTEGER NOT NULL, |
|
| 15 | + | used_ms INTEGER |
|
| 16 | + | ); |
|
| 17 | + | CREATE INDEX account_links_by_account ON account_links (account_id, kind, created_ms DESC); |
added crates/hub/src/mail.rs +38 −0
| 1 | + | //! Outgoing mail (hub spec §5.1.1, §10): SMTP through lettre, or, for |
|
| 2 | + | //! development and tests, `file:///dir`, which writes each message to that |
|
| 3 | + | //! directory instead of sending it. |
|
| 4 | + | ||
| 5 | + | use crate::Shared; |
|
| 6 | + | ||
| 7 | + | /// Whether the hub can send mail at all. |
|
| 8 | + | pub fn enabled(app: &Shared) -> bool { |
|
| 9 | + | let cfg = &app.config.email; |
|
| 10 | + | !cfg.smtp.is_empty() && !cfg.from.is_empty() |
|
| 11 | + | } |
|
| 12 | + | ||
| 13 | + | /// Send a plain-text message. The subject gets the site's name in brackets. |
|
| 14 | + | pub async fn send(app: &Shared, to_name: &str, to_addr: &str, subject: &str, body: &str) -> Result<(), String> { |
|
| 15 | + | if !enabled(app) { |
|
| 16 | + | return Err("this hub does not send mail".into()); |
|
| 17 | + | } |
|
| 18 | + | let cfg = &app.config.email; |
|
| 19 | + | let subject = format!("[{}] {subject}", app.config.site.name); |
|
| 20 | + | if let Some(dir) = cfg.smtp.strip_prefix("file://") { |
|
| 21 | + | let name = format!("{}-{}.eml", crate::db::now_ms(), crate::auth::random_hex(4)); |
|
| 22 | + | let text = format!("From: {}\nTo: {to_name} <{to_addr}>\nSubject: {subject}\n\n{body}", cfg.from); |
|
| 23 | + | return tokio::fs::write(std::path::Path::new(dir).join(name), text).await.map_err(|e| format!("write mail to {dir}: {e}")); |
|
| 24 | + | } |
|
| 25 | + | use lettre::message::header::ContentType; |
|
| 26 | + | use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; |
|
| 27 | + | let from = cfg.from.parse().map_err(|e| format!("[email] from: {e}"))?; |
|
| 28 | + | let to = format!("{to_name} <{to_addr}>").parse().or_else(|_| to_addr.parse()).map_err(|e| format!("address {to_addr}: {e}"))?; |
|
| 29 | + | let message = Message::builder() |
|
| 30 | + | .from(from) |
|
| 31 | + | .to(to) |
|
| 32 | + | .subject(subject) |
|
| 33 | + | .header(ContentType::TEXT_PLAIN) |
|
| 34 | + | .body(body.to_string()) |
|
| 35 | + | .map_err(|e| e.to_string())?; |
|
| 36 | + | let transport = AsyncSmtpTransport::<Tokio1Executor>::from_url(&cfg.smtp).map_err(|e| format!("[email] smtp: {e}"))?.build(); |
|
| 37 | + | transport.send(message).await.map(|_| ()).map_err(|e| format!("sending to {to_addr}: {e}")) |
|
| 38 | + | } |
added crates/hub/src/web/recovery.rs +201 −0
| 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 | + | }; |
|
| 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 | + | // 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?; |
|
| 137 | + | Ok(account.filter(|a| !a.banned).map(|a| (link, a))) |
|
| 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 | + | } |
added crates/hub/tests/recovery.rs +221 −0
| 1 | + | //! Email verification and password reset (hub spec §5.1.1), with mail |
|
| 2 | + | //! written to a directory (`file://`) so the test reads what was sent. |
|
| 3 | + | ||
| 4 | + | use axum::body::Body; |
|
| 5 | + | use axum::http::{header, Request, StatusCode}; |
|
| 6 | + | use http_body_util::BodyExt; |
|
| 7 | + | use oxygenhub::config::Config; |
|
| 8 | + | use oxygenhub::{db, Shared}; |
|
| 9 | + | use std::path::{Path, PathBuf}; |
|
| 10 | + | use tower::ServiceExt; |
|
| 11 | + | ||
| 12 | + | fn hermetic() { |
|
| 13 | + | static ONCE: std::sync::Once = std::sync::Once::new(); |
|
| 14 | + | static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new(); |
|
| 15 | + | ONCE.call_once(|| { |
|
| 16 | + | let d = tempfile::tempdir().unwrap(); |
|
| 17 | + | std::env::set_var("OXY_HOME", d.path()); |
|
| 18 | + | DIR.set(d).ok(); |
|
| 19 | + | }); |
|
| 20 | + | } |
|
| 21 | + | ||
| 22 | + | struct Hub { |
|
| 23 | + | app: Shared, |
|
| 24 | + | mailbox: PathBuf, |
|
| 25 | + | _dir: tempfile::TempDir, |
|
| 26 | + | } |
|
| 27 | + | ||
| 28 | + | async fn hub(mail: bool) -> Hub { |
|
| 29 | + | hermetic(); |
|
| 30 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 31 | + | let mailbox = dir.path().join("mailbox"); |
|
| 32 | + | std::fs::create_dir_all(&mailbox).unwrap(); |
|
| 33 | + | let mut config = Config::load(dir.path()).unwrap(); |
|
| 34 | + | config.server.base_url = "http://hub.test".into(); |
|
| 35 | + | config.registration.mode = "open".into(); |
|
| 36 | + | if mail { |
|
| 37 | + | config.email.smtp = format!("file://{}", mailbox.display()); |
|
| 38 | + | config.email.from = "OxygenHub <[email protected]>".into(); |
|
| 39 | + | } |
|
| 40 | + | let app = oxygenhub::build(config).await.unwrap(); |
|
| 41 | + | Hub { app, mailbox, _dir: dir } |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | struct Res { |
|
| 45 | + | status: StatusCode, |
|
| 46 | + | body: String, |
|
| 47 | + | cookie: Option<String>, |
|
| 48 | + | location: String, |
|
| 49 | + | } |
|
| 50 | + | ||
| 51 | + | async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, form: Option<&str>) -> Res { |
|
| 52 | + | let mut req = Request::builder().method(method).uri(path); |
|
| 53 | + | if let Some(c) = cookie { |
|
| 54 | + | req = req.header(header::COOKIE, format!("oxyhub={c}")); |
|
| 55 | + | } |
|
| 56 | + | let body = match form { |
|
| 57 | + | Some(f) => { |
|
| 58 | + | req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); |
|
| 59 | + | Body::from(f.to_string()) |
|
| 60 | + | } |
|
| 61 | + | None => Body::empty(), |
|
| 62 | + | }; |
|
| 63 | + | let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap(); |
|
| 64 | + | let status = res.status(); |
|
| 65 | + | let h = |k| res.headers().get(k).and_then(|v: &axum::http::HeaderValue| v.to_str().ok()).map(String::from); |
|
| 66 | + | let cookie = h(header::SET_COOKIE).and_then(|v| v.split(';').next().and_then(|kv| kv.split_once('=')).map(|(_, v)| v.to_string())); |
|
| 67 | + | let location = h(header::LOCATION).unwrap_or_default(); |
|
| 68 | + | let bytes = res.into_body().collect().await.unwrap().to_bytes(); |
|
| 69 | + | Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie, location } |
|
| 70 | + | } |
|
| 71 | + | ||
| 72 | + | async fn get(app: &Shared, path: &str, cookie: Option<&str>) -> Res { |
|
| 73 | + | send(app, "GET", path, cookie, None).await |
|
| 74 | + | } |
|
| 75 | + | ||
| 76 | + | async fn post(app: &Shared, path: &str, cookie: Option<&str>, form: &str) -> Res { |
|
| 77 | + | send(app, "POST", path, cookie, Some(form)).await |
|
| 78 | + | } |
|
| 79 | + | ||
| 80 | + | fn csrf_of(page: &str) -> String { |
|
| 81 | + | let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len(); |
|
| 82 | + | page[i..].split('"').next().unwrap().to_string() |
|
| 83 | + | } |
|
| 84 | + | ||
| 85 | + | /// Every message so far, oldest first, as text. |
|
| 86 | + | fn mail(dir: &Path) -> Vec<String> { |
|
| 87 | + | let mut names: Vec<_> = std::fs::read_dir(dir).unwrap().flatten().map(|e| e.path()).collect(); |
|
| 88 | + | names.sort(); |
|
| 89 | + | names.iter().map(|p| std::fs::read_to_string(p).unwrap()).collect() |
|
| 90 | + | } |
|
| 91 | + | ||
| 92 | + | /// Mail is sent in the background for resets; wait for `n` messages. |
|
| 93 | + | async fn mail_count(dir: &Path, n: usize) -> Vec<String> { |
|
| 94 | + | for _ in 0..100 { |
|
| 95 | + | let m = mail(dir); |
|
| 96 | + | if m.len() >= n { |
|
| 97 | + | return m; |
|
| 98 | + | } |
|
| 99 | + | tokio::time::sleep(std::time::Duration::from_millis(20)).await; |
|
| 100 | + | } |
|
| 101 | + | mail(dir) |
|
| 102 | + | } |
|
| 103 | + | ||
| 104 | + | /// The path of the first `http://hub.test/…` link in a message. |
|
| 105 | + | fn link(message: &str, prefix: &str) -> String { |
|
| 106 | + | let i = message.find(&format!("http://hub.test{prefix}")).expect("a link") + "http://hub.test".len(); |
|
| 107 | + | message[i..].split_whitespace().next().unwrap().to_string() |
|
| 108 | + | } |
|
| 109 | + | ||
| 110 | + | async fn account(app: &Shared, name: &str) -> db::accounts::Account { |
|
| 111 | + | db::accounts::account_by_name(&app.db, name).await.unwrap().unwrap() |
|
| 112 | + | } |
|
| 113 | + | ||
| 114 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 115 | + | async fn verification_and_reset() { |
|
| 116 | + | let h = hub(true).await; |
|
| 117 | + | let app = &h.app; |
|
| 118 | + | let r = post(app, "/register", None, "username=andy&email=andy%40example.com&password=correct-horse-battery").await; |
|
| 119 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 120 | + | let old_session = r.cookie.unwrap(); |
|
| 121 | + | ||
| 122 | + | // Registration sends a verification link; until it is used, no reset mail. |
|
| 123 | + | let m = mail(&h.mailbox); |
|
| 124 | + | assert_eq!(m.len(), 1); |
|
| 125 | + | assert!(m[0].contains("To: andy <[email protected]>") && m[0].contains("Subject: [OxygenHub] Confirm your email address"), "{}", m[0]); |
|
| 126 | + | let verify = link(&m[0], "/verify/"); |
|
| 127 | + | assert!(!account(app, "andy").await.email_verified); |
|
| 128 | + | let settings = get(app, "/settings", Some(&old_session)).await; |
|
| 129 | + | assert!(settings.body.contains("Not verified yet") && settings.body.contains("send a new one")); |
|
| 130 | + | let sent = post(app, "/login/reset", None, "who=andy").await; |
|
| 131 | + | assert!(sent.body.contains("Check your email")); |
|
| 132 | + | tokio::time::sleep(std::time::Duration::from_millis(200)).await; |
|
| 133 | + | assert_eq!(mail(&h.mailbox).len(), 1, "no reset to an unverified address"); |
|
| 134 | + | ||
| 135 | + | // The link verifies once. |
|
| 136 | + | assert!(get(app, &verify, None).await.body.contains("Email verified")); |
|
| 137 | + | assert!(account(app, "andy").await.email_verified); |
|
| 138 | + | assert!(get(app, &verify, None).await.body.contains("already used"), "single use"); |
|
| 139 | + | assert!(get(app, "/verify/00ff", None).await.body.contains("already used")); |
|
| 140 | + | ||
| 141 | + | // Reset by verified email (any case); a second request within two minutes sends nothing. |
|
| 142 | + | let unknown = post(app, "/login/reset", None, "who=nobody").await; |
|
| 143 | + | let known = post(app, "/login/reset", None, "who=ANDY%40example.com").await; |
|
| 144 | + | assert_eq!(unknown.body, known.body, "the answer does not reveal accounts"); |
|
| 145 | + | let m = mail_count(&h.mailbox, 2).await; |
|
| 146 | + | assert_eq!(m.len(), 2); |
|
| 147 | + | assert!(m[1].contains("Subject: [OxygenHub] Reset your password")); |
|
| 148 | + | let reset = link(&m[1], "/login/reset/"); |
|
| 149 | + | post(app, "/login/reset", None, "who=andy").await; |
|
| 150 | + | tokio::time::sleep(std::time::Duration::from_millis(200)).await; |
|
| 151 | + | assert_eq!(mail(&h.mailbox).len(), 2, "rate limited"); |
|
| 152 | + | ||
| 153 | + | // Choosing the new password: checks, then every session ends. |
|
| 154 | + | let form = get(app, &reset, None).await; |
|
| 155 | + | assert!(form.body.contains("Choose a new password") && form.body.contains("andy")); |
|
| 156 | + | let differ = post(app, &reset, None, "password=new-password-long-enough&confirm=other-password-long").await; |
|
| 157 | + | assert!(differ.body.contains("The two passwords differ")); |
|
| 158 | + | let weak = post(app, &reset, None, "password=short&confirm=short").await; |
|
| 159 | + | assert!(weak.body.contains("class=\"error\""), "password rules apply"); |
|
| 160 | + | let done = post(app, &reset, None, "password=new-password-long-enough&confirm=new-password-long-enough").await; |
|
| 161 | + | assert_eq!((done.status, done.location.as_str()), (StatusCode::SEE_OTHER, "/login?reset=1")); |
|
| 162 | + | assert!(get(app, "/login?reset=1", None).await.body.contains("Your password is set")); |
|
| 163 | + | assert!(get(app, &reset, None).await.body.contains("expired or was already used"), "single use"); |
|
| 164 | + | let stale = get(app, "/settings", Some(&old_session)).await; |
|
| 165 | + | assert!(stale.status.is_redirection() || !stale.body.contains("name=\"_csrf\""), "the old session ended"); |
|
| 166 | + | let wrong = post(app, "/login", None, "username=andy&password=correct-horse-battery").await; |
|
| 167 | + | assert!(wrong.body.contains("Wrong username or password")); |
|
| 168 | + | let signed = post(app, "/login", None, "username=andy&password=new-password-long-enough").await; |
|
| 169 | + | assert_eq!(signed.status, StatusCode::SEE_OTHER); |
|
| 170 | + | let session = signed.cookie.unwrap(); |
|
| 171 | + | let audit = db::accounts::audit_rows(&app.db, 50).await.unwrap(); |
|
| 172 | + | assert!(audit.iter().any(|a| a.action == "password.reset" && a.detail == "emailed link")); |
|
| 173 | + | ||
| 174 | + | // Changing the email unverifies it; only the link for the current address counts. |
|
| 175 | + | let page = get(app, "/settings", Some(&session)).await; |
|
| 176 | + | let csrf = csrf_of(&page.body); |
|
| 177 | + | let save = |email: &'static str| { |
|
| 178 | + | let csrf = csrf.clone(); |
|
| 179 | + | let session = session.clone(); |
|
| 180 | + | async move { |
|
| 181 | + | post(app, "/settings", Some(&session), &format!("_csrf={csrf}&display_name=Andy&email={email}&bio=&website=&location=")).await |
|
| 182 | + | } |
|
| 183 | + | }; |
|
| 184 | + | let saved = save("b%40example.com").await; |
|
| 185 | + | assert!(saved.body.contains("A verification link is on its way to [email protected]"), "{}", saved.body); |
|
| 186 | + | assert!(!account(app, "andy").await.email_verified); |
|
| 187 | + | let to_b = link(mail(&h.mailbox).last().unwrap(), "/verify/"); |
|
| 188 | + | save("c%40example.com").await; |
|
| 189 | + | let to_c = link(mail(&h.mailbox).last().unwrap(), "/verify/"); |
|
| 190 | + | assert!(get(app, &to_b, None).await.body.contains("no longer has"), "an old address's link does nothing"); |
|
| 191 | + | assert!(!account(app, "andy").await.email_verified); |
|
| 192 | + | assert!(get(app, &to_c, None).await.body.contains("Email verified")); |
|
| 193 | + | assert_eq!(account(app, "andy").await.email, "[email protected]"); |
|
| 194 | + | // A second link to the same address within two minutes is refused. |
|
| 195 | + | save("d%40example.com").await; |
|
| 196 | + | let again = post(app, "/settings/email/verify", Some(&session), &format!("_csrf={csrf}")).await; |
|
| 197 | + | assert!(again.body.contains("less than two minutes ago"), "{}", again.body); |
|
| 198 | + | ||
| 199 | + | // An administrator's link, for an account without a verified email. |
|
| 200 | + | post(app, "/register", None, "username=bob&email=&password=bob-password-long-enough").await; |
|
| 201 | + | let admin = get(app, "/admin", Some(&session)).await; |
|
| 202 | + | assert!(admin.body.contains("value=\"reset_link\"")); |
|
| 203 | + | let r = post(app, "/admin/account", Some(&session), &format!("_csrf={}&username=bob&action=reset_link", csrf_of(&admin.body))).await; |
|
| 204 | + | assert!(r.body.contains("Reset link for bob"), "{}", r.body); |
|
| 205 | + | let i = r.body.find("http://hub.test/login/reset/").unwrap() + "http://hub.test".len(); |
|
| 206 | + | let bob_link: String = r.body[i..].chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '/').collect(); |
|
| 207 | + | assert_eq!(post(app, &bob_link, None, "password=bob-new-password-ok&confirm=bob-new-password-ok").await.location, "/login?reset=1"); |
|
| 208 | + | let audit = db::accounts::audit_rows(&app.db, 50).await.unwrap(); |
|
| 209 | + | assert!(audit.iter().any(|a| a.action == "admin.account.reset_link" && a.target == "bob")); |
|
| 210 | + | assert!(audit.iter().any(|a| a.action == "password.reset" && a.target == "bob" && a.detail == "administrator's link")); |
|
| 211 | + | } |
|
| 212 | + | ||
| 213 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 214 | + | async fn without_mail_an_administrator_resets() { |
|
| 215 | + | let h = hub(false).await; |
|
| 216 | + | let page = get(&h.app, "/login/reset", None).await; |
|
| 217 | + | assert!(page.body.contains("does not send email") && !page.body.contains("SEND A RESET LINK")); |
|
| 218 | + | let sent = post(&h.app, "/login/reset", None, "who=andy").await; |
|
| 219 | + | assert!(!sent.body.contains("Check your email")); |
|
| 220 | + | assert!(get(&h.app, "/login", None).await.body.contains("href=\"/login/reset\"")); |
|
| 221 | + | } |
added templates/reset.html +43 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}{% if mode == "verified" || mode == "verify-invalid" %}Email address{% else %}Reset your password{% endif %}{% endblock %} |
|
| 3 | + | {% block content %} |
|
| 4 | + | {% if mode == "verified" %} |
|
| 5 | + | <div class="head"><h1>Email verified</h1></div> |
|
| 6 | + | <p class="note"><strong>{{ username }}</strong> is confirmed. Notification email can go there now.</p> |
|
| 7 | + | <p><a href="/settings">Settings</a></p> |
|
| 8 | + | {% else if mode == "verify-invalid" %} |
|
| 9 | + | <div class="head"><h1>Email address</h1></div> |
|
| 10 | + | <p class="error">This verification link has expired, was already used, or is for an address the account no longer has.</p> |
|
| 11 | + | <p>Send a new one from <a href="/settings">Settings</a>.</p> |
|
| 12 | + | {% else if mode == "request" %} |
|
| 13 | + | <div class="head"><h1>Reset your password</h1></div> |
|
| 14 | + | {% if mail %} |
|
| 15 | + | <form class="card" method="post" action="/login/reset"> |
|
| 16 | + | <label for="who">Username or verified email</label><input type="text" id="who" name="who" autocomplete="username" autofocus required> |
|
| 17 | + | <button type="submit">SEND A RESET LINK</button> |
|
| 18 | + | </form> |
|
| 19 | + | <p class="small mute">The link goes to the account's verified email and works once, for one hour.</p> |
|
| 20 | + | {% else %} |
|
| 21 | + | <p>This hub does not send email. Ask an administrator for a reset link: they can issue one from the admin page, valid for 24 hours.</p> |
|
| 22 | + | {% endif %} |
|
| 23 | + | <p class="small mute"><a href="/login">Back to sign in</a></p> |
|
| 24 | + | {% else if mode == "sent" %} |
|
| 25 | + | <div class="head"><h1>Check your email</h1></div> |
|
| 26 | + | <p>If that matches an account with a verified email, a reset link is on its way. It works once, for one hour.</p> |
|
| 27 | + | <p class="small mute">Nothing arriving? The account may have no verified email; an administrator can issue a reset link instead.</p> |
|
| 28 | + | {% else if mode == "new" %} |
|
| 29 | + | <div class="head"><h1>Choose a new password <small>{{ username }}</small></h1></div> |
|
| 30 | + | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 31 | + | <form class="card" method="post" action="/login/reset/{{ secret }}"> |
|
| 32 | + | <input type="text" name="username" value="{{ username }}" autocomplete="username" hidden> |
|
| 33 | + | <label for="password">New password</label><input type="password" id="password" name="password" autocomplete="new-password" required autofocus> |
|
| 34 | + | <label for="confirm">The same again</label><input type="password" id="confirm" name="confirm" autocomplete="new-password" required> |
|
| 35 | + | <button type="submit">SET PASSWORD</button> |
|
| 36 | + | </form> |
|
| 37 | + | <p class="small mute">Every session of this account is signed out. Two-factor sign-in stays on if you use it.</p> |
|
| 38 | + | {% else %} |
|
| 39 | + | <div class="head"><h1>Reset your password</h1></div> |
|
| 40 | + | <p class="error">This reset link has expired or was already used.</p> |
|
| 41 | + | <p><a href="/login/reset">Ask for a new one</a></p> |
|
| 42 | + | {% endif %} |
|
| 43 | + | {% endblock %} |
modified crates/hub/src/db/accounts.rs +125 −6
| … | 153 unchanged lines |
||
| 154 | 154 | } |
|
| 155 | 155 | ||
| 156 | 156 | pub async fn set_profile(pool: &Pool, id: i64, display_name: &str, email: &str) -> sqlx::Result<()> { |
|
| 157 | - | sqlx::query("UPDATE accounts SET display_name = ?, email = ? WHERE id = ?") |
|
| 158 | - | .bind(display_name) |
|
| 159 | - | .bind(email) |
|
| 160 | - | .bind(id) |
|
| 161 | - | .execute(pool) |
|
| 162 | - | .await?; |
|
| 157 | + | // A changed address is unverified until its own link is used. |
|
| 158 | + | sqlx::query( |
|
| 159 | + | "UPDATE accounts SET display_name = ?, email_verified = CASE WHEN email = ? THEN email_verified ELSE 0 END, email = ? WHERE id = ?", |
|
| 160 | + | ) |
|
| 161 | + | .bind(display_name) |
|
| 162 | + | .bind(email) |
|
| 163 | + | .bind(email) |
|
| 164 | + | .bind(id) |
|
| 165 | + | .execute(pool) |
|
| 166 | + | .await?; |
|
| 163 | 167 | Ok(()) |
|
| 164 | 168 | } |
|
| 165 | 169 | ||
| … | 423 unchanged lines |
||
| 589 | 593 | .bind(account_id) |
|
| 590 | 594 | .fetch_all(pool) |
|
| 591 | 595 | .await |
|
| 596 | + | } |
|
| 597 | + | ||
| 598 | + | // ------------------------------------------------------ account links |
|
| 599 | + | ||
| 600 | + | /// A verification or reset link (hub spec §5.1.1). |
|
| 601 | + | #[derive(Clone, Debug, FromRow)] |
|
| 602 | + | pub struct AccountLink { |
|
| 603 | + | pub id: i64, |
|
| 604 | + | pub account_id: i64, |
|
| 605 | + | pub kind: String, |
|
| 606 | + | pub hash: String, |
|
| 607 | + | pub email: String, |
|
| 608 | + | pub created_by: Option<i64>, |
|
| 609 | + | pub created_ms: i64, |
|
| 610 | + | pub expires_ms: i64, |
|
| 611 | + | pub used_ms: Option<i64>, |
|
| 612 | + | } |
|
| 613 | + | ||
| 614 | + | /// Make a link; returns its secret (shown or sent once, stored as a hash). |
|
| 615 | + | pub async fn create_link( |
|
| 616 | + | pool: &Pool, |
|
| 617 | + | account_id: i64, |
|
| 618 | + | kind: &str, |
|
| 619 | + | email: &str, |
|
| 620 | + | created_by: Option<i64>, |
|
| 621 | + | ttl_ms: i64, |
|
| 622 | + | ) -> sqlx::Result<String> { |
|
| 623 | + | let secret = crate::auth::random_hex(32); |
|
| 624 | + | let now = now_ms(); |
|
| 625 | + | sqlx::query( |
|
| 626 | + | "INSERT INTO account_links (account_id, kind, hash, email, created_by, created_ms, expires_ms) VALUES (?, ?, ?, ?, ?, ?, ?)", |
|
| 627 | + | ) |
|
| 628 | + | .bind(account_id) |
|
| 629 | + | .bind(kind) |
|
| 630 | + | .bind(crate::auth::token_hash(&secret)) |
|
| 631 | + | .bind(email) |
|
| 632 | + | .bind(created_by) |
|
| 633 | + | .bind(now) |
|
| 634 | + | .bind(now + ttl_ms) |
|
| 635 | + | .execute(pool) |
|
| 636 | + | .await?; |
|
| 637 | + | Ok(secret) |
|
| 638 | + | } |
|
| 639 | + | ||
| 640 | + | /// An unused, unexpired link of this kind with this secret. |
|
| 641 | + | pub async fn live_link(pool: &Pool, kind: &str, secret: &str) -> sqlx::Result<Option<AccountLink>> { |
|
| 642 | + | sqlx::query_as("SELECT * FROM account_links WHERE kind = ? AND hash = ? AND used_ms IS NULL AND expires_ms > ?") |
|
| 643 | + | .bind(kind) |
|
| 644 | + | .bind(crate::auth::token_hash(secret)) |
|
| 645 | + | .bind(now_ms()) |
|
| 646 | + | .fetch_optional(pool) |
|
| 647 | + | .await |
|
| 648 | + | } |
|
| 649 | + | ||
| 650 | + | /// Consume a link; false when it was already used (a race lost). |
|
| 651 | + | pub async fn use_link(pool: &Pool, id: i64) -> sqlx::Result<bool> { |
|
| 652 | + | let r = |
|
| 653 | + | sqlx::query("UPDATE account_links SET used_ms = ? WHERE id = ? AND used_ms IS NULL").bind(now_ms()).bind(id).execute(pool).await?; |
|
| 654 | + | Ok(r.rows_affected() == 1) |
|
| 655 | + | } |
|
| 656 | + | ||
| 657 | + | /// Void every unused link of a kind for an account. |
|
| 658 | + | pub async fn void_links(pool: &Pool, account_id: i64, kind: &str) -> sqlx::Result<()> { |
|
| 659 | + | sqlx::query("UPDATE account_links SET used_ms = ? WHERE account_id = ? AND kind = ? AND used_ms IS NULL") |
|
| 660 | + | .bind(now_ms()) |
|
| 661 | + | .bind(account_id) |
|
| 662 | + | .bind(kind) |
|
| 663 | + | .execute(pool) |
|
| 664 | + | .await?; |
|
| 665 | + | Ok(()) |
|
| 666 | + | } |
|
| 667 | + | ||
| 668 | + | /// When the account's newest link of a kind (for this address, when given) was made, for rate limits. |
|
| 669 | + | pub async fn last_link_ms(pool: &Pool, account_id: i64, kind: &str, email: Option<&str>) -> sqlx::Result<Option<i64>> { |
|
| 670 | + | match email { |
|
| 671 | + | Some(e) => { |
|
| 672 | + | sqlx::query_scalar("SELECT MAX(created_ms) FROM account_links WHERE account_id = ? AND kind = ? AND email = ?") |
|
| 673 | + | .bind(account_id) |
|
| 674 | + | .bind(kind) |
|
| 675 | + | .bind(e) |
|
| 676 | + | .fetch_one(pool) |
|
| 677 | + | .await |
|
| 678 | + | } |
|
| 679 | + | None => { |
|
| 680 | + | sqlx::query_scalar("SELECT MAX(created_ms) FROM account_links WHERE account_id = ? AND kind = ?") |
|
| 681 | + | .bind(account_id) |
|
| 682 | + | .bind(kind) |
|
| 683 | + | .fetch_one(pool) |
|
| 684 | + | .await |
|
| 685 | + | } |
|
| 686 | + | } |
|
| 687 | + | } |
|
| 688 | + | ||
| 689 | + | /// How many links of a kind the account got since `since_ms`. |
|
| 690 | + | pub async fn links_since(pool: &Pool, account_id: i64, kind: &str, since_ms: i64) -> sqlx::Result<i64> { |
|
| 691 | + | sqlx::query_scalar("SELECT COUNT(*) FROM account_links WHERE account_id = ? AND kind = ? AND created_ms >= ?") |
|
| 692 | + | .bind(account_id) |
|
| 693 | + | .bind(kind) |
|
| 694 | + | .bind(since_ms) |
|
| 695 | + | .fetch_one(pool) |
|
| 696 | + | .await |
|
| 697 | + | } |
|
| 698 | + | ||
| 699 | + | pub async fn set_email_verified(pool: &Pool, id: i64, verified: bool) -> sqlx::Result<()> { |
|
| 700 | + | sqlx::query("UPDATE accounts SET email_verified = ? WHERE id = ?").bind(verified).bind(id).execute(pool).await?; |
|
| 701 | + | Ok(()) |
|
| 702 | + | } |
|
| 703 | + | ||
| 704 | + | /// The account with this verified email (case-insensitive), if exactly one. |
|
| 705 | + | pub async fn account_by_verified_email(pool: &Pool, email: &str) -> sqlx::Result<Option<Account>> { |
|
| 706 | + | let found: Vec<Account> = sqlx::query_as("SELECT * FROM accounts WHERE email_verified = 1 AND lower(email) = lower(?) LIMIT 2") |
|
| 707 | + | .bind(email) |
|
| 708 | + | .fetch_all(pool) |
|
| 709 | + | .await?; |
|
| 710 | + | Ok(if found.len() == 1 { found.into_iter().next() } else { None }) |
|
| 592 | 711 | } |
modified crates/hub/src/lib.rs +1 −0
| … | 10 unchanged lines |
||
| 11 | 11 | pub mod flow; |
|
| 12 | 12 | pub mod hooks; |
|
| 13 | 13 | pub mod keys; |
|
| 14 | + | pub mod mail; |
|
| 14 | 15 | pub mod mirror; |
|
| 15 | 16 | pub mod notify; |
|
| 16 | 17 | pub mod protocol; |
|
| … | 82 unchanged lines |
modified crates/hub/src/notify/mod.rs +3 −27
| … | 54 unchanged lines |
||
| 55 | 55 | for id in targets { |
|
| 56 | 56 | ndb::add(pool, id, ev.kind, &ev.title, &ev.url, &ev.actor.username).await.ok(); |
|
| 57 | 57 | if let Ok(Some(a)) = db::accounts::account_by_id(pool, id).await { |
|
| 58 | - | if a.email_notifications && !a.email.is_empty() { |
|
| 58 | + | // Only to addresses the account has proven it reads (hub spec §5.1.1). |
|
| 59 | + | if a.email_notifications && a.email_verified && !a.email.is_empty() && crate::mail::enabled(app) { |
|
| 59 | 60 | email(app, &a, &ev.title, &ev.url, &ev.actor.username).await; |
|
| 60 | 61 | } |
|
| 61 | 62 | } |
|
| … | 1 unchanged lines |
||
| 63 | 64 | } |
|
| 64 | 65 | ||
| 65 | 66 | async fn email(app: &Shared, to: &Account, title: &str, url: &str, actor: &str) { |
|
| 66 | - | let cfg = &app.config.email; |
|
| 67 | - | if cfg.smtp.is_empty() || cfg.from.is_empty() { |
|
| 68 | - | return; |
|
| 69 | - | } |
|
| 70 | - | use lettre::message::header::ContentType; |
|
| 71 | - | use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; |
|
| 72 | 67 | let unsubscribe = format!("{}/settings/notifications?unsubscribe={}", app.config.base_url(), to.unsubscribe_key); |
|
| 73 | 68 | let body = format!("{actor}: {title}\n\n{}{url}\n\nStop these emails: {unsubscribe}\n", app.config.base_url()); |
|
| 74 | - | let Ok(message) = Message::builder() |
|
| 75 | - | .from(cfg.from.parse().unwrap_or_else(|_| "hub@localhost".parse().unwrap())) |
|
| 76 | - | .to(match format!("{} <{}>", to.name(), to.email).parse() { |
|
| 77 | - | Ok(m) => m, |
|
| 78 | - | Err(_) => return, |
|
| 79 | - | }) |
|
| 80 | - | .subject(format!("[{}] {title}", app.config.site.name)) |
|
| 81 | - | .header(ContentType::TEXT_PLAIN) |
|
| 82 | - | .body(body) |
|
| 83 | - | else { |
|
| 84 | - | return; |
|
| 85 | - | }; |
|
| 86 | - | let transport = match AsyncSmtpTransport::<Tokio1Executor>::from_url(&cfg.smtp) { |
|
| 87 | - | Ok(t) => t.build(), |
|
| 88 | - | Err(e) => { |
|
| 89 | - | tracing::warn!("smtp url: {e}"); |
|
| 90 | - | return; |
|
| 91 | - | } |
|
| 92 | - | }; |
|
| 93 | - | if let Err(e) = transport.send(message).await { |
|
| 69 | + | if let Err(e) = crate::mail::send(app, to.name(), &to.email, title, &body).await { |
|
| 94 | 70 | tracing::warn!("email to {}: {e}", to.email); |
|
| 95 | 71 | } |
|
| 96 | 72 | } |
|
| … | 11 unchanged lines |
modified crates/hub/src/web/admin.rs +12 −0
| … | 94 unchanged lines |
||
| 95 | 95 | return admin_page(&app, shell, Some("Not on yourself.".into())).await; |
|
| 96 | 96 | } |
|
| 97 | 97 | let e = |e| Error::from_db(shell.clone(), e); |
|
| 98 | + | if f.action == "reset_link" { |
|
| 99 | + | if target.banned { |
|
| 100 | + | return admin_page(&app, shell, Some("A banned account cannot be reset.".into())).await; |
|
| 101 | + | } |
|
| 102 | + | let url = super::recovery::admin_reset_link(&app, &me, &target).await.map_err(e)?; |
|
| 103 | + | return admin_page( |
|
| 104 | + | &app, |
|
| 105 | + | shell, |
|
| 106 | + | Some(format!("Reset link for {} (works once, for 24 hours; shown only now): {url}", target.username)), |
|
| 107 | + | ) |
|
| 108 | + | .await; |
|
| 109 | + | } |
|
| 98 | 110 | match f.action.as_str() { |
|
| 99 | 111 | "ban" => accounts::set_banned(&app.db, target.id, true).await.map_err(e)?, |
|
| 100 | 112 | "unban" => accounts::set_banned(&app.db, target.id, false).await.map_err(e)?, |
|
| … | 7 unchanged lines |
modified crates/hub/src/web/mod.rs +5 −0
| … | 15 unchanged lines |
||
| 16 | 16 | pub mod issues; |
|
| 17 | 17 | mod orgs; |
|
| 18 | 18 | pub mod proposals; |
|
| 19 | + | mod recovery; |
|
| 19 | 20 | mod refs; |
|
| 20 | 21 | pub mod releases; |
|
| 21 | 22 | mod repo; |
|
| … | 66 unchanged lines |
||
| 88 | 89 | .route("/{owner}/{name}/changes/{r}", get(history::history)) |
|
| 89 | 90 | .route("/{owner}/{name}/change/{hash}", get(history::change)) |
|
| 90 | 91 | .route("/help", get(help::help)) |
|
| 92 | + | .route("/login/reset", get(recovery::request_form).post(recovery::request)) |
|
| 93 | + | .route("/login/reset/{secret}", get(recovery::new_password_form).post(recovery::set_new_password)) |
|
| 94 | + | .route("/verify/{secret}", get(recovery::verify)) |
|
| 95 | + | .route("/settings/email/verify", post(settings::resend_verification)) |
|
| 91 | 96 | .route("/{owner}/{name}/compare", get(history::compare_index)) |
|
| 92 | 97 | .route("/{owner}/{name}/compare/{spec}", get(history::compare)) |
|
| 93 | 98 | .route("/{owner}/{name}/bookmarks", get(refs::bookmarks)) |
|
| … | 466 unchanged lines |
modified crates/hub/src/web/session.rs +10 −2
| … | 17 unchanged lines |
||
| 18 | 18 | next: String, |
|
| 19 | 19 | /// Second step: the password was right, a one-time code is needed. |
|
| 20 | 20 | totp_for: Option<String>, |
|
| 21 | + | /// Arrived from a password reset. |
|
| 22 | + | reset: bool, |
|
| 21 | 23 | } |
|
| 22 | 24 | ||
| 23 | 25 | #[derive(Deserialize)] |
|
| 24 | 26 | pub struct NextQuery { |
|
| 25 | 27 | next: Option<String>, |
|
| 28 | + | reset: Option<String>, |
|
| 26 | 29 | } |
|
| 27 | 30 | ||
| 28 | 31 | fn safe_next(next: Option<String>) -> String { |
|
| … | 7 unchanged lines |
||
| 36 | 39 | if viewer.signed_in() { |
|
| 37 | 40 | return Redirect::to(&safe_next(q.next)).into_response(); |
|
| 38 | 41 | } |
|
| 39 | - | Page(LoginPage { shell: Shell::new(&app, "/login", &viewer), error: None, next: safe_next(q.next), totp_for: None }).into_response() |
|
| 42 | + | let reset = q.reset.is_some(); |
|
| 43 | + | Page(LoginPage { shell: Shell::new(&app, "/login", &viewer), error: None, next: safe_next(q.next), totp_for: None, reset }) |
|
| 44 | + | .into_response() |
|
| 40 | 45 | } |
|
| 41 | 46 | ||
| 42 | 47 | #[derive(Deserialize)] |
|
| … | 10 unchanged lines |
||
| 53 | 58 | let shell = Shell::new(&app, "/login", &viewer); |
|
| 54 | 59 | let next = safe_next(Some(f.next.clone())); |
|
| 55 | 60 | let fail = |msg: &str, totp_for: Option<String>| { |
|
| 56 | - | Page(LoginPage { shell: shell.clone(), error: Some(msg.into()), next: next.clone(), totp_for }).into_response() |
|
| 61 | + | Page(LoginPage { shell: shell.clone(), error: Some(msg.into()), next: next.clone(), totp_for, reset: false }).into_response() |
|
| 57 | 62 | }; |
|
| 58 | 63 | let username = f.username.trim().to_string(); |
|
| 59 | 64 | let account = accounts::account_by_name(&app.db, &username).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| … | 130 unchanged lines |
||
| 190 | 195 | accounts::use_invitation(&app.db, f.invite.trim(), account.id).await.ok(); |
|
| 191 | 196 | } |
|
| 192 | 197 | accounts::audit(&app.db, Some(&account), "register", &username, if first { "first account: administrator" } else { "" }).await.ok(); |
|
| 198 | + | if let Err(e) = super::recovery::send_verification(&app, &account).await { |
|
| 199 | + | tracing::info!("verification for {username}: {e}"); |
|
| 200 | + | } |
|
| 193 | 201 | Ok(signed_in(&app, &account, "/")) |
|
| 194 | 202 | } |
modified crates/hub/src/web/settings.rs +44 −6
| … | 45 unchanged lines |
||
| 46 | 46 | /// The theme cookie's value, for the Appearance form. |
|
| 47 | 47 | theme: &'static str, |
|
| 48 | 48 | themes: &'static [(&'static str, &'static str)], |
|
| 49 | + | /// Whether the hub sends mail, for the "send a new link" button. |
|
| 50 | + | mail: bool, |
|
| 49 | 51 | } |
|
| 50 | 52 | ||
| 51 | 53 | /// The themes (hub spec §6.1): the cookie value and its label. |
|
| … | 7 unchanged lines |
||
| 59 | 61 | ||
| 60 | 62 | impl SettingsPage { |
|
| 61 | 63 | fn new(shell: Shell, me: accounts::Account, headers: &HeaderMap) -> SettingsPage { |
|
| 62 | - | SettingsPage { shell, me, notice: None, error: None, theme: theme_of(headers), themes: THEMES } |
|
| 64 | + | SettingsPage { shell, me, notice: None, error: None, theme: theme_of(headers), themes: THEMES, mail: false } |
|
| 63 | 65 | } |
|
| 64 | 66 | ||
| 67 | + | fn with_mail(mut self, app: &Shared) -> Self { |
|
| 68 | + | self.mail = crate::mail::enabled(app); |
|
| 69 | + | self |
|
| 70 | + | } |
|
| 71 | + | ||
| 65 | 72 | fn notice(mut self, n: impl Into<String>) -> Self { |
|
| 66 | 73 | self.notice = Some(n.into()); |
|
| 67 | 74 | self |
|
| … | 8 unchanged lines |
||
| 76 | 83 | pub async fn profile(State(app): State<Shared>, viewer: Viewer, headers: HeaderMap) -> Result<Page<SettingsPage>, Error> { |
|
| 77 | 84 | let shell = Shell::new(&app, "/settings", &viewer); |
|
| 78 | 85 | let me = require(&viewer, &shell)?; |
|
| 79 | - | Ok(Page(SettingsPage::new(shell, me.account, &headers))) |
|
| 86 | + | Ok(Page(SettingsPage::new(shell, me.account, &headers).with_mail(&app))) |
|
| 87 | + | } |
|
| 88 | + | ||
| 89 | + | /// `POST /settings/email/verify`: send a new verification link. |
|
| 90 | + | pub async fn resend_verification( |
|
| 91 | + | State(app): State<Shared>, |
|
| 92 | + | viewer: Viewer, |
|
| 93 | + | headers: HeaderMap, |
|
| 94 | + | Form(f): Form<super::session::CsrfForm>, |
|
| 95 | + | ) -> Result<Page<SettingsPage>, Error> { |
|
| 96 | + | let shell = Shell::new(&app, "/settings", &viewer); |
|
| 97 | + | let me = require(&viewer, &shell)?; |
|
| 98 | + | csrf(&viewer, &f.csrf, &shell)?; |
|
| 99 | + | let page = SettingsPage::new(shell, me.account.clone(), &headers).with_mail(&app); |
|
| 100 | + | Ok(Page(match super::recovery::send_verification(&app, &me.account).await { |
|
| 101 | + | Ok(()) if me.account.email_verified => page.notice("Your email is already verified."), |
|
| 102 | + | Ok(()) => page.notice(format!("A verification link is on its way to {}.", me.account.email)), |
|
| 103 | + | Err(e) => page.error(e), |
|
| 104 | + | })) |
|
| 80 | 105 | } |
|
| 81 | 106 | ||
| 82 | 107 | #[derive(Deserialize)] |
|
| … | 46 unchanged lines |
||
| 129 | 154 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 130 | 155 | let website = f.website.trim(); |
|
| 131 | 156 | if !(website.is_empty() || website.starts_with("https://") || website.starts_with("http://")) || website.len() > 200 { |
|
| 132 | - | return Ok(Page(SettingsPage::new(shell, me.account, &headers).error("The website must be an http:// or https:// address."))); |
|
| 157 | + | return Ok(Page( |
|
| 158 | + | SettingsPage::new(shell, me.account, &headers).with_mail(&app).error("The website must be an http:// or https:// address."), |
|
| 159 | + | )); |
|
| 133 | 160 | } |
|
| 134 | 161 | let bio: String = f.bio.trim().chars().take(280).collect(); |
|
| 135 | 162 | let location: String = f.location.trim().chars().take(80).collect(); |
|
| … | 1 unchanged lines |
||
| 137 | 164 | .await |
|
| 138 | 165 | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 139 | 166 | accounts::set_bio(&app.db, me.account.id, &bio, website, &location).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 167 | + | let changed = me.account.email != f.email.trim(); |
|
| 140 | 168 | let updated = |
|
| 141 | 169 | accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account); |
|
| 142 | - | Ok(Page(SettingsPage::new(shell, updated, &headers).notice("Profile saved."))) |
|
| 170 | + | let mut notice = "Profile saved.".to_string(); |
|
| 171 | + | if changed && !updated.email.is_empty() { |
|
| 172 | + | accounts::audit(&app.db, Some(&updated), "email.changed", &updated.username, "").await.ok(); |
|
| 173 | + | match super::recovery::send_verification(&app, &updated).await { |
|
| 174 | + | Ok(()) => notice = format!("Profile saved. A verification link is on its way to {}.", updated.email), |
|
| 175 | + | Err(e) => notice = format!("Profile saved. {e}"), |
|
| 176 | + | } |
|
| 177 | + | } |
|
| 178 | + | Ok(Page(SettingsPage::new(shell, updated, &headers).with_mail(&app).notice(notice))) |
|
| 143 | 179 | } |
|
| 144 | 180 | ||
| 145 | 181 | #[derive(Deserialize)] |
|
| … | 14 unchanged lines |
||
| 160 | 196 | let me = require(&viewer, &shell)?; |
|
| 161 | 197 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 162 | 198 | if !auth::verify_password(&me.account.password_hash, &f.current) { |
|
| 163 | - | return Ok(Page(SettingsPage::new(shell, me.account, &headers).error("Your current password is wrong.")).into_response()); |
|
| 199 | + | return Ok( |
|
| 200 | + | Page(SettingsPage::new(shell, me.account, &headers).with_mail(&app).error("Your current password is wrong.")).into_response() |
|
| 201 | + | ); |
|
| 164 | 202 | } |
|
| 165 | 203 | if let Err(e) = auth::password_ok(&f.password) { |
|
| 166 | - | return Ok(Page(SettingsPage::new(shell, me.account, &headers).error(e)).into_response()); |
|
| 204 | + | return Ok(Page(SettingsPage::new(shell, me.account, &headers).with_mail(&app).error(e)).into_response()); |
|
| 167 | 205 | } |
|
| 168 | 206 | let hash = auth::hash_password(&f.password).map_err(|e| Error::bad(shell.clone(), e.to_string()))?; |
|
| 169 | 207 | // A new session salt signs out every other session; this one is renewed. |
|
| … | 351 unchanged lines |
modified docs/spec.md +10 −0
| … | 161 unchanged lines |
||
| 162 | 162 | ||
| 163 | 163 | Sessions are a signed cookie `oxyhub=<username>.<mac>` where `mac = HMAC-SHA256(cookie_key, "session:" || username || ":" || session_salt)`, `HttpOnly`, `Secure` when served over HTTPS, `SameSite=Lax`. Password changes and "sign out everywhere" rotate the salt. Passkeys (WebAuthn) are planned for 1.0 as a second factor or password replacement. |
|
| 164 | 164 | ||
| 165 | + | #### 5.1.1 Email verification and password reset |
|
| 166 | + | ||
| 167 | + | Both use **account links**: 32 random bytes as hex in a URL, stored only as their BLAKE3 hash, bound to one account and one purpose, expiring, and consumed on first use. |
|
| 168 | + | ||
| 169 | + | - **Verification.** Setting or changing an email marks it unverified. When the hub can send mail it sends a link to the new address (valid 48 hours). The link verifies that exact address: one sent to an address the account has since replaced does nothing. The settings page shows whether the email is verified and can send a new link: at most one every two minutes for the same address, and ten an hour per account whatever the addresses, so the form cannot be used to mail other people. Notification email goes only to verified addresses. |
|
| 170 | + | - **Reset by email.** `/login/reset` takes a username or a verified email. When it matches an active account with a verified email and the hub can send mail, a link valid for one hour goes to that address, at most one every two minutes per account. The page answers the same way whether or not anything matched, so it cannot be used to find accounts. |
|
| 171 | + | - **Reset by an administrator.** When the hub cannot send mail, or the account has no verified email, an administrator issues a reset link from the admin page (valid 24 hours), shown once to hand over by other means. The audit log records who issued it. |
|
| 172 | + | - **Using a reset link** sets a new password (the same rules as registration), rotates the session salt so every existing session ends, voids the account's other reset links, and records `password.reset` in the audit log. It does not turn off TOTP: a reset account with two-factor sign-in still needs its code. A banned account cannot be reset. |
|
| 173 | + | - **Mail.** `[email] smtp` is an `smtp://` or `smtps://` URL, or `file:///path` for development, which writes each message to that directory as a file instead of sending it. |
|
| 174 | + | ||
| 165 | 175 | ### 5.2 Organisations and teams |
|
| 166 | 176 | ||
| 167 | 177 | An organisation is an owner with members and teams. Roles on an organisation: `owner`, `member`. Roles on a repository, whether personal or organisational: `read`, `triage` (manage issues and proposals without writing), `write`, `maintain` (settings except deletion and secrets), `admin`. Teams hold members and receive repository roles as a unit. An organisation's default repository role for members is configurable. |
|
| … | 388 unchanged lines |
modified templates/admin.html +3 −2
| … | 8 unchanged lines |
||
| 9 | 9 | <h2>Accounts</h2> |
|
| 10 | 10 | <table class="small"><tr><th>Username</th><th>Email</th><th>Flags</th><th>Last seen</th><th></th></tr> |
|
| 11 | 11 | {% for a in accounts %} |
|
| 12 | - | <tr><td><a href="/{{ a.username }}">{{ a.username }}</a></td><td>{{ a.email }}</td><td>{% if a.is_admin %}<span class="tag">admin</span>{% endif %}{% if a.banned %} <span class="tag bad">banned</span>{% endif %}{% if a.totp_enabled() %} <span class="tag">2fa</span>{% endif %}</td> |
|
| 12 | + | <tr><td><a href="/{{ a.username }}">{{ a.username }}</a></td><td>{{ a.email }}{% if !a.email.is_empty() %} <span class="small mute" title="{% if a.email_verified %}verified{% else %}not verified{% endif %}">{% if a.email_verified %}✔{% else %}○{% endif %}</span>{% endif %}</td><td>{% if a.is_admin %}<span class="tag">admin</span>{% endif %}{% if a.banned %} <span class="tag bad">banned</span>{% endif %}{% if a.totp_enabled() %} <span class="tag">2fa</span>{% endif %}</td> |
|
| 13 | 13 | <td>{% if a.last_seen_ms > 0 %}<time title="{{ a.last_seen_ms|when_i }}">{{ a.last_seen_ms|ago_i }}</time>{% else %}<span class="mute">never</span>{% endif %}</td> |
|
| 14 | 14 | <td><form class="tiny" method="post" action="/admin/account"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="username" value="{{ a.username }}"> |
|
| 15 | 15 | {% if a.banned %}<button name="action" value="unban">unban</button>{% else %}<button name="action" value="ban">ban</button>{% endif %} |
|
| 16 | - | {% if a.is_admin %}<button name="action" value="demote">demote</button>{% else %}<button name="action" value="promote">make admin</button>{% endif %}</form></td></tr> |
|
| 16 | + | {% if a.is_admin %}<button name="action" value="demote">demote</button>{% else %}<button name="action" value="promote">make admin</button>{% endif %} |
|
| 17 | + | {% if !a.banned %}<button name="action" value="reset_link" title="A one-time password reset link to hand over">reset link</button>{% endif %}</form></td></tr> |
|
| 17 | 18 | {% endfor %} |
|
| 18 | 19 | </table> |
|
| 19 | 20 | </section> |
|
| … | 22 unchanged lines |
modified templates/login.html +2 −1
| … | 1 unchanged lines |
||
| 2 | 2 | {% block title %}Sign in{% endblock %} |
|
| 3 | 3 | {% block content %} |
|
| 4 | 4 | <div class="head"><h1>Sign in <small>{{ shell.site }}</small></h1></div> |
|
| 5 | + | {% if reset %}<p class="note">Your password is set. Sign in with it; every other session was signed out.</p>{% endif %} |
|
| 5 | 6 | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 6 | 7 | <form class="card" method="post" action="/login"> |
|
| 7 | 8 | <input type="hidden" name="next" value="{{ next }}"> |
|
| … | 9 unchanged lines |
||
| 17 | 18 | {% endif %} |
|
| 18 | 19 | <button type="submit">SIGN IN</button> |
|
| 19 | 20 | </form> |
|
| 20 | - | <p class="small mute">No account? <a href="/register">Register</a>.</p> |
|
| 21 | + | <p class="small mute"><a href="/login/reset">Forgot your password?</a> · No account? <a href="/register">Register</a>.</p> |
|
| 21 | 22 | {% endblock %} |
modified templates/settings.html +2 −1
| … | 12 unchanged lines |
||
| 13 | 13 | <a href="#password">Password</a> |
|
| 14 | 14 | </nav> |
|
| 15 | 15 | <div class="sections"> |
|
| 16 | + | <form id="verify-email" method="post" action="/settings/email/verify" hidden><input type="hidden" name="_csrf" value="{{ shell.csrf }}"></form> |
|
| 16 | 17 | <form class="card" id="profile" method="post" action="/settings"> |
|
| 17 | 18 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 18 | 19 | <label for="display_name">Display name</label><input type="text" id="display_name" name="display_name" value="{{ me.display_name }}"> |
|
| 19 | 20 | <label for="email">Email</label><input type="email" id="email" name="email" value="{{ me.email }}"> |
|
| 20 | - | <p class="small mute">Your email is never shown on your profile.</p> |
|
| 21 | + | <p class="small mute">Your email is never shown on your profile. {% if me.email.is_empty() %}Add one to receive notification email and reset your password by email.{% else if me.email_verified %}<span class="sig sig-valid">✔</span> Verified.{% else %}<span class="sig sig-unsigned">○</span> Not verified yet{% if mail %}: use the link we sent, or <button type="submit" form="verify-email" class="linkish">send a new one</button>{% endif %}.{% endif %}</p> |
|
| 21 | 22 | <label for="bio">Bio</label><textarea id="bio" name="bio" rows="3" maxlength="280">{{ me.bio }}</textarea> |
|
| 22 | 23 | <label for="website">Website</label><input type="url" id="website" name="website" value="{{ me.website }}" placeholder="https://"> |
|
| 23 | 24 | <label for="location">Location</label><input type="text" id="location" name="location" value="{{ me.location }}" maxlength="80"> |
|
| … | 25 unchanged lines |
modified templates/style.css +3 −0
| … | 460 unchanged lines |
||
| 461 | 461 | .help li { margin: 4px 0; } |
|
| 462 | 462 | table.glossary td:first-child { white-space: nowrap; color: var(--mute); } |
|
| 463 | 463 | table.glossary td:nth-child(2) { white-space: nowrap; } |
|
| 464 | + | ||
| 465 | + | button.linkish { background: none; border: 0; padding: 0; margin: 0; color: var(--acc); font: inherit; cursor: pointer; text-transform: none; letter-spacing: normal; } |
|
| 466 | + | button.linkish:hover { text-decoration: underline; } |