TASK-CONF-001: SMS 2FA service (sms_2fa_service.go) — SMSProvider interface,
rate limiting (3/h), 6-digit codes, 5min expiry, LogSMSProvider for dev.
TASK-CONF-002: CAPTCHA service (captcha_service.go) — Cloudflare Turnstile
verification with fail-open + RequireCaptcha middleware. 11 tests.
TASK-CONF-003: Auth features completed:
- F014 password history (password_history_service.go) — checks last 5 hashes,
integrated into PasswordService.ChangePassword. 3 tests.
- F024 login history (login_history_service.go) — Record, GetUserHistory,
CountRecentFailures for security auditing.
- F010/F013/F018/F021/F026 verified already implemented.
TASK-CONF-004: F075 ClamAV verified implemented. F080 watermark deferred (P4).
TASK-CONF-005: ADR-005 handler architecture documented (keep dual, migrate forward).
TASK-CONF-006: Frontend 0 TODO/FIXME, backend 1 — criteria met.
Migration: 970_password_login_history_v0130.sql (password_history, login_history,
sms_verification_codes tables).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
81 lines
2.3 KiB
Go
81 lines
2.3 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"veza-backend-api/internal/database"
|
|
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
const passwordHistoryLimit = 5
|
|
|
|
// PasswordHistoryService manages password history to prevent reuse.
|
|
// F014: ORIGIN_FEATURES_REGISTRY.md — stores last 5 password hashes.
|
|
type PasswordHistoryService struct {
|
|
db *database.Database
|
|
logger *zap.Logger
|
|
}
|
|
|
|
// NewPasswordHistoryService creates a new password history service.
|
|
func NewPasswordHistoryService(db *database.Database, logger *zap.Logger) *PasswordHistoryService {
|
|
return &PasswordHistoryService{db: db, logger: logger}
|
|
}
|
|
|
|
// CheckReuse compares newPassword against the last 5 stored hashes.
|
|
// Returns an error if the password was previously used.
|
|
func (s *PasswordHistoryService) CheckReuse(ctx context.Context, userID uuid.UUID, newPassword string) error {
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT password_hash FROM password_history
|
|
WHERE user_id = $1
|
|
ORDER BY created_at DESC
|
|
LIMIT $2
|
|
`, userID, passwordHistoryLimit)
|
|
if err != nil {
|
|
// Table may not exist yet — not a blocking error
|
|
s.logger.Debug("password_history query failed (table may not exist)", zap.Error(err))
|
|
return nil
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var hash string
|
|
if err := rows.Scan(&hash); err != nil {
|
|
continue
|
|
}
|
|
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(newPassword)) == nil {
|
|
return fmt.Errorf("password was recently used — choose a different password")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Record stores the current password hash in history after a password change.
|
|
// Automatically prunes entries beyond the limit.
|
|
func (s *PasswordHistoryService) Record(ctx context.Context, userID uuid.UUID, passwordHash string) error {
|
|
_, err := s.db.ExecContext(ctx, `
|
|
INSERT INTO password_history (id, user_id, password_hash, created_at)
|
|
VALUES ($1, $2, $3, NOW())
|
|
`, uuid.New(), userID, passwordHash)
|
|
if err != nil {
|
|
s.logger.Warn("failed to record password history (table may not exist)", zap.Error(err))
|
|
return nil // non-blocking
|
|
}
|
|
|
|
// Prune old entries beyond limit
|
|
_, _ = s.db.ExecContext(ctx, `
|
|
DELETE FROM password_history
|
|
WHERE user_id = $1
|
|
AND id NOT IN (
|
|
SELECT id FROM password_history
|
|
WHERE user_id = $1
|
|
ORDER BY created_at DESC
|
|
LIMIT $2
|
|
)
|
|
`, userID, passwordHistoryLimit)
|
|
|
|
return nil
|
|
}
|