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:
1 parent
677fe0fc0a
commit
5e20710aa7
16 files changed
+489
-8
No files matched your search
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user