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 }