Files
enkly-ai/services/iam/internal/infrastructure/postgres/verification_repo.go
enkly 79ce8dc4c0
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s
Init Project
2026-10-30 18:06:55 +01:00

179 lines
4.9 KiB
Go

package postgres
import (
"context"
"crypto/subtle"
"database/sql"
"errors"
"time"
"enkly/pkg/pgkit"
"enkly/services/iam/internal/domain"
)
// maxVerificationAttempts caps how many wrong codes a single
// verification row accepts before it's dead even to the correct code —
// see domain.VerificationRepository's doc comment.
const maxVerificationAttempts = 8
// VerificationRepo implements domain.VerificationRepository.
type VerificationRepo struct {
DB *pgkit.DB
}
func NewVerificationRepo(db *pgkit.DB) *VerificationRepo { return &VerificationRepo{DB: db} }
func (r *VerificationRepo) Create(ctx context.Context, userID int64, codeHash string, expiresAt time.Time) error {
tx, err := r.DB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`DELETE FROM email_verification_codes WHERE user_id = $1 AND used_at IS NULL`, userID); err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO email_verification_codes (user_id, code_hash, expires_at) VALUES ($1, $2, $3)`,
userID, codeHash, expiresAt); err != nil {
return err
}
return tx.Commit()
}
func (r *VerificationRepo) VerifyAndConsume(ctx context.Context, userID int64, codeHash string) error {
tx, err := r.DB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var verified bool
if err := tx.QueryRowContext(ctx, `SELECT email_verified FROM users WHERE id = $1 FOR UPDATE`, userID).Scan(&verified); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return domain.ErrNotFound
}
return err
}
if verified {
return nil // idempotent for an already-verified account
}
var (
rowID int64
storedHash string
attempts int
expiresAt time.Time
)
err = tx.QueryRowContext(ctx, `
SELECT id, code_hash, attempts, expires_at
FROM email_verification_codes
WHERE user_id = $1 AND used_at IS NULL
ORDER BY created_at DESC LIMIT 1
FOR UPDATE`, userID).Scan(&rowID, &storedHash, &attempts, &expiresAt)
if errors.Is(err, sql.ErrNoRows) {
return domain.ErrInvalidToken
}
if err != nil {
return err
}
if attempts >= maxVerificationAttempts || time.Now().After(expiresAt) {
return domain.ErrInvalidToken
}
if subtle.ConstantTimeCompare([]byte(storedHash), []byte(codeHash)) != 1 {
if _, err := tx.ExecContext(ctx,
`UPDATE email_verification_codes SET attempts = attempts + 1 WHERE id = $1`, rowID); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
return domain.ErrInvalidToken
}
if _, err := tx.ExecContext(ctx,
`UPDATE email_verification_codes SET used_at = now() WHERE id = $1`, rowID); err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`UPDATE users SET email_verified = true WHERE id = $1`, userID); err != nil {
return err
}
return tx.Commit()
}
// ---------------------------------------------------------------------
// Password reset tokens
// ---------------------------------------------------------------------
// PasswordResetRepo implements domain.PasswordResetRepository.
type PasswordResetRepo struct {
DB *pgkit.DB
}
func NewPasswordResetRepo(db *pgkit.DB) *PasswordResetRepo { return &PasswordResetRepo{DB: db} }
func (r *PasswordResetRepo) Create(ctx context.Context, userID int64, tokenHash string, expiresAt time.Time) error {
tx, err := r.DB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`DELETE FROM password_reset_tokens WHERE user_id = $1 AND used_at IS NULL`, userID); err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`,
userID, tokenHash, expiresAt); err != nil {
return err
}
return tx.Commit()
}
func (r *PasswordResetRepo) Valid(ctx context.Context, tokenHash string) (bool, error) {
var ok bool
err := r.DB.QueryRowContext(ctx, `
SELECT EXISTS (
SELECT 1 FROM password_reset_tokens
WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now()
)`, tokenHash).Scan(&ok)
return ok, err
}
func (r *PasswordResetRepo) ConsumeAndResetPassword(ctx context.Context, tokenHash, newPasswordHash string) (int64, error) {
tx, err := r.DB.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback()
var userID int64
err = tx.QueryRowContext(ctx, `
UPDATE password_reset_tokens SET used_at = now()
WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now()
RETURNING user_id`, tokenHash).Scan(&userID)
if errors.Is(err, sql.ErrNoRows) {
return 0, domain.ErrInvalidToken
}
if err != nil {
return 0, err
}
if _, err := tx.ExecContext(ctx,
`UPDATE users SET password_hash = $2 WHERE id = $1`, userID, newPasswordHash); err != nil {
return 0, err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = $1`, userID); err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return userID, nil
}