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>
92 lines
2.8 KiB
Go
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
|
|
}
|