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
+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