feat(auth): reset a forgotten password by mail

"Passwort vergessen?" appears on the login screen when SMTP and BASE_URL
are configured. The request always gets the same answer and sends the mail
in the background (at most every 2 minutes per account). The link holds a
random token valid once for one hour; only its SHA-256 is stored. Setting
the new password invalidates all open links and ends every session. The
app removes the token from the address bar and offers a way back for
expired links.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 08:06:56 +01:00
1 parent 677fe0fc0a
commit 5e20710aa7
16 files changed
+489 -8

No files matched your search

+89
View File
@@ -0,0 +1,89 @@
package storage
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"time"
)
// Password reset: the user gets a random link token by mail. Only its SHA-256 hash is stored, so a
// leaked database does not contain usable links. A token works once and for one hour; a new password
// also ends all sessions (token_version).
const (
resetTokenTTL = time.Hour
resetTokenCooldown = 2 * time.Minute // no new mail for the same account within this time
)
var (
ErrResetTokenInvalid = errors.New("Der Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an.")
ErrResetTooSoon = errors.New("reset requested again too soon")
)
func (s *Store) migratePasswordReset() error {
_, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS password_reset_tokens (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
used_at TEXT NOT NULL DEFAULT ''
)`)
return err
}
func hashResetToken(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
// CreatePasswordResetToken returns a new raw token for the user, or ErrResetTooSoon while the last
// one is younger than the cooldown.
func (s *Store) CreatePasswordResetToken(userID string) (string, error) {
now := time.Now().UTC()
var last sql.NullString
_ = s.db.QueryRow(`SELECT MAX(created_at) FROM password_reset_tokens WHERE user_id = ?`, userID).Scan(&last)
if last.Valid {
if t, err := time.Parse(time.RFC3339, last.String); err == nil && now.Sub(t) < resetTokenCooldown {
return "", ErrResetTooSoon
}
}
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
raw := hex.EncodeToString(buf)
// Expired and used tokens are of no use any more
_, _ = s.db.Exec(`DELETE FROM password_reset_tokens WHERE expires_at < ? OR used_at != ''`, now.Format(time.RFC3339))
_, err := s.db.Exec(`INSERT INTO password_reset_tokens (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)`,
hashResetToken(raw), userID, now.Format(time.RFC3339), now.Add(resetTokenTTL).Format(time.RFC3339))
return raw, err
}
// ResetPasswordWithToken sets a new password hash if the token is valid and returns the user ID.
// All other open tokens of the user become invalid as well.
func (s *Store) ResetPasswordWithToken(raw, passwordHash string) (string, error) {
var userID, expires, used string
var active bool
err := s.db.QueryRow(`SELECT t.user_id, t.expires_at, t.used_at, u.is_active FROM password_reset_tokens t
JOIN users u ON u.id = t.user_id WHERE t.token_hash = ?`, hashResetToken(raw)).Scan(&userID, &expires, &used, &active)
if err != nil {
return "", ErrResetTokenInvalid
}
exp, perr := time.Parse(time.RFC3339, expires)
if used != "" || !active || perr != nil || time.Now().UTC().After(exp) {
return "", ErrResetTokenInvalid
}
now := time.Now().UTC().Format(time.RFC3339)
res, err := s.db.Exec(`UPDATE password_reset_tokens SET used_at = ? WHERE token_hash = ? AND used_at = ''`, now, hashResetToken(raw))
if err != nil {
return "", err
}
if n, _ := res.RowsAffected(); n == 0 {
return "", ErrResetTokenInvalid // used in parallel
}
_, _ = s.db.Exec(`UPDATE password_reset_tokens SET used_at = ? WHERE user_id = ? AND used_at = ''`, now, userID)
return userID, s.UpdateUserPassword(userID, passwordHash)
}
+51
View File
@@ -0,0 +1,51 @@
package storage
import (
"path/filepath"
"testing"
)
func TestPasswordResetToken(t *testing.T) {
store, err := NewStore(filepath.Join(t.TempDir(), "reset.db"))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
defer store.Close()
u, _ := store.CreateUser("anna@example.com", "old", "Anna", "member")
raw, err := store.CreatePasswordResetToken(u.ID)
if err != nil || len(raw) != 64 {
t.Fatalf("token: %q %v", raw, err)
}
var stored string
_ = store.db.QueryRow(`SELECT token_hash FROM password_reset_tokens`).Scan(&stored)
if stored == raw {
t.Fatalf("only the hash may be stored")
}
if _, err := store.CreatePasswordResetToken(u.ID); err != ErrResetTooSoon {
t.Errorf("second request within the cooldown must be refused, got %v", err)
}
before, _ := store.GetUserByID(u.ID)
if _, err := store.ResetPasswordWithToken("falsch", "new"); err != ErrResetTokenInvalid {
t.Errorf("unknown token must fail")
}
if id, err := store.ResetPasswordWithToken(raw, "new"); err != nil || id != u.ID {
t.Fatalf("reset: %v", err)
}
after, _ := store.GetUserByID(u.ID)
if after.PasswordHash != "new" || after.TokenVersion != before.TokenVersion+1 {
t.Errorf("password not changed or sessions not ended: %+v", after)
}
if _, err := store.ResetPasswordWithToken(raw, "again"); err != ErrResetTokenInvalid {
t.Errorf("a token works only once")
}
// Expired tokens fail
_, _ = store.db.Exec(`DELETE FROM password_reset_tokens`)
raw2, _ := store.CreatePasswordResetToken(u.ID)
_, _ = store.db.Exec(`UPDATE password_reset_tokens SET expires_at = '2000-01-01T00:00:00Z'`)
if _, err := store.ResetPasswordWithToken(raw2, "x"); err != ErrResetTokenInvalid {
t.Errorf("expired token must fail")
}
}
+3
View File
@@ -514,6 +514,9 @@ func (s *Store) migrate() error {
}
s.migrateAnchorTags()
s.migrateSearchFTS()
if err := s.migratePasswordReset(); err != nil {
return err
}
_, err := s.db.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, schemaVersion))
return err