"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>
90 lines
3.3 KiB
Go
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)
|
|
}
|