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