Files
selfpost/test/e2e/password_reset_test.go
T
mixandClaude Opus 5.5 b8e156c9f4 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>
2026-10-17 12:03:27 +03:00

121 lines
4.7 KiB
Go

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)
}
}