veza/veza-backend-api/internal/services/login_history_service.go
senke e4dd09a909
Some checks failed
Backend API CI / test-unit (push) Failing after 0s
Backend API CI / test-integration (push) Failing after 0s
feat(v0.13.0): conformité features partielles — CAPTCHA, password history, login history, SMS 2FA
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>
2026-03-12 09:31:50 +01:00

92 lines
2.8 KiB
Go

package services
import (
"context"
"fmt"
"time"
"veza-backend-api/internal/database"
"github.com/google/uuid"
"go.uber.org/zap"
)
// LoginHistoryEntry represents a single login attempt record.
// F024: ORIGIN_FEATURES_REGISTRY.md — login history tracking.
type LoginHistoryEntry struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
IP string `json:"ip_address"`
UserAgent string `json:"user_agent"`
Success bool `json:"success"`
Reason string `json:"reason,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// LoginHistoryService tracks login attempts for security auditing.
type LoginHistoryService struct {
db *database.Database
logger *zap.Logger
}
// NewLoginHistoryService creates a login history service.
func NewLoginHistoryService(db *database.Database, logger *zap.Logger) *LoginHistoryService {
return &LoginHistoryService{db: db, logger: logger}
}
// Record stores a login attempt (success or failure).
func (s *LoginHistoryService) Record(ctx context.Context, userID uuid.UUID, ip, userAgent string, success bool, reason string) {
_, err := s.db.ExecContext(ctx, `
INSERT INTO login_history (id, user_id, ip_address, user_agent, success, reason, created_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
`, uuid.New(), userID, ip, userAgent, success, reason)
if err != nil {
// Non-blocking: login history is audit-only
s.logger.Debug("failed to record login history (table may not exist)", zap.Error(err))
}
}
// GetUserHistory returns the last N login entries for a user.
func (s *LoginHistoryService) GetUserHistory(ctx context.Context, userID uuid.UUID, limit int) ([]LoginHistoryEntry, error) {
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
rows, err := s.db.QueryContext(ctx, `
SELECT id, user_id, ip_address, user_agent, success, COALESCE(reason, ''), created_at
FROM login_history
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2
`, userID, limit)
if err != nil {
return nil, fmt.Errorf("query login history: %w", err)
}
defer rows.Close()
var entries []LoginHistoryEntry
for rows.Next() {
var e LoginHistoryEntry
if err := rows.Scan(&e.ID, &e.UserID, &e.IP, &e.UserAgent, &e.Success, &e.Reason, &e.CreatedAt); err != nil {
continue
}
entries = append(entries, e)
}
return entries, nil
}
// CountRecentFailures returns the number of failed login attempts in the given window.
func (s *LoginHistoryService) CountRecentFailures(ctx context.Context, userID uuid.UUID, window time.Duration) (int, error) {
var count int
err := s.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM login_history
WHERE user_id = $1 AND success = false AND created_at > $2
`, userID, time.Now().Add(-window)).Scan(&count)
if err != nil {
return 0, err
}
return count, nil
}