veza/veza-backend-api/internal/services/account_lockout_service.go
senke ae586f6134 Phase 2 stabilisation: code mort, Modal→Dialog, feature flags, tests, router split, Rust legacy
Bloc A - Code mort:
- Suppression Studio (components, views, features)
- Suppression gamification + services mock (projectService, storageService, gamificationService)
- Mise à jour Sidebar, Navbar, locales

Bloc B - Frontend:
- Suppression modal.tsx deprecated, Modal.stories (doublon Dialog)
- Feature flags: PLAYLIST_SEARCH, PLAYLIST_RECOMMENDATIONS, ROLE_MANAGEMENT = true
- Suppression 19 tests orphelins, retrait exclusions vitest.config

Bloc C - Backend:
- Extraction routes_auth.go depuis router.go

Bloc D - Rust:
- Suppression security_legacy.rs (code mort, patterns déjà dans security/)
2026-02-14 17:23:32 +01:00

336 lines
10 KiB
Go

package services
import (
"context"
"fmt"
"strings"
"sync"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
// lockoutEntry holds in-memory lockout state for fallback when Redis is unavailable
type lockoutEntry struct {
attempts int
windowStart time.Time
lockedUntil *time.Time
}
// AccountLockoutService gère le verrouillage de compte après tentatives échouées
// BE-SEC-007: Implement account lockout after failed login attempts
// Uses in-memory fallback when Redis is unavailable (fail-secure instead of fail-open)
type AccountLockoutService struct {
redisClient *redis.Client
logger *zap.Logger
maxAttempts int // Nombre maximum de tentatives avant verrouillage (défaut: 5)
lockoutDuration time.Duration // Durée du verrouillage (défaut: 30 minutes)
windowDuration time.Duration // Fenêtre de temps pour compter les tentatives (défaut: 15 minutes)
exemptEmails map[string]struct{} // Emails exemptés du lockout (ex: testuser@example.com)
inMemoryStore map[string]*lockoutEntry
inMemoryMu sync.RWMutex
}
// AccountLockoutConfig configuration pour le service de verrouillage
type AccountLockoutConfig struct {
MaxAttempts int // Nombre maximum de tentatives (défaut: 5)
LockoutDuration time.Duration // Durée du verrouillage (défaut: 30 minutes)
WindowDuration time.Duration // Fenêtre de temps pour compter les tentatives (défaut: 15 minutes)
ExemptEmails []string // Emails qui ne sont jamais verrouillés (ex: comptes de test)
}
// DefaultAccountLockoutConfig retourne la configuration par défaut
func DefaultAccountLockoutConfig() *AccountLockoutConfig {
return &AccountLockoutConfig{
MaxAttempts: 5,
LockoutDuration: 30 * time.Minute,
WindowDuration: 15 * time.Minute,
}
}
// NewAccountLockoutService crée un nouveau service de verrouillage de compte
func NewAccountLockoutService(redisClient *redis.Client, logger *zap.Logger) *AccountLockoutService {
config := DefaultAccountLockoutConfig()
return &AccountLockoutService{
redisClient: redisClient,
logger: logger,
maxAttempts: config.MaxAttempts,
lockoutDuration: config.LockoutDuration,
windowDuration: config.WindowDuration,
inMemoryStore: make(map[string]*lockoutEntry),
}
}
// NewAccountLockoutServiceWithConfig crée un nouveau service avec configuration personnalisée
func NewAccountLockoutServiceWithConfig(redisClient *redis.Client, logger *zap.Logger, config *AccountLockoutConfig) *AccountLockoutService {
if config == nil {
config = DefaultAccountLockoutConfig()
}
exempt := make(map[string]struct{})
for _, e := range config.ExemptEmails {
normalized := strings.TrimSpace(strings.ToLower(e))
if normalized != "" {
exempt[normalized] = struct{}{}
}
}
return &AccountLockoutService{
redisClient: redisClient,
logger: logger,
maxAttempts: config.MaxAttempts,
lockoutDuration: config.LockoutDuration,
windowDuration: config.WindowDuration,
exemptEmails: exempt,
inMemoryStore: make(map[string]*lockoutEntry),
}
}
// isExempt returns true if the email is exempt from lockout (e.g. test accounts).
func (s *AccountLockoutService) isExempt(email string) bool {
if len(s.exemptEmails) == 0 {
return false
}
_, ok := s.exemptEmails[strings.TrimSpace(strings.ToLower(email))]
return ok
}
// RecordFailedAttempt enregistre une tentative de login échouée
func (s *AccountLockoutService) RecordFailedAttempt(ctx context.Context, email string) error {
if s.isExempt(email) {
return nil
}
if s.redisClient == nil {
// Fallback in-memory when Redis unavailable (fail-secure)
s.logger.Warn("Redis not available - using in-memory account lockout fallback")
return s.recordFailedAttemptInMemory(ctx, email)
}
key := s.getAttemptsKey(email)
// Incrémenter le compteur de tentatives
count, err := s.redisClient.Incr(ctx, key).Result()
if err != nil {
return fmt.Errorf("failed to record failed attempt: %w", err)
}
// Définir l'expiration de la clé (fenêtre de temps)
if count == 1 {
// Première tentative, définir l'expiration
if err := s.redisClient.Expire(ctx, key, s.windowDuration).Err(); err != nil {
s.logger.Warn("Failed to set expiration for attempts key", zap.Error(err))
}
}
// Si on atteint le maximum de tentatives, verrouiller le compte
if count >= int64(s.maxAttempts) {
if err := s.LockAccount(ctx, email); err != nil {
s.logger.Error("Failed to lock account after max attempts",
zap.String("email", email),
zap.Int64("attempts", count),
zap.Error(err))
return err
}
s.logger.Warn("Account locked due to too many failed login attempts",
zap.String("email", email),
zap.Int64("attempts", count))
}
return nil
}
// RecordSuccessfulLogin réinitialise le compteur de tentatives échouées après un login réussi
func (s *AccountLockoutService) RecordSuccessfulLogin(ctx context.Context, email string) error {
if s.redisClient == nil {
return s.recordSuccessfulLoginInMemory(ctx, email)
}
// Supprimer le compteur de tentatives échouées
key := s.getAttemptsKey(email)
if err := s.redisClient.Del(ctx, key).Err(); err != nil {
s.logger.Warn("Failed to reset failed attempts counter",
zap.String("email", email),
zap.Error(err))
return err
}
// Déverrouiller le compte si il était verrouillé
if err := s.UnlockAccount(ctx, email); err != nil {
s.logger.Warn("Failed to unlock account after successful login",
zap.String("email", email),
zap.Error(err))
// Ne pas retourner d'erreur, c'est non-critique
}
return nil
}
// IsAccountLocked vérifie si un compte est verrouillé
func (s *AccountLockoutService) IsAccountLocked(ctx context.Context, email string) (bool, *time.Time, error) {
if s.isExempt(email) {
return false, nil, nil
}
if s.redisClient == nil {
return s.isAccountLockedInMemory(ctx, email)
}
key := s.getLockoutKey(email)
lockedUntilStr, err := s.redisClient.Get(ctx, key).Result()
if err == redis.Nil {
// Pas de verrouillage
return false, nil, nil
}
if err != nil {
return false, nil, fmt.Errorf("failed to check account lockout: %w", err)
}
// Parser la date de déverrouillage
lockedUntil, err := time.Parse(time.RFC3339, lockedUntilStr)
if err != nil {
s.logger.Warn("Failed to parse lockout expiration",
zap.String("email", email),
zap.String("locked_until", lockedUntilStr),
zap.Error(err))
// Si on ne peut pas parser, considérer comme non verrouillé
return false, nil, nil
}
// Vérifier si le verrouillage a expiré
if time.Now().After(lockedUntil) {
// Le verrouillage a expiré, le supprimer
if err := s.UnlockAccount(ctx, email); err != nil {
s.logger.Warn("Failed to unlock expired account",
zap.String("email", email),
zap.Error(err))
}
return false, nil, nil
}
return true, &lockedUntil, nil
}
// LockAccount verrouille un compte
func (s *AccountLockoutService) LockAccount(ctx context.Context, email string) error {
if s.redisClient == nil {
return nil
}
key := s.getLockoutKey(email)
lockedUntil := time.Now().Add(s.lockoutDuration)
lockedUntilStr := lockedUntil.Format(time.RFC3339)
if err := s.redisClient.Set(ctx, key, lockedUntilStr, s.lockoutDuration).Err(); err != nil {
return fmt.Errorf("failed to lock account: %w", err)
}
return nil
}
// UnlockAccount déverrouille un compte
func (s *AccountLockoutService) UnlockAccount(ctx context.Context, email string) error {
if s.redisClient == nil {
return nil
}
key := s.getLockoutKey(email)
if err := s.redisClient.Del(ctx, key).Err(); err != nil {
return fmt.Errorf("failed to unlock account: %w", err)
}
// Aussi supprimer le compteur de tentatives
attemptsKey := s.getAttemptsKey(email)
if err := s.redisClient.Del(ctx, attemptsKey).Err(); err != nil {
s.logger.Warn("Failed to delete attempts counter",
zap.String("email", email),
zap.Error(err))
// Non-critique
}
return nil
}
// GetFailedAttemptsCount retourne le nombre de tentatives échouées pour un email
func (s *AccountLockoutService) GetFailedAttemptsCount(ctx context.Context, email string) (int, error) {
if s.redisClient == nil {
return 0, nil
}
key := s.getAttemptsKey(email)
count, err := s.redisClient.Get(ctx, key).Int()
if err == redis.Nil {
return 0, nil
}
if err != nil {
return 0, fmt.Errorf("failed to get failed attempts count: %w", err)
}
return count, nil
}
// getAttemptsKey génère la clé Redis pour le compteur de tentatives
func (s *AccountLockoutService) getAttemptsKey(email string) string {
return fmt.Sprintf("account_lockout:attempts:%s", email)
}
// getLockoutKey génère la clé Redis pour le statut de verrouillage
func (s *AccountLockoutService) getLockoutKey(email string) string {
return fmt.Sprintf("account_lockout:locked:%s", email)
}
// recordFailedAttemptInMemory implements lockout logic in-memory when Redis is unavailable
func (s *AccountLockoutService) recordFailedAttemptInMemory(_ context.Context, email string) error {
key := strings.TrimSpace(strings.ToLower(email))
s.inMemoryMu.Lock()
defer s.inMemoryMu.Unlock()
entry := s.inMemoryStore[key]
now := time.Now()
if entry == nil {
entry = &lockoutEntry{attempts: 0, windowStart: now}
s.inMemoryStore[key] = entry
}
// Reset window if expired
if now.Sub(entry.windowStart) >= s.windowDuration {
entry.attempts = 0
entry.windowStart = now
}
entry.attempts++
if entry.attempts >= s.maxAttempts {
lockedUntil := now.Add(s.lockoutDuration)
entry.lockedUntil = &lockedUntil
s.logger.Warn("Account locked (in-memory fallback) due to too many failed login attempts",
zap.String("email", email),
zap.Int("attempts", entry.attempts))
}
return nil
}
// isAccountLockedInMemory checks lockout status in memory
func (s *AccountLockoutService) isAccountLockedInMemory(_ context.Context, email string) (bool, *time.Time, error) {
key := strings.TrimSpace(strings.ToLower(email))
s.inMemoryMu.RLock()
entry := s.inMemoryStore[key]
s.inMemoryMu.RUnlock()
if entry == nil || entry.lockedUntil == nil {
return false, nil, nil
}
if time.Now().After(*entry.lockedUntil) {
s.inMemoryMu.Lock()
entry.lockedUntil = nil
entry.attempts = 0
s.inMemoryMu.Unlock()
return false, nil, nil
}
return true, entry.lockedUntil, nil
}
// recordSuccessfulLoginInMemory clears in-memory lockout state
func (s *AccountLockoutService) recordSuccessfulLoginInMemory(_ context.Context, email string) error {
key := strings.TrimSpace(strings.ToLower(email))
s.inMemoryMu.Lock()
defer s.inMemoryMu.Unlock()
delete(s.inMemoryStore, key)
return nil
}