Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
174 lines
4.6 KiB
Go
174 lines
4.6 KiB
Go
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)
|
|
}
|