Files
MindSift/internal/storage/password_reset.go
T
sandrodachandClaude Opus 5.5 5e20710aa7 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>
2026-10-29 08:06:56 +01:00

90 lines
3.3 KiB
Go

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