/forgot mails a link to set a new password when the cluster's mail settings say how - SMTP relay, sender, and the public base_url the link points at, never the request's Host - and says the same whether or not the address has an account. /reset sets it once, closing every session. Links are kept by their hash in password_resets, good for an hour, a newer one replacing an older. Without mail, /forgot says to ask an operator, and llmgw users reset-link makes the link to hand over. Single sign-on accounts get none. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
64 lines
2.0 KiB
Go
64 lines
2.0 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"errors"
|
|
"time"
|
|
)
|
|
|
|
// CreatePasswordReset mints a one-time link token to set userID's password,
|
|
// good for ttl. Only its hash is kept.
|
|
func (s *DB) CreatePasswordReset(ctx context.Context, userID string, ttl time.Duration) (string, error) {
|
|
token, err := NewSessionToken()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
now := time.Now()
|
|
// Older links for the same person stop working: the newest is the one
|
|
// they were sent.
|
|
if _, err := s.db.ExecContext(ctx,
|
|
`UPDATE password_resets SET used_at = ? WHERE user_id = ? AND used_at = 0`, millis(now), userID); err != nil {
|
|
return "", err
|
|
}
|
|
_, err = s.db.ExecContext(ctx,
|
|
`INSERT INTO password_resets (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)`,
|
|
resetHash(token), userID, millis(now), millis(now.Add(ttl)))
|
|
return token, err
|
|
}
|
|
|
|
// PasswordResetUser is whose a reset token is, while it is good: ErrNotFound
|
|
// for one used, expired or never made.
|
|
func (s *DB) PasswordResetUser(ctx context.Context, token string) (string, error) {
|
|
var userID string
|
|
err := s.db.QueryRowContext(ctx,
|
|
`SELECT user_id FROM password_resets WHERE token_hash = ? AND used_at = 0 AND expires_at > ?`,
|
|
resetHash(token), millis(time.Now())).Scan(&userID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return "", ErrNotFound
|
|
}
|
|
return userID, err
|
|
}
|
|
|
|
// UsePasswordReset spends a reset token, returning whose it was. Of two uses
|
|
// at once, one wins and the other gets ErrNotFound.
|
|
func (s *DB) UsePasswordReset(ctx context.Context, token string) (string, error) {
|
|
userID, err := s.PasswordResetUser(ctx, token)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
res, err := s.db.ExecContext(ctx,
|
|
`UPDATE password_resets SET used_at = ? WHERE token_hash = ? AND used_at = 0`,
|
|
millis(time.Now()), resetHash(token))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if n, _ := res.RowsAffected(); n != 1 {
|
|
return "", ErrNotFound
|
|
}
|
|
return userID, nil
|
|
}
|
|
|
|
func resetHash(token string) string { return hex.EncodeToString(HashKey(token)) }
|