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:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
+173
View File
@@ -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)
}