Files
selfpost/internal/store/userlinks_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

192 lines
6.7 KiB
Go

package store
import (
"errors"
"sync"
"testing"
"time"
)
func linkTestUser(t *testing.T, st *Store, email string) User {
t.Helper()
if err := st.CreateGlobalUser("admin", "hash"); err != nil {
t.Fatal(err)
}
u, err := st.GetUserByUsername("admin")
if err != nil {
t.Fatal(err)
}
if err := st.UpdateUser(u.ID, u.Username, u.PasswordHash, email); err != nil {
t.Fatal(err)
}
u, _ = st.GetUser(u.ID)
return u
}
// A link is taken once, not after it expires, and only for its purpose; a
// newer one of the same purpose replaces it.
func TestUserLinks(t *testing.T) {
st := openTestStore(t)
u := linkTestUser(t, st, "admin@example.org")
now := time.Date(2026, 10, 17, 10, 0, 0, 0, time.UTC)
if err := st.CreateUserLink("h1", u.ID, LinkReset, u.Email, now, now.Add(30*time.Minute)); err != nil {
t.Fatal(err)
}
if _, err := st.PeekUserLink("h1", LinkConfirm, now); !errors.Is(err, ErrNoLink) {
t.Errorf("a reset link taken as a confirmation one: %v", err)
}
if l, err := st.PeekUserLink("h1", LinkReset, now); err != nil || l.UserID != u.ID || l.Email != u.Email {
t.Fatalf("peek = %+v, %v", l, err)
}
if _, err := st.PeekUserLink("h1", LinkReset, now.Add(30*time.Minute)); !errors.Is(err, ErrNoLink) {
t.Errorf("an expired link was valid: %v", err)
}
// A second reset link leaves the first working: a request of anyone's
// must not kill the link in the user's mailbox.
if err := st.CreateUserLink("h2", u.ID, LinkReset, u.Email, now.Add(time.Minute), now.Add(31*time.Minute)); err != nil {
t.Fatal(err)
}
if l, err := st.LatestUserLink(u.ID, LinkReset, now.Add(2*time.Minute)); err != nil || !l.CreatedAt.Equal(now.Add(time.Minute)) {
t.Errorf("latest = %+v, %v", l, err)
}
if _, err := st.TakeUserLink("h1", LinkReset, now.Add(2*time.Minute)); err != nil {
t.Errorf("the first reset link died with the second: %v", err)
}
if _, err := st.TakeUserLink("h2", LinkReset, now.Add(2*time.Minute)); err != nil {
t.Fatalf("take: %v", err)
}
if _, err := st.TakeUserLink("h2", LinkReset, now.Add(3*time.Minute)); !errors.Is(err, ErrNoLink) {
t.Errorf("a link was taken twice: %v", err)
}
if _, err := st.LatestUserLink(u.ID, LinkReset, now.Add(3*time.Minute)); !errors.Is(err, ErrNoLink) {
t.Errorf("a taken link is still the latest: %v", err)
}
// A second confirmation link replaces the first: it confirms the
// address saved last.
if err := st.CreateUserLink("c1", u.ID, LinkConfirm, u.Email, now, now.Add(time.Hour)); err != nil {
t.Fatal(err)
}
if err := st.CreateUserLink("c2", u.ID, LinkConfirm, u.Email, now.Add(time.Minute), now.Add(time.Hour)); err != nil {
t.Fatal(err)
}
if _, err := st.TakeUserLink("c1", LinkConfirm, now.Add(2*time.Minute)); !errors.Is(err, ErrNoLink) {
t.Errorf("the replaced confirmation link was taken: %v", err)
}
}
// A reset link sets the password only while the account's address is still
// the confirmed one it was sent to; then it and the user's other reset links
// are gone. A link that does not hold changes nothing and is not used up.
func TestTakeResetLink(t *testing.T) {
st := openTestStore(t)
u := linkTestUser(t, st, "admin@example.org")
now := time.Date(2026, 10, 17, 10, 0, 0, 0, time.UTC)
for _, h := range []string{"r1", "r2"} {
if err := st.CreateUserLink(h, u.ID, LinkReset, u.Email, now, now.Add(30*time.Minute)); err != nil {
t.Fatal(err)
}
}
// Not confirmed yet: refused, and the link is still there.
if _, err := st.TakeResetLink("r1", "new-hash", now); !errors.Is(err, ErrNoLink) {
t.Fatalf("an unconfirmed address: %v", err)
}
if _, err := st.PeekUserLink("r1", LinkReset, now); err != nil {
t.Fatalf("a refused take used the link up: %v", err)
}
if ok, _ := st.ConfirmEmail(u.ID, u.Email, now); !ok {
t.Fatal("confirm")
}
id, err := st.TakeResetLink("r1", "new-hash", now)
if err != nil || id != u.ID {
t.Fatalf("take = %d, %v", id, err)
}
if got, _ := st.GetUser(u.ID); got.PasswordHash != "new-hash" {
t.Errorf("hash = %q", got.PasswordHash)
}
if _, err := st.PeekUserLink("r2", LinkReset, now); !errors.Is(err, ErrNoLink) {
t.Errorf("another reset link survived the reset: %v", err)
}
// A link of an address the account no longer has does not hold.
if err := st.CreateUserLink("r3", u.ID, LinkReset, u.Email, now, now.Add(30*time.Minute)); err != nil {
t.Fatal(err)
}
if err := st.UpdateUser(u.ID, u.Username, "new-hash", "other@example.org"); err != nil {
t.Fatal(err)
}
if _, err := st.TakeResetLink("r3", "third-hash", now); !errors.Is(err, ErrNoLink) {
t.Errorf("a link of the old address set the password: %v", err)
}
if err := st.DeleteAllUserLinks(); err != nil {
t.Fatal(err)
}
if _, err := st.PeekUserLink("r3", LinkReset, now); !errors.Is(err, ErrNoLink) {
t.Errorf("a link survived DeleteAllUserLinks: %v", err)
}
}
// Of two requests with one link, one takes it.
func TestUserLinkTakenOnce(t *testing.T) {
st := openTestStore(t)
u := linkTestUser(t, st, "admin@example.org")
now := time.Now()
if err := st.CreateUserLink("h", u.ID, LinkReset, u.Email, now, now.Add(time.Hour)); err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
var mu sync.Mutex
took := 0
for range 8 {
wg.Add(1)
go func() {
defer wg.Done()
if _, err := st.TakeUserLink("h", LinkReset, now); err == nil {
mu.Lock()
took++
mu.Unlock()
}
}()
}
wg.Wait()
if took != 1 {
t.Errorf("the link was taken %d times", took)
}
}
// The mark follows the address: confirmed only while it is the address the
// link was sent to, cleared when the address changes, kept when it does not.
func TestEmailConfirmation(t *testing.T) {
st := openTestStore(t)
u := linkTestUser(t, st, "admin@example.org")
if !u.EmailConfirmed.IsZero() {
t.Fatal("a new address is confirmed")
}
now := time.Date(2026, 10, 17, 10, 0, 0, 0, time.UTC)
if ok, err := st.ConfirmEmail(u.ID, "old@example.org", now); err != nil || ok {
t.Errorf("another address confirmed: %v %v", ok, err)
}
if ok, err := st.ConfirmEmail(u.ID, "admin@example.org", now); err != nil || !ok {
t.Fatalf("confirm: %v %v", ok, err)
}
u, _ = st.GetUser(u.ID)
if !u.EmailConfirmed.Equal(now) {
t.Fatalf("confirmed at %v", u.EmailConfirmed)
}
// Saving the same address keeps the mark; another clears it.
if err := st.UpdateUser(u.ID, "admin2", u.PasswordHash, u.Email); err != nil {
t.Fatal(err)
}
if u, _ = st.GetUser(u.ID); u.EmailConfirmed.IsZero() {
t.Error("the same address lost its mark")
}
if err := st.SaveUser(UserChange{ID: u.ID, Username: u.Username, Email: "new@example.org", Role: RoleGlobal}); err != nil {
t.Fatal(err)
}
if u, _ = st.GetUser(u.ID); !u.EmailConfirmed.IsZero() {
t.Error("a new address kept the old one's mark")
}
if ok, _ := st.ConfirmEmail(u.ID, "admin@example.org", now); ok {
t.Error("a link of the old address confirmed the new one")
}
}