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:
@@ -0,0 +1,21 @@
|
||||
-- A confirmed account e-mail, and the one-time links that confirm one and
|
||||
-- reset a forgotten password (password-reset, 2.14.0; owner, 2026-10-13: "the
|
||||
-- e-mail must be confirmed"; 2026-10-17: notifications go to it confirmed or
|
||||
-- not). email_confirmed_at is when the address in email was confirmed, ''
|
||||
-- while it is not; changing the address clears it. Every address saved before
|
||||
-- this file is unconfirmed.
|
||||
ALTER TABLE users ADD COLUMN email_confirmed_at TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- A link's token is in a mail and nowhere else: only its SHA-256 is kept, as a
|
||||
-- session's. email is the address a confirmation link confirms - it fails
|
||||
-- once the account's address is another - and the one a reset link was sent
|
||||
-- to. A link is taken once; a new one of the same purpose replaces the old.
|
||||
CREATE TABLE user_links (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
purpose TEXT NOT NULL CHECK (purpose IN ('confirm', 'reset')),
|
||||
email TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_user_links_user ON user_links(user_id, purpose);
|
||||
@@ -42,7 +42,7 @@ func TestBaselineSchema(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
want := map[string]string{
|
||||
"settings": "key value",
|
||||
"users": "id username password_hash role email all_domains all_inbound_domains created_at notify",
|
||||
"users": "id username password_hash role email all_domains all_inbound_domains created_at notify email_confirmed_at",
|
||||
"sessions": "token_hash user_id created_at expires_at",
|
||||
"domains": "id name dkim_selector dmarc_rua created_at",
|
||||
"user_domains": "user_id domain_id",
|
||||
@@ -64,6 +64,7 @@ func TestBaselineSchema(t *testing.T) {
|
||||
"notify_addressees": "key address told_at",
|
||||
"sender_check_days": "day down_seconds deferred messages estimated",
|
||||
"delivery_log_days": "day reconcile_closed reconcile_missed reconcile_contradicted tailer_blind_seconds queued_over_lifetime",
|
||||
"user_links": "token_hash user_id purpose email created_at expires_at",
|
||||
}
|
||||
rows, err := st.db.Query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
||||
if err != nil {
|
||||
@@ -111,7 +112,7 @@ func TestBaselineSchema(t *testing.T) {
|
||||
"idx_inbound_spam_log_created_at", "idx_inbound_spam_log_domain_created_at", "idx_inbound_spam_log_domain_id_created_at", "idx_inbound_spam_log_queue_id",
|
||||
"idx_send_log_app_login", "idx_send_log_app_login_created_at", "idx_send_log_created_at", "idx_send_log_domain", "idx_send_log_domain_id", "idx_send_log_domain_id_created_at",
|
||||
"idx_send_log_lines_queue_id", "idx_send_log_open", "idx_send_log_queue_id", "idx_send_log_refusal_line", "idx_send_log_rejected",
|
||||
"idx_sessions_expires_at",
|
||||
"idx_sessions_expires_at", "idx_user_links_user",
|
||||
}
|
||||
sort.Strings(wantIdx)
|
||||
if strings.Join(indexes, " ") != strings.Join(wantIdx, " ") {
|
||||
@@ -119,8 +120,8 @@ func TestBaselineSchema(t *testing.T) {
|
||||
}
|
||||
|
||||
var version, appID int64
|
||||
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 12 {
|
||||
t.Errorf("user_version = %d, %v; want 12", version, err)
|
||||
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 13 {
|
||||
t.Errorf("user_version = %d, %v; want 13", version, err)
|
||||
}
|
||||
if err := st.db.QueryRow("PRAGMA application_id").Scan(&appID); err != nil || appID != applicationID {
|
||||
t.Errorf("application_id = %#x, %v; want %#x", appID, err, applicationID)
|
||||
@@ -196,8 +197,8 @@ func TestOpenMigratesA20Database(t *testing.T) {
|
||||
}
|
||||
defer st.Close()
|
||||
var version int
|
||||
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 12 {
|
||||
t.Errorf("user_version = %d, %v; want 12", version, err)
|
||||
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 13 {
|
||||
t.Errorf("user_version = %d, %v; want 13", version, err)
|
||||
}
|
||||
if ok, err := st.UserExists(); err != nil || !ok {
|
||||
t.Errorf("the user of the 2.0 database: exists = %v, %v", ok, err)
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The purposes of a user link (migration 0013).
|
||||
const (
|
||||
LinkConfirm = "confirm" // confirms the account's e-mail
|
||||
LinkReset = "reset" // sets a new password
|
||||
)
|
||||
|
||||
// ErrNoLink is a link that does not exist, has been taken or has expired:
|
||||
// the three are one answer.
|
||||
var ErrNoLink = errors.New("no such link")
|
||||
|
||||
// UserLink is a one-time link of a user, as stored: the token itself is in
|
||||
// the mail alone.
|
||||
type UserLink struct {
|
||||
UserID int64
|
||||
Purpose string
|
||||
Email string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// CreateUserLink stores a link of the user for purpose, sent to email, valid
|
||||
// until expires, and removes every expired link. A confirmation link replaces
|
||||
// the user's earlier one - it confirms the address saved last. A reset link
|
||||
// does not: anyone who knows a username can ask for one, and a request of
|
||||
// theirs must not kill the link the user is about to open (security review,
|
||||
// 2026-10-17); setting a password removes them all.
|
||||
func (s *Store) CreateUserLink(tokenHash string, userID int64, purpose, email string, now, expires time.Time) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create user link: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
// The first statement is a write (see UpdateStatus).
|
||||
if _, err := tx.Exec(`DELETE FROM user_links WHERE (user_id = ? AND purpose = ? AND purpose = ?) OR expires_at <= ?`,
|
||||
userID, purpose, LinkConfirm, now.UTC().Format(time.RFC3339)); err != nil {
|
||||
return fmt.Errorf("create user link: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO user_links (token_hash, user_id, purpose, email, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`, tokenHash, userID, purpose, email,
|
||||
now.UTC().Format(time.RFC3339), expires.UTC().Format(time.RFC3339)); err != nil {
|
||||
return fmt.Errorf("create user link: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("create user link: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanLink(r scanRow) (UserLink, error) {
|
||||
var l UserLink
|
||||
var created, expires string
|
||||
if err := r.Scan(&l.UserID, &l.Purpose, &l.Email, &created, &expires); err != nil {
|
||||
return UserLink{}, err
|
||||
}
|
||||
l.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
||||
l.ExpiresAt, _ = time.Parse(time.RFC3339, expires)
|
||||
return l, nil
|
||||
}
|
||||
|
||||
const linkColumns = "user_id, purpose, email, created_at, expires_at"
|
||||
|
||||
// PeekUserLink returns the link of purpose with tokenHash if it is still
|
||||
// valid at now, without taking it: the page a link opens shows its form.
|
||||
func (s *Store) PeekUserLink(tokenHash, purpose string, now time.Time) (UserLink, error) {
|
||||
l, err := scanLink(s.db.QueryRow(`SELECT `+linkColumns+` FROM user_links
|
||||
WHERE token_hash = ? AND purpose = ? AND expires_at > ?`,
|
||||
tokenHash, purpose, now.UTC().Format(time.RFC3339)))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return UserLink{}, ErrNoLink
|
||||
}
|
||||
if err != nil {
|
||||
return UserLink{}, fmt.Errorf("peek user link: %w", err)
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// TakeUserLink returns the link of purpose with tokenHash and deletes it, in
|
||||
// one transaction: of two requests with the same link one gets it.
|
||||
func (s *Store) TakeUserLink(tokenHash, purpose string, now time.Time) (UserLink, error) {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return UserLink{}, fmt.Errorf("take user link: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
// The delete first: it is the write that takes the lock (see
|
||||
// UpdateStatus), and RETURNING gives the row it removed (SQLite 3.35+,
|
||||
// https://sqlite.org/lang_returning.html).
|
||||
l, err := scanLink(tx.QueryRow(`DELETE FROM user_links
|
||||
WHERE token_hash = ? AND purpose = ? AND expires_at > ?
|
||||
RETURNING `+linkColumns, tokenHash, purpose, now.UTC().Format(time.RFC3339)))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return UserLink{}, ErrNoLink
|
||||
}
|
||||
if err != nil {
|
||||
return UserLink{}, fmt.Errorf("take user link: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return UserLink{}, fmt.Errorf("take user link: %w", err)
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// LatestUserLink is the user's live link of purpose, ErrNoLink when there is
|
||||
// none: Account says when the confirmation link went.
|
||||
func (s *Store) LatestUserLink(userID int64, purpose string, now time.Time) (UserLink, error) {
|
||||
l, err := scanLink(s.db.QueryRow(`SELECT `+linkColumns+` FROM user_links
|
||||
WHERE user_id = ? AND purpose = ? AND expires_at > ? ORDER BY created_at DESC LIMIT 1`,
|
||||
userID, purpose, now.UTC().Format(time.RFC3339)))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return UserLink{}, ErrNoLink
|
||||
}
|
||||
if err != nil {
|
||||
return UserLink{}, fmt.Errorf("latest user link: %w", err)
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// DeleteUserLinks removes every link of the user of purpose: a password set
|
||||
// another way leaves no reset link behind it.
|
||||
func (s *Store) DeleteUserLinks(userID int64, purpose string) error {
|
||||
if _, err := s.db.Exec(`DELETE FROM user_links WHERE user_id = ? AND purpose = ?`, userID, purpose); err != nil {
|
||||
return fmt.Errorf("delete user links: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConfirmEmail marks the user's e-mail confirmed at now if it is still email:
|
||||
// false when the address has changed since the link was sent.
|
||||
func (s *Store) ConfirmEmail(userID int64, email string, now time.Time) (bool, error) {
|
||||
res, err := s.db.Exec(`UPDATE users SET email_confirmed_at = ? WHERE id = ? AND email = ? AND email <> ''`,
|
||||
now.UTC().Format(time.RFC3339), userID, email)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("confirm e-mail: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n == 1, nil
|
||||
}
|
||||
|
||||
// TakeResetLink sets the password of the reset link with tokenHash, in one
|
||||
// transaction: the link is taken, the password set only while the account's
|
||||
// address is still the confirmed one the link was sent to, and the user's
|
||||
// other reset links go. ErrNoLink, and nothing changed, for a link that does
|
||||
// not hold - taken, expired, or of an address the account no longer has.
|
||||
func (s *Store) TakeResetLink(tokenHash, passwordHash string, now time.Time) (int64, error) {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("take reset link: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
l, err := scanLink(tx.QueryRow(`DELETE FROM user_links
|
||||
WHERE token_hash = ? AND purpose = ? AND expires_at > ?
|
||||
RETURNING `+linkColumns, tokenHash, LinkReset, now.UTC().Format(time.RFC3339)))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, ErrNoLink
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("take reset link: %w", err)
|
||||
}
|
||||
res, err := tx.Exec(`UPDATE users SET password_hash = ?
|
||||
WHERE id = ? AND email = ? AND email <> '' AND email_confirmed_at <> ''`, passwordHash, l.UserID, l.Email)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("take reset link: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n != 1 {
|
||||
return 0, ErrNoLink
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM user_links WHERE user_id = ? AND purpose = ?`, l.UserID, LinkReset); err != nil {
|
||||
return 0, fmt.Errorf("take reset link: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, fmt.Errorf("take reset link: %w", err)
|
||||
}
|
||||
return l.UserID, nil
|
||||
}
|
||||
|
||||
// DeleteAllUserLinks removes every link: a restored backup must not bring
|
||||
// back one that was taken after it was made (security review, 2026-10-17).
|
||||
func (s *Store) DeleteAllUserLinks() error {
|
||||
if _, err := s.db.Exec(`DELETE FROM user_links`); err != nil {
|
||||
return fmt.Errorf("delete user links: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetUserPassword replaces the user's password hash alone.
|
||||
func (s *Store) SetUserPassword(userID int64, passwordHash string) error {
|
||||
res, err := s.db.Exec(`UPDATE users SET password_hash = ? WHERE id = ?`, passwordHash, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set password: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
+21
-9
@@ -42,6 +42,9 @@ type User struct {
|
||||
// Email is the user's own address — what the panel writes to, and what the
|
||||
// button beside a domain's typed report address fills it with.
|
||||
Email string
|
||||
// EmailConfirmed is when Email was confirmed by its link, zero while it
|
||||
// is not: only a confirmed address is sent a reset link (migration 0013).
|
||||
EmailConfirmed time.Time
|
||||
// AllDomains / AllInboundDomains widen a domain user's list to every
|
||||
// domain of that direction, including those added later; the id lists
|
||||
// are then ignored.
|
||||
@@ -55,19 +58,20 @@ type User struct {
|
||||
InboundDomainIDs []int64
|
||||
}
|
||||
|
||||
const userColumns = "id, username, password_hash, role, email, all_domains, all_inbound_domains, notify, created_at"
|
||||
const userColumns = "id, username, password_hash, role, email, all_domains, all_inbound_domains, notify, created_at, email_confirmed_at"
|
||||
|
||||
func scanUser(r scanRow) (User, error) {
|
||||
var (
|
||||
u User
|
||||
createdAt string
|
||||
u User
|
||||
createdAt, confirmed string
|
||||
)
|
||||
err := r.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.Email,
|
||||
&u.AllDomains, &u.AllInboundDomains, &u.Notify, &createdAt)
|
||||
&u.AllDomains, &u.AllInboundDomains, &u.Notify, &createdAt, &confirmed)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
u.EmailConfirmed, _ = time.Parse(time.RFC3339, confirmed)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
@@ -316,8 +320,8 @@ func (s *Store) SaveUser(c UserChange) error {
|
||||
// An empty hash keeps the stored one: the form read it when it was
|
||||
// opened, and the user may have changed it on Account since.
|
||||
res, err := tx.Exec(
|
||||
"UPDATE users SET username = ?, password_hash = COALESCE(NULLIF(?, ''), password_hash), email = ?, role = ? WHERE id = ?",
|
||||
c.Username, c.PasswordHash, c.Email, c.Role, c.ID,
|
||||
"UPDATE users SET username = ?, password_hash = COALESCE(NULLIF(?, ''), password_hash), email = ?, role = ?, "+keepConfirmed+" WHERE id = ?",
|
||||
c.Username, c.PasswordHash, c.Email, c.Role, c.Email, c.ID,
|
||||
)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
@@ -366,11 +370,19 @@ func requireAGlobal(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateUser replaces a user's username, password hash and account e-mail.
|
||||
// keepConfirmed keeps the confirmation of an address that stays the same and
|
||||
// clears it for another; its placeholder takes the new address. SQLite
|
||||
// 3.53.4, https://sqlite.org/lang_update.html: every expression on the right
|
||||
// of SET sees the row as it was before the UPDATE, so email here is the old
|
||||
// address.
|
||||
const keepConfirmed = "email_confirmed_at = CASE WHEN email = ? THEN email_confirmed_at ELSE '' END"
|
||||
|
||||
// UpdateUser replaces a user's username, password hash and account e-mail; a
|
||||
// new e-mail is unconfirmed.
|
||||
func (s *Store) UpdateUser(id int64, username, passwordHash, email string) error {
|
||||
res, err := s.db.Exec(
|
||||
"UPDATE users SET username = ?, password_hash = ?, email = ? WHERE id = ?",
|
||||
username, passwordHash, email, id,
|
||||
"UPDATE users SET username = ?, password_hash = ?, email = ?, "+keepConfirmed+" WHERE id = ?",
|
||||
username, passwordHash, email, email, id,
|
||||
)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
|
||||
Reference in New Issue
Block a user