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:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user