Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// DefaultPasswordResetTTL is the self-serve reset link lifetime.
|
||||
const DefaultPasswordResetTTL = time.Hour
|
||||
|
||||
// PasswordResetIssue is returned once when a reset token is created (plaintext token for email only).
|
||||
type PasswordResetIssue struct {
|
||||
UserID uuid.UUID
|
||||
Email string
|
||||
Token string
|
||||
}
|
||||
|
||||
// IssuePasswordReset creates a durable hashed reset token for an active user with a deliverable email.
|
||||
// Unknown, inactive, and synthetic @legacy.local addresses return ErrUserNotFound / ErrSyntheticEmail
|
||||
// so callers can respond opaquely without enumeration.
|
||||
func (s *Service) IssuePasswordReset(ctx context.Context, email string, ttl time.Duration) (PasswordResetIssue, error) {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
if email == "" {
|
||||
return PasswordResetIssue{}, ErrEmailRequired
|
||||
}
|
||||
if IsSyntheticLegacyEmail(email) {
|
||||
return PasswordResetIssue{}, ErrSyntheticEmail
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = DefaultPasswordResetTTL
|
||||
}
|
||||
|
||||
var (
|
||||
userID uuid.UUID
|
||||
isActive bool
|
||||
)
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, is_active
|
||||
FROM users
|
||||
WHERE lower(email) = $1`, email).Scan(&userID, &isActive)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return PasswordResetIssue{}, ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return PasswordResetIssue{}, err
|
||||
}
|
||||
if !isActive {
|
||||
return PasswordResetIssue{}, ErrUserNotFound
|
||||
}
|
||||
|
||||
token, err := RandomToken(24)
|
||||
if err != nil {
|
||||
return PasswordResetIssue{}, err
|
||||
}
|
||||
expiresAt := time.Now().UTC().Add(ttl)
|
||||
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return PasswordResetIssue{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// Invalidate prior unused tokens so only the latest link works.
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE password_reset_tokens
|
||||
SET expires_at = least(expires_at, now())
|
||||
WHERE user_id = $1 AND consumed_at IS NULL AND expires_at > now()`, userID); err != nil {
|
||||
return PasswordResetIssue{}, err
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO password_reset_tokens (user_id, token_hash, expires_at)
|
||||
VALUES ($1, $2, $3)`, userID, HashInviteToken(token), expiresAt); err != nil {
|
||||
return PasswordResetIssue{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return PasswordResetIssue{}, err
|
||||
}
|
||||
|
||||
return PasswordResetIssue{UserID: userID, Email: email, Token: token}, nil
|
||||
}
|
||||
|
||||
// ResetPasswordWithToken consumes a one-time reset token and sets a new password
|
||||
// regardless of must_set_password (dedicated reset path — not SetPassword / ForceSetPassword).
|
||||
func (s *Service) ResetPasswordWithToken(ctx context.Context, rawToken, password string) error {
|
||||
rawToken = strings.TrimSpace(rawToken)
|
||||
if rawToken == "" {
|
||||
return ErrTokenInvalid
|
||||
}
|
||||
passwordHash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var (
|
||||
tokenID uuid.UUID
|
||||
userID uuid.UUID
|
||||
expiresAt time.Time
|
||||
consumedAt *time.Time
|
||||
)
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT id, user_id, expires_at, consumed_at
|
||||
FROM password_reset_tokens
|
||||
WHERE token_hash = $1
|
||||
FOR UPDATE`, HashInviteToken(rawToken)).Scan(&tokenID, &userID, &expiresAt, &consumedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrTokenInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if consumedAt != nil || !expiresAt.After(time.Now().UTC()) {
|
||||
return ErrTokenInvalid
|
||||
}
|
||||
|
||||
var isActive bool
|
||||
err = tx.QueryRow(ctx, `SELECT is_active FROM users WHERE id = $1 FOR UPDATE`, userID).Scan(&isActive)
|
||||
if errors.Is(err, pgx.ErrNoRows) || (err == nil && !isActive) {
|
||||
return ErrTokenInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ct, err := tx.Exec(ctx, `
|
||||
UPDATE users
|
||||
SET password_hash = $2,
|
||||
must_set_password = false,
|
||||
session_version = session_version + 1,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND is_active = true`, userID, passwordHash)
|
||||
if isUndefinedColumn(err) {
|
||||
// Pre-042 DBs: still reset password; session revoke requires session_version migration.
|
||||
ct, err = tx.Exec(ctx, `
|
||||
UPDATE users
|
||||
SET password_hash = $2, must_set_password = false, updated_at = now()
|
||||
WHERE id = $1 AND is_active = true`, userID, passwordHash)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
return ErrTokenInvalid
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE password_reset_tokens
|
||||
SET consumed_at = now()
|
||||
WHERE id = $1`, tokenID); err != nil {
|
||||
return err
|
||||
}
|
||||
// Expire any sibling unused tokens for this user.
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE password_reset_tokens
|
||||
SET expires_at = least(expires_at, now())
|
||||
WHERE user_id = $1 AND id <> $2 AND consumed_at IS NULL AND expires_at > now()`,
|
||||
userID, tokenID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
Reference in New Issue
Block a user