239 lines
7.4 KiB
Go
239 lines
7.4 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// AccountLockoutService gère le verrouillage de compte après tentatives échouées
|
|
// BE-SEC-007: Implement account lockout after failed login attempts
|
|
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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
return &AccountLockoutService{
|
|
redisClient: redisClient,
|
|
logger: logger,
|
|
maxAttempts: config.MaxAttempts,
|
|
lockoutDuration: config.LockoutDuration,
|
|
windowDuration: config.WindowDuration,
|
|
}
|
|
}
|
|
|
|
// RecordFailedAttempt enregistre une tentative de login échouée
|
|
func (s *AccountLockoutService) RecordFailedAttempt(ctx context.Context, email string) error {
|
|
if s.redisClient == nil {
|
|
// Si Redis n'est pas disponible, on ne peut pas tracker les tentatives
|
|
// On retourne nil pour ne pas bloquer le login
|
|
s.logger.Warn("Redis not available - account lockout disabled")
|
|
return nil
|
|
}
|
|
|
|
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 nil
|
|
}
|
|
|
|
// 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.redisClient == nil {
|
|
return false, nil, nil
|
|
}
|
|
|
|
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)
|
|
}
|