password-reset: a confirmed e-mail, then a link to reset the password

Agreed by the owner on 2026-10-13 ("the e-mail must be confirmed");
notifications to an unconfirmed address settled on 2026-10-17: "Без
подтверждения слать". Mockups 2f5f5b2, route guard b7bbb2c, panel guard
0774da2.

Migration 0013: users.email_confirmed_at ('' for every address saved
before it) and user_links - the SHA-256 of a link's token, its user,
purpose (confirm, reset), address, created and expiry. A changed address
loses its mark (keepConfirmed, in UpdateUser and SaveUser).

internal/web/auth/links.go. A link is 256 bits from crypto/rand, mailed as
https://SELFPOST_HOSTNAME/... - never the request's Host. Confirmation:
24 h, sent when an address is saved (Account, or the global role's user
form) and by Account's Send the link again; its page /confirm/<token> has
a button (a GET changes nothing) and confirms only for a session of its
own account. Reset: /login/reset asks for a username and answers the same
for every name (the sign-in page, "If that account has a confirmed e-mail,
the link is on its way"), mailing after the answer; only a confirmed
address gets one, none within ten minutes of the last, five links an hour
per account, five requests in 15 minutes per client address;
/login/reset/<token> (30 min) sets the password with the link taken in
the same transaction that checks the address again
(store.TakeResetLink), then ends every session. Changing the e-mail on
Account asks for the current password. A password set on Account or the
user form removes the reset links; a restore removes every link; the stop
waits for links being mailed (Server.Drain); a rejected request's log line
hides a link's token (loggedPath).

Reviewed for security before the commit (2026-10-17): no high; two
medium and five low, all fixed here - confirming needs the account's
session and the mail does not name the account (a mistyped address was a
takeover); a reset request neither replaces the links already sent nor
spends the account's quota inside ten minutes; take-and-set atomic; the
confirmation mail sent after the answer; the stop waits for mail; a
restore deletes the links; the token kept out of the log.

Tests: store TestUserLinks, TestUserLinkTakenOnce, TestEmailConfirmation,
TestTakeResetLink; auth TestResetSendsALinkOnlyToAConfirmedAddress,
TestResetLinkSetsThePasswordOnce, TestResetLinkDiesWithTheAddress,
TestResetRequestsAreLimited, TestResetRequestsAreSpaced, TestConfirmLink,
TestLinkPathsWithoutAToken; handlers TestAccountEmailIsConfirmedByItsLink,
TestUserFormSendsANewAddressItsLink; web TestLoggedPathHidesTokens; the
view's pages and fixtures; e2e password_reset_by_e_mail runs it through
the sink (and checks a link from before the restore is dead). go vet +
go test (26 packages) and make e2e on the test server for this tree.

Docs: guide § Account, security.md (the bullet and what ends sessions),
architecture (routes), schema-migrations (row 13, users, user_links),
help #account (word for word with the mockup), roadmap row done,
CHANGELOG [Unreleased].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-17 12:03:27 +03:00
co-authored by Claude Opus 5.5
parent 09edc0fac6
commit b8e156c9f4
43 changed files with 1966 additions and 61 deletions
+4 -2
View File
@@ -821,7 +821,9 @@ building them only takes method values, so the route guard test can run it on
a bare `Server` and ask which pattern answers a path:
- **public** — `/healthz` (liveness), `/license` (the embedded `LICENSE`),
`/static/`, `/setup/`, `/login`, `/logout`;
`/static/`, `/setup/`, `/login`, `/logout`, and the links of
password-reset, opened from a mail signed in or not: `/login/reset`,
`/login/reset/<token>`, `/confirm/<token>` (`internal/web/auth/links.go`);
- **authenticated** — everything else, behind `RequireAuth`: a request without
a live session is redirected to `/login`.
@@ -845,7 +847,7 @@ only reads (the delete confirmation at `GET …/delete` renders a form).
| Inbound › Domains | `GET, POST /inbound/domains` · `/inbound/domains/{id}` · `POST …/dns-recheck`, `…/upstream`, `…/recipients` · `GET, POST …/delete` — registered only with `INBOUND_RELAY_ENABLE=true` |
| Inbound › Log, Filter lists | `GET /inbound/log` · `GET, POST /inbound/filter-lists` · `POST /inbound/filter-lists/{id}/delete` — registered only with `INBOUND_RELAY_ENABLE=true` |
| Server | `GET /server/health` (+ `/fragment`) · `POST /server/health/recheck`, `/server/health/reload`, `/server/health/test-message` · `GET /server/log` (+ `/fragment`) · `GET, POST /server/backup` · `POST /server/backup/import` · `/server/users`, `…/new`, `…/{uid}`, `…/{uid}/delete` · `GET, POST /server/settings` · `POST /server/settings/dns-recheck` · `GET /server/components` |
| Account | `GET /account` · `POST /account/profile`, `/account/password` |
| Account | `GET /account` · `POST /account/profile`, `/account/password`, `/account/email/confirm` |
| User menu | `GET /help` · `POST /logout` (public mux) |
An application is addressed under its domain, and an application id that does
+25 -3
View File
@@ -558,14 +558,36 @@ readings, numbered in the order of what stops mail first:
### Account
**Account** (`/account`, every role) is the signed-in user's own: **Profile**
(username and e-mail) and **Password**. Saving the profile does not ask for the
password; changing the password asks for the current one and is rate-limited
like a sign-in. The e-mail is the user's own address: it is where the panel
(username and e-mail) and **Password**. Changing the username or the
notifications switch does not ask for the password; changing the e-mail does,
and so does changing the password, both on the limit of a sign-in's guesses
(ten in 15 minutes per user). The e-mail is the user's own address: it is where the panel
sends [notifications](#notifications) while *Send me notifications* is ticked
beside it, and what a domain's *Use my e-mail* button fills in (see
[Domain-level DNS](#domain-level-dns-spf-dkim-dmarc)). Application SASL logins
are separate and are not changed here.
**A confirmed e-mail and a forgotten password.** A new address - saved under
Account, or typed for a user by the global role on the user form - is sent a
link that confirms it (`https://<SELFPOST_HOSTNAME>/confirm/…`), which works
once, for 24 hours, opened in a browser signed in as that account; its page
has a button, and only the button confirms. The mail does not name the
account, so a mistyped address cannot be confirmed by whoever reads it.
Account tags the address *confirmed* or *not confirmed* and, while it is not,
offers *Send the link again* (at most five links an hour to one address).
Notifications go to the address either way. Only a confirmed address can be
sent a reset link: *Forgot your password?* on the sign-in page asks for the
username and always answers the same - whether the name exists, and whether
its address is confirmed, is not said; another link is not sent within ten
minutes of the last - and the link
(`https://<SELFPOST_HOSTNAME>/login/reset/…`) works once, for 30 minutes, and
only while the account's address is still the one it was sent to. Setting the
new password signs every session of the account out. The links name the
server by `SELFPOST_HOSTNAME`, so the panel must be served at that name. An
account without a confirmed address recovers as before: the global role sets
a new password on the user form, and the only global user from the container
(see [Users](#users)).
### Settings
**Server › Settings** (`/server/settings`, global role) holds what is true for
+17 -1
View File
@@ -37,7 +37,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| review-2026-08-followups | Remaining findings of the 2026-08-19 code review | closed 2026-10-14 (rows placed in [audit-2026-10-12](audit-2026-10-12.md)) | — | — |
| panel-notifications | E-mail notifications about important events | shipped `2.3.0` | 10/10 | [plans/panel-notifications.md](plans/panel-notifications.md) |
| delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | shipped `2.2.0` | 8/8 | [plans/delivery-log-storage.md](plans/delivery-log-storage.md) |
| password-reset | Password reset by e-mail | agreed (2026-10-13) | — | — |
| password-reset | Password reset by e-mail | done 2026-10-17, unreleased | — | — |
| local-time | The panel shows time, and counts its days, in a time zone other than UTC | shipped `2.13.0` | — | — |
| notification-mail-recheck | The Notification mail records on Server › Settings can be checked again from where they are shown | shipped `2.9.0` | — | — |
| settings-save-row | Server › Settings: the Save button no longer stands alone under a long right column | shipped `2.9.0` | — | — |
@@ -459,6 +459,22 @@ to come need it - they are not the owner and should not have to ask him for
a new password). Mockup-first; changing the account e-mail asks for the
current password in the same cut.
**Notifications to an unconfirmed address (owner, 2026-10-17):** "Без
подтверждения слать" - they go to it confirmed or not; only a reset link
needs it confirmed. Every address saved before the release is unconfirmed,
and cutting them off would have stopped every warning at once.
**Done (2026-10-17):** design 2f5f5b2, route guard b7bbb2c, panel guard
0774da2. Migration 0013 (`users.email_confirmed_at`, `user_links`). Links
are 256 bits, kept as SHA-256, confirmation 24 h and reset 30 min, taken
once (`DELETE … RETURNING`), built on `SELFPOST_HOSTNAME` (never `Host`);
the request answers the same for every name and mails after answering;
limits per client address and per account; a link's GET page has a button
(mail scanners open links). A reset ends every session. Changing the
e-mail on Account asks for the password. The global role's user form sends
a typed address its link. e2e `password_reset_by_e_mail` runs it through
the sink.
---
## local-time
+4 -2
View File
@@ -17,7 +17,7 @@ When you add or review a migration, update this file in the same change.
| Field | Value |
|---|---|
| Chain head | `user_version = 12` |
| Chain head | `user_version = 13` |
| Files | `0001_init.sql` (the 2.0 baseline), `0002_inbound_spam_log.sql`, `0003_send_log_lines.sql`, `0004_notifications.sql`, `0005_send_log_rejected_index.sql`, `0006_sender_check.sql`, `0007_delivery_log_days.sql`, `0008_tenant_boundary.sql`, `0009_refusal_line.sql`, `0010_send_log_indexes.sql`, `0011_notify_addressees.sql`, `0012_inbound_upstream.sql` |
| File mark | `PRAGMA application_id = 0x53503230` ("SP20") |
| Database file | `/data/selfpost.db` (bind mount) |
@@ -90,6 +90,7 @@ audit (`2.5.0`), `0009_refusal_line.sql` (`2.5.1`) and
| 10 | `0010_send_log_indexes.sql` | 2.5.3 | DDL | Four indexes of `send_log`, each for a question that walked the table or sorted a domain's log (audit 2026-10-12, M2, stage 3): `idx_send_log_open`, partial, the queued and deferred rows by time (the tailer's sweep every 5 min); `idx_send_log_domain_id` and `idx_send_log_app_login`, a page of one domain's or one application's log in id order; `idx_send_log_refusal_line`, partial, whether a refusal line is already on a row. `TestSendLogQueriesUseTheirIndexes` checks the plans of these and of the other by-time and by-scope statements the store runs |
| 11 | `0011_notify_addressees.sql` | 2.5.4 | DDL | `notify_addressees (key, address, told_at)`: who a subject's notification is owed to and who was handed it, so a copy that failed or was held back by the hourly ceiling goes out at a later check and a subject's end goes to those told (audit 2026-10-12, stage 4); deleted with its `notify_state` row |
| 12 | `0012_inbound_upstream.sql` | 2.10.0 | DDL | `inbound_spam_log` gains `queue_id`, `upstream_status` (`''`, `queued`, `held`, `sent`, `deferred`, `bounced`; CHECK), `upstream_relay`, `upstream_reply` and `upstream_at`, and the partial index `idx_inbound_spam_log_queue_id` on rows that have a queue id: what the upstream answered about mail accepted on port 25, put on the row by the log tailer (inbound-upstream-result). Rows from before have none |
| 13 | `0013_email_links.sql` | 2.14.0 | DDL | `users.email_confirmed_at` (`''` for every address saved before it: all unconfirmed) and `user_links`, the one-time links that confirm an address and reset a password, with `idx_user_links_user` (password-reset; owner, 2026-10-13 and 2026-10-17) |
**Kind:** *DDL* — schema only; *data* — `INSERT`/`UPDATE` that must stay correct
for operators upgrading from older 2.x images.
@@ -101,7 +102,8 @@ for operators upgrading from older 2.x images.
| Table | Role |
|-------|------|
| `settings` | Key/value settings of the instance — what is true whoever is signed in (send-log retention). Nothing that belongs to a user |
| `users` | Panel logins. `role` is `global` or `domain`; `email` is the user's own address; `notify` is the user's own switch for notification e-mails (v4); `all_domains` / `all_inbound_domains` widen a `domain` user to every domain of that direction |
| `users` | Panel logins. `role` is `global` or `domain`; `email` is the user's own address; `notify` is the user's own switch for notification e-mails (v4); `all_domains` / `all_inbound_domains` widen a `domain` user to every domain of that direction; `email_confirmed_at` is when `email` was confirmed, `''` while it is not, cleared when it changes (v13) |
| `user_links` | The one-time links of password-reset (v13): the SHA-256 of the token, the user, `purpose` (`confirm`, `reset`; CHECK), the address it was sent to, when, and until when; one live link per user and purpose |
| `sessions` | Panel login sessions (token hash, owning user by id and deleted with them, sliding idle expiry) |
| `domains` | Sending (outbound) domains. `dmarc_rua` is where aggregate reports go (`''` = no reports) |
| `user_domains` | Outbound domains assigned to a `domain` user |
+32 -1
View File
@@ -138,7 +138,8 @@ The panel is exposed to the internet — the items below are **not optional**.
after it was found, so the cookie of a deleted user is dead and cannot become
the session of whoever is created under the same name later; renaming a user
leaves their sessions alone.
- **What ends a user's sessions:** deleting the user (all); a password change on
- **What ends a user's sessions:** deleting the user (all); a password set
from a reset link (all, `2.14.0`); a password change on
Account (that user's *other* sessions — never another user's); a password set
for them by a global user on the user form (all of theirs, except the current
one when they set their own); signing out (the current one only); signing
@@ -148,6 +149,36 @@ The panel is exposed to the internet — the items below are **not optional**.
held.
- Polling by the monitoring screens (`HX-Request` `GET`s) does not renew a
session.
- **Password reset by e-mail (`2.14.0`).** A link goes only to an address
its account has confirmed, by a link sent to that address; notifications
go to an unconfirmed one (owner, 2026-10-17) - they carry no secret. A link
is 256 random bits (`crypto/rand`); `user_links` keeps its SHA-256 only,
so the database or a backup opens nothing. A confirmation link lives 24
hours, a reset link 30 minutes; each is taken once, in the transaction
that deletes it (`DELETE … RETURNING`), and a newer link of the same kind
replaces the older. A reset link holds only while the account's address is
still the confirmed one it was sent to; a confirmation link only while the
address is the one it confirms. The link's host is `SELFPOST_HOSTNAME`,
never the request's `Host`, which an attacker would choose to receive the
token. The request form answers the same for every name and sends the mail
after answering, so neither the words nor the time tell whether a name
exists or is confirmed; requests are limited to five in 15 minutes per
client address, mails to five an hour per account, and a link's page to 30
requests in 15 minutes per client address. A link's page changes nothing
on `GET` (a mail scanner opens links): the button does. Setting a password
from a link ends every session of the account and every other reset link.
Changing the account e-mail asks for the current password, as the password
does: the address is where a reset link goes. From the security review
(2026-10-17): confirming an address also takes being signed in as its
account, and the confirmation mail does not name the account - a mistyped
address is a stranger's, who could otherwise confirm it and reset the
password; a reset request does not replace the links already sent, and
none is sent within ten minutes of the last, so a stranger repeating a
username neither kills the link in the mailbox nor spends the account's
links; the link is taken and the password set in one transaction that
checks the address again; a restore deletes every link; a rejected
request's log line hides a link's token; a stop waits for the links still
being mailed.
### Roles