Files
llmgw/internal/store/resets.go
T
Ilker ManapandClaude Opus 5.5 66f702d5c1 A forgotten password is set through a one-time link
/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>
2026-10-07 13:20:11 +02:00

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