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/)
336 lines
10 KiB
Go
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
|
|
}
|