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:
@@ -401,6 +401,10 @@ func TestE2E(t *testing.T) {
|
||||
run("inbound_bare_newline_is_one_message", func(t *testing.T) {
|
||||
testInboundBareNewline(t, sc)
|
||||
})
|
||||
// After every step that uses adminPassword: the reset sets another.
|
||||
run("password_reset_by_e_mail", func(t *testing.T) {
|
||||
testPasswordReset(t, sc)
|
||||
})
|
||||
// Last: it recreates the container in another zone.
|
||||
run("time_zone_is_the_containers", func(t *testing.T) {
|
||||
testTimeZone(t, sc)
|
||||
|
||||
@@ -40,6 +40,8 @@ func (c *panelClient) setAccountEmail(username, email string) error {
|
||||
"username": {username},
|
||||
"email": {email},
|
||||
"notify": {"on"},
|
||||
// A new e-mail asks for the password (password-reset).
|
||||
"current_password": {adminPassword},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// testPasswordReset (password-reset): the whole way, through the sink, as
|
||||
// the administrator would. The address saved for notifications was sent its
|
||||
// confirmation link, and the restore of an earlier step deleted it (a
|
||||
// restore deletes every link), so Account sends it again; opening it changes
|
||||
// nothing until its button is pressed. Then, signed out, Forgot your
|
||||
// password? sends a link to that confirmed address; it sets a new password,
|
||||
// which signs in, and ends every session - the scenario's own included,
|
||||
// which signs in again with the new one.
|
||||
func testPasswordReset(t *testing.T, sc *scenario) {
|
||||
// links are the links of path in the sink's messages to the
|
||||
// administrator.
|
||||
links := func(path string) map[string]bool {
|
||||
t.Helper()
|
||||
re := regexp.MustCompile(`https://` + regexp.QuoteMeta(selfpostHostname) + `(` + regexp.QuoteMeta(path) + `[A-Za-z0-9_-]{43})\r?\n`)
|
||||
dir := filepath.Join(h.stageDir, "mail-stage")
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||
if err != nil || !strings.Contains(string(b), "To: <"+adminEmail+">") {
|
||||
continue
|
||||
}
|
||||
for _, m := range re.FindAllStringSubmatch(string(b), -1) {
|
||||
out[m[1]] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
// newLink waits for a link of path that before did not have.
|
||||
newLink := func(path string, before map[string]bool) string {
|
||||
t.Helper()
|
||||
var link string
|
||||
if err := waitFor("a new link to "+path+" in the sink", 60*time.Second, 500*time.Millisecond, func() (bool, error) {
|
||||
for l := range links(path) {
|
||||
if !before[l] {
|
||||
link = l
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}); err != nil {
|
||||
t.Fatalf("%v\n%s", err, tailLines(h.logs("selfpost"), "link", 20))
|
||||
}
|
||||
return link
|
||||
}
|
||||
|
||||
before := links("/confirm/")
|
||||
if len(before) == 0 {
|
||||
t.Fatal("saving the administrator's address sent no confirmation link")
|
||||
}
|
||||
if resp, body, err := sc.panel.postForm("/account/email/confirm", nil); err != nil || resp.Request.URL.Path != "/account" || !strings.Contains(body, "A new link is on its way.") {
|
||||
t.Fatalf("send the link again: %v\n%.2000s", err, body)
|
||||
}
|
||||
confirm := newLink("/confirm/", before)
|
||||
// The link sent before the restore no longer works.
|
||||
for old := range before {
|
||||
if _, page, _ := sc.panel.get(old); !strings.Contains(page, "This link has expired or was already used.") {
|
||||
t.Errorf("a link sent before the restore still works")
|
||||
}
|
||||
}
|
||||
_, page, err := sc.panel.get(confirm)
|
||||
if err != nil || !strings.Contains(page, `action="`+confirm+`"`) {
|
||||
t.Fatalf("the confirmation link's page: %v\n%.2000s", err, page)
|
||||
}
|
||||
if _, account, _ := sc.panel.get("/account"); !strings.Contains(account, "not confirmed") {
|
||||
t.Fatalf("opening the link confirmed the address:\n%.3000s", account)
|
||||
}
|
||||
if _, done, err := sc.panel.postForm(confirm, nil); err != nil || !strings.Contains(done, "E-mail confirmed") {
|
||||
t.Fatalf("confirm: %v\n%.2000s", err, done)
|
||||
}
|
||||
if _, account, _ := sc.panel.get("/account"); !strings.Contains(account, `<span class="tag is-success is-light">confirmed</span>`) {
|
||||
t.Fatalf("the address is not confirmed on Account:\n%.3000s", account)
|
||||
}
|
||||
|
||||
stranger, err := newPanelClient()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, body, err := stranger.postForm("/login/reset", url.Values{"username": {adminUser}})
|
||||
if err != nil || resp.Request.URL.Path != "/login" || !strings.Contains(body, "the link is on its way") {
|
||||
t.Fatalf("ask for a reset link: %v, landed on %s\n%.2000s", err, resp.Request.URL.Path, body)
|
||||
}
|
||||
reset := newLink("/login/reset/", nil)
|
||||
const fresh = "a-brand-new-e2e-password"
|
||||
resp, body, err = stranger.postForm(reset, url.Values{"new_password": {fresh}, "new_password_confirm": {fresh}})
|
||||
if err != nil || resp.Request.URL.Path != "/login" || !strings.Contains(body, "Password set. Sign in with it.") {
|
||||
t.Fatalf("set the new password: %v, landed on %s\n%.2000s", err, resp.Request.URL.Path, body)
|
||||
}
|
||||
if resp, _, _ := stranger.postForm(reset, url.Values{"new_password": {fresh + "x"}, "new_password_confirm": {fresh + "x"}}); resp == nil || resp.StatusCode != 404 {
|
||||
t.Errorf("the reset link worked twice")
|
||||
}
|
||||
if err := stranger.login(adminUser, adminPassword); err == nil {
|
||||
t.Error("the old password still signs in")
|
||||
}
|
||||
// The scenario's session ended with the reset.
|
||||
if resp, err := sc.panel.status(); err != nil || resp.Request.URL.Path != "/login" {
|
||||
t.Fatalf("the scenario's session survived the reset")
|
||||
}
|
||||
if err := sc.panel.login(adminUser, fresh); err != nil {
|
||||
t.Fatalf("sign in with the new password: %v", err)
|
||||
}
|
||||
if err := stranger.login(adminUser, fresh); err != nil {
|
||||
t.Fatalf("sign in with the new password elsewhere: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user