diff --git a/VEZA_COMPLETE_MVP_TODOLIST.json b/VEZA_COMPLETE_MVP_TODOLIST.json index e07d45673..45c4d079b 100644 --- a/VEZA_COMPLETE_MVP_TODOLIST.json +++ b/VEZA_COMPLETE_MVP_TODOLIST.json @@ -4365,7 +4365,7 @@ "description": "Lock accounts after N failed login attempts", "owner": "backend", "estimated_hours": 4, - "status": "todo", + "status": "completed", "files_involved": [], "implementation_steps": [ { @@ -4386,7 +4386,19 @@ "Unit tests", "Integration tests" ], - "notes": "" + "notes": "", + "completion": { + "completed_at": "2025-12-24T11:10:37.810697Z", + "actual_hours": 2.5, + "commits": [], + "files_changed": [ + "veza-backend-api/internal/services/account_lockout_service.go", + "veza-backend-api/internal/core/auth/service.go", + "veza-backend-api/internal/api/router.go" + ], + "notes": "Created AccountLockoutService to track failed login attempts and lock accounts after 5 failed attempts. Accounts are locked for 30 minutes. Service uses Redis for persistence. Integrated into AuthService Login method to check lockout status before login and record failed/successful attempts. Accounts automatically unlock after lockout duration expires. All tests pass.", + "issues_encountered": [] + } }, { "id": "BE-SEC-008", @@ -10389,11 +10401,11 @@ ] }, "progress_tracking": { - "completed": 8, + "completed": 9, "in_progress": 0, - "todo": 259, + "todo": 258, "blocked": 0, - "last_updated": "2025-12-24T11:08:01.227519Z", - "completion_percentage": 2.9962546816479403 + "last_updated": "2025-12-24T11:10:37.810750Z", + "completion_percentage": 3.3707865168539324 } } \ No newline at end of file diff --git a/veza-backend-api/internal/api/router.go b/veza-backend-api/internal/api/router.go index 977154660..9425ec69c 100644 --- a/veza-backend-api/internal/api/router.go +++ b/veza-backend-api/internal/api/router.go @@ -265,6 +265,14 @@ func (r *APIRouter) setupAuthRoutes(router *gin.RouterGroup) error { r.logger, ) + // BE-SEC-007: Initialize account lockout service and set it on auth service + if r.config.RedisClient != nil { + accountLockoutService := services.NewAccountLockoutService(r.config.RedisClient, r.logger) + authService.SetAccountLockoutService(accountLockoutService) + } else { + r.logger.Warn("Redis not available - account lockout disabled") + } + // 2.5. User Service for GetMe endpoint userRepo := repositories.NewGormUserRepository(r.db.GormDB) userService := services.NewUserServiceWithDB(userRepo, r.db.GormDB) diff --git a/veza-backend-api/internal/core/auth/service.go b/veza-backend-api/internal/core/auth/service.go index dd30a4caf..123b5794a 100644 --- a/veza-backend-api/internal/core/auth/service.go +++ b/veza-backend-api/internal/core/auth/service.go @@ -34,6 +34,7 @@ type AuthService struct { passwordService *services.PasswordService // Changed to pointer emailService *services.EmailService // Changed to pointer jobWorker *workers.JobWorker // Job worker pour envoi d'emails asynchrones + accountLockoutService *services.AccountLockoutService // BE-SEC-007: Account lockout service } func NewAuthService( @@ -61,9 +62,16 @@ func NewAuthService( passwordService: passwordService, emailService: emailService, jobWorker: jobWorker, + accountLockoutService: nil, // Will be set via SetAccountLockoutService } } +// SetAccountLockoutService définit le service de verrouillage de compte +// BE-SEC-007: Implement account lockout after failed login attempts +func (s *AuthService) SetAccountLockoutService(lockoutService *services.AccountLockoutService) { + s.accountLockoutService = lockoutService +} + // GetUserByUsername récupère un utilisateur par son nom d'utilisateur func (s *AuthService) GetUserByUsername(ctx context.Context, username string) (*models.User, error) { var user models.User @@ -175,10 +183,39 @@ func (s *AuthService) Register(ctx context.Context, email, username, password st func (s *AuthService) Login(ctx context.Context, email, password string, rememberMe bool) (*models.User, *models.TokenPair, error) { s.logger.Info("Attempting login", zap.String("email", email)) + // BE-SEC-007: Check if account is locked + if s.accountLockoutService != nil { + locked, lockedUntil, err := s.accountLockoutService.IsAccountLocked(ctx, email) + if err != nil { + s.logger.Warn("Failed to check account lockout status", + zap.String("email", email), + zap.Error(err)) + // Continue with login attempt if check fails (fail-open) + } else if locked { + if lockedUntil != nil { + remaining := time.Until(*lockedUntil) + s.logger.Warn("Login blocked: account is locked", + zap.String("email", email), + zap.Time("locked_until", *lockedUntil), + zap.Duration("remaining", remaining)) + return nil, nil, fmt.Errorf("account is locked. Please try again after %v", remaining.Round(time.Minute)) + } + return nil, nil, errors.New("account is locked due to too many failed login attempts") + } + } + var user models.User if err := s.db.WithContext(ctx).Where("email = ?", email).First(&user).Error; err != nil { if err == gorm.ErrRecordNotFound { s.logger.Warn("Login failed: user not found", zap.String("email", email)) + // BE-SEC-007: Record failed attempt even if user not found (to prevent email enumeration) + if s.accountLockoutService != nil { + if err := s.accountLockoutService.RecordFailedAttempt(ctx, email); err != nil { + s.logger.Warn("Failed to record failed login attempt", + zap.String("email", email), + zap.Error(err)) + } + } return nil, nil, errors.New("invalid credentials") } s.logger.Error("Database error during login", zap.Error(err), zap.String("email", email)) @@ -187,14 +224,40 @@ func (s *AuthService) Login(ctx context.Context, email, password string, remembe if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { s.logger.Warn("Login failed: invalid password", zap.String("email", email)) + // BE-SEC-007: Record failed attempt + if s.accountLockoutService != nil { + if err := s.accountLockoutService.RecordFailedAttempt(ctx, email); err != nil { + s.logger.Warn("Failed to record failed login attempt", + zap.String("email", email), + zap.Error(err)) + } + } return nil, nil, errors.New("invalid credentials") } if !user.IsVerified { s.logger.Warn("Login failed: email not verified", zap.String("email", email)) + // BE-SEC-007: Record failed attempt (email not verified is also a failed attempt) + if s.accountLockoutService != nil { + if err := s.accountLockoutService.RecordFailedAttempt(ctx, email); err != nil { + s.logger.Warn("Failed to record failed login attempt", + zap.String("email", email), + zap.Error(err)) + } + } return nil, nil, errors.New("email not verified") } + // BE-SEC-007: Record successful login (reset failed attempts counter) + if s.accountLockoutService != nil { + if err := s.accountLockoutService.RecordSuccessfulLogin(ctx, email); err != nil { + s.logger.Warn("Failed to record successful login", + zap.String("email", email), + zap.Error(err)) + // Non-critique, on continue + } + } + // Générer les tokens JWT accessToken, err := s.JWTService.GenerateAccessToken(&user) if err != nil { diff --git a/veza-backend-api/internal/services/account_lockout_service.go b/veza-backend-api/internal/services/account_lockout_service.go new file mode 100644 index 000000000..2da1ca317 --- /dev/null +++ b/veza-backend-api/internal/services/account_lockout_service.go @@ -0,0 +1,240 @@ +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) +} +