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
+91
View File
@@ -16,6 +16,9 @@ type Login struct {
Host string
SetupHint bool
Error string
// Info is the result of what brought the person back here - a reset link
// asked for, a password set from one - shown as a success flash.
Info string
}
// NewLogin returns the sign-in page's data. host and formErr may be empty.
@@ -33,6 +36,94 @@ func NewLoginSetupHint(host string) *Login {
// Error is set.
func (l *Login) Notice() Flash { return Flash{Error: true, Text: Plain(l.Error)} }
// WithInfo sets the success flash.
func (l *Login) WithInfo(info string) *Login {
l.Info = info
return l
}
// InfoNotice is Info as the flash partial's input.
func (l *Login) InfoNotice() Flash { return Flash{Text: Plain(l.Info)} }
// Reset is the page that asks for a reset link (templates/login_reset.html,
// mockup login-reset.html): a username, and what will happen. Error is a
// refusal, or a link that no longer works.
type Reset struct {
Meta
Host string
Error string
}
// NewReset returns the reset request page's data.
func NewReset(host, formErr string) *Reset {
return &Reset{Meta: Meta{Title: "Reset password"}, Host: host, Error: formErr}
}
// Notice is the page's error as the flash partial's input.
func (p *Reset) Notice() Flash { return Flash{Error: true, Text: Plain(p.Error)} }
// ResetSet is the page a reset link opens (templates/login_reset_set.html,
// mockup login-reset-set.html): the new password, for User, posted back to
// the link's own path.
type ResetSet struct {
Meta
Host string
User string
Token string
Error string
}
// NewResetSet returns the new-password page's data.
func NewResetSet(host, user, token, formErr string) *ResetSet {
return &ResetSet{Meta: Meta{Title: "New password"}, Host: host, User: user, Token: token, Error: formErr}
}
// Kicker is the host and the account the password is for.
func (p *ResetSet) Kicker() string { return kickerOf(p.Host, p.User) }
// Notice is the page's error as the flash partial's input.
func (p *ResetSet) Notice() Flash { return Flash{Error: true, Text: Plain(p.Error)} }
// Confirm is the page a confirmation link opens (templates/confirm_email.html,
// mockup confirm-email.html): the address and a button - a mail scanner
// opens a link, so the GET changes nothing. Done is the page after the
// button; Error a link that does not work, with no button.
type Confirm struct {
Meta
Host string
User string
Email string
Token string
Error string
Done bool
// SignIn is the error of a link opened without its account's session:
// the page points at sign-in.
SignIn bool
}
// NewConfirm returns the confirmation page's data.
func NewConfirm(host, user, email, token string) *Confirm {
return &Confirm{Meta: Meta{Title: "Confirm e-mail"}, Host: host, User: user, Email: email, Token: token}
}
// Kicker is the host and the account the address is of.
func (p *Confirm) Kicker() string { return kickerOf(p.Host, p.User) }
// Notice is the page's error as the flash partial's input.
func (p *Confirm) Notice() Flash { return Flash{Error: true, Text: Plain(p.Error)} }
// kickerOf joins the host and an account's name, either of which may be
// unknown.
func kickerOf(host, user string) string {
switch {
case host == "":
return user
case user == "":
return host
}
return host + " · " + user
}
// Setup is the data of the first-run page that creates the administrator
// (templates/setup.html), reached by the one-time link /setup/<Token>. Token is
// the path segment the form posts back to; Error is the refusal of the last