package ratelimit import ( "context" "crypto/sha256" "encoding/hex" "fmt" "strings" "time" "github.com/redis/go-redis/v9" "go.uber.org/zap" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation" ) // LoginRateLimiter provides specialized rate limiting for login attempts // with account lockout functionality type LoginRateLimiter interface { // CheckAndRecordAttempt checks if login attempt is allowed and records it // Returns: allowed (bool), isLocked (bool), remainingAttempts (int), error CheckAndRecordAttempt(ctx context.Context, email string, clientIP string) (bool, bool, int, error) // RecordFailedAttempt records a failed login attempt RecordFailedAttempt(ctx context.Context, email string, clientIP string) error // RecordSuccessfulLogin records a successful login and resets counters RecordSuccessfulLogin(ctx context.Context, email string, clientIP string) error // IsAccountLocked checks if an account is locked due to too many failed attempts IsAccountLocked(ctx context.Context, email string) (bool, time.Duration, error) // UnlockAccount manually unlocks an account (admin function) UnlockAccount(ctx context.Context, email string) error // GetFailedAttempts returns the number of failed attempts for an email GetFailedAttempts(ctx context.Context, email string) (int, error) } // LoginRateLimiterConfig holds configuration for login rate limiting type LoginRateLimiterConfig struct { // MaxAttemptsPerIP is the maximum login attempts per IP in the window MaxAttemptsPerIP int // IPWindow is the time window for IP-based rate limiting IPWindow time.Duration // MaxFailedAttemptsPerAccount is the maximum failed attempts before account lockout MaxFailedAttemptsPerAccount int // AccountLockoutDuration is how long to lock an account after too many failures AccountLockoutDuration time.Duration // KeyPrefix is the prefix for Redis keys KeyPrefix string } // DefaultLoginRateLimiterConfig returns recommended configuration func DefaultLoginRateLimiterConfig() LoginRateLimiterConfig { return LoginRateLimiterConfig{ MaxAttemptsPerIP: 10, // 10 attempts per IP IPWindow: 15 * time.Minute, // in 15 minutes MaxFailedAttemptsPerAccount: 10, // 10 failed attempts per account AccountLockoutDuration: 30 * time.Minute, // lock for 30 minutes KeyPrefix: "login_rl", } } type loginRateLimiter struct { client *redis.Client config LoginRateLimiterConfig logger *zap.Logger } // NewLoginRateLimiter creates a new login rate limiter func NewLoginRateLimiter(client *redis.Client, config LoginRateLimiterConfig, logger *zap.Logger) LoginRateLimiter { return &loginRateLimiter{ client: client, config: config, logger: logger.Named("login-rate-limiter"), } } // CheckAndRecordAttempt checks if login attempt is allowed // CWE-307: Implements protection against brute force attacks func (r *loginRateLimiter) CheckAndRecordAttempt(ctx context.Context, email string, clientIP string) (bool, bool, int, error) { // Check account lockout first locked, remaining, err := r.IsAccountLocked(ctx, email) if err != nil { r.logger.Error("failed to check account lockout", zap.String("email_hash", hashEmail(email)), zap.Error(err)) // Fail open on Redis error return true, false, 0, err } if locked { r.logger.Warn("login attempt on locked account", zap.String("email_hash", hashEmail(email)), zap.String("ip", validation.MaskIP(clientIP)), zap.Duration("remaining_lockout", remaining)) return false, true, 0, nil } // Check IP-based rate limit ipKey := r.getIPKey(clientIP) allowed, err := r.checkIPRateLimit(ctx, ipKey) if err != nil { r.logger.Error("failed to check IP rate limit", zap.String("ip", validation.MaskIP(clientIP)), zap.Error(err)) // Fail open on Redis error return true, false, 0, err } if !allowed { r.logger.Warn("IP rate limit exceeded", zap.String("ip", validation.MaskIP(clientIP))) return false, false, 0, nil } // Record the attempt for IP if err := r.recordIPAttempt(ctx, ipKey); err != nil { r.logger.Error("failed to record IP attempt", zap.String("ip", validation.MaskIP(clientIP)), zap.Error(err)) } // Get remaining attempts for account failedAttempts, err := r.GetFailedAttempts(ctx, email) if err != nil { r.logger.Error("failed to get failed attempts", zap.String("email_hash", hashEmail(email)), zap.Error(err)) } remainingAttempts := r.config.MaxFailedAttemptsPerAccount - failedAttempts if remainingAttempts < 0 { remainingAttempts = 0 } r.logger.Debug("login attempt check passed", zap.String("email_hash", hashEmail(email)), zap.String("ip", validation.MaskIP(clientIP)), zap.Int("remaining_attempts", remainingAttempts)) return true, false, remainingAttempts, nil } // RecordFailedAttempt records a failed login attempt // CWE-307: Tracks failed attempts to enable account lockout func (r *loginRateLimiter) RecordFailedAttempt(ctx context.Context, email string, clientIP string) error { accountKey := r.getAccountKey(email) // Increment failed attempt counter count, err := r.client.Incr(ctx, accountKey).Result() if err != nil { r.logger.Error("failed to increment failed attempts", zap.String("email_hash", hashEmail(email)), zap.Error(err)) return err } // Set expiration on first failed attempt if count == 1 { r.client.Expire(ctx, accountKey, r.config.AccountLockoutDuration) } // Check if account should be locked if count >= int64(r.config.MaxFailedAttemptsPerAccount) { lockKey := r.getLockKey(email) err := r.client.Set(ctx, lockKey, "locked", r.config.AccountLockoutDuration).Err() if err != nil { r.logger.Error("failed to lock account", zap.String("email_hash", hashEmail(email)), zap.Error(err)) return err } r.logger.Warn("account locked due to too many failed attempts", zap.String("email_hash", hashEmail(email)), zap.String("ip", validation.MaskIP(clientIP)), zap.Int64("failed_attempts", count), zap.Duration("lockout_duration", r.config.AccountLockoutDuration)) } r.logger.Info("failed login attempt recorded", zap.String("email_hash", hashEmail(email)), zap.String("ip", validation.MaskIP(clientIP)), zap.Int64("total_failed_attempts", count)) return nil } // RecordSuccessfulLogin records a successful login and resets counters func (r *loginRateLimiter) RecordSuccessfulLogin(ctx context.Context, email string, clientIP string) error { accountKey := r.getAccountKey(email) lockKey := r.getLockKey(email) // Delete failed attempt counter pipe := r.client.Pipeline() pipe.Del(ctx, accountKey) pipe.Del(ctx, lockKey) _, err := pipe.Exec(ctx) if err != nil { r.logger.Error("failed to reset login counters", zap.String("email_hash", hashEmail(email)), zap.Error(err)) return err } r.logger.Info("successful login recorded, counters reset", zap.String("email_hash", hashEmail(email)), zap.String("ip", validation.MaskIP(clientIP))) return nil } // IsAccountLocked checks if an account is locked func (r *loginRateLimiter) IsAccountLocked(ctx context.Context, email string) (bool, time.Duration, error) { lockKey := r.getLockKey(email) ttl, err := r.client.TTL(ctx, lockKey).Result() if err != nil { return false, 0, err } // TTL returns -2 if key doesn't exist, -1 if no expiration if ttl < 0 { return false, 0, nil } return true, ttl, nil } // UnlockAccount manually unlocks an account func (r *loginRateLimiter) UnlockAccount(ctx context.Context, email string) error { accountKey := r.getAccountKey(email) lockKey := r.getLockKey(email) pipe := r.client.Pipeline() pipe.Del(ctx, accountKey) pipe.Del(ctx, lockKey) _, err := pipe.Exec(ctx) if err != nil { r.logger.Error("failed to unlock account", zap.String("email_hash", hashEmail(email)), zap.Error(err)) return err } r.logger.Info("account unlocked", zap.String("email_hash", hashEmail(email))) return nil } // GetFailedAttempts returns the number of failed attempts func (r *loginRateLimiter) GetFailedAttempts(ctx context.Context, email string) (int, error) { accountKey := r.getAccountKey(email) count, err := r.client.Get(ctx, accountKey).Int() if err == redis.Nil { return 0, nil } if err != nil { return 0, err } return count, nil } // checkIPRateLimit checks if IP has exceeded rate limit func (r *loginRateLimiter) checkIPRateLimit(ctx context.Context, ipKey string) (bool, error) { now := time.Now() windowStart := now.Add(-r.config.IPWindow) // Remove old entries r.client.ZRemRangeByScore(ctx, ipKey, "0", fmt.Sprintf("%d", windowStart.UnixNano())) // Count current attempts count, err := r.client.ZCount(ctx, ipKey, fmt.Sprintf("%d", windowStart.UnixNano()), "+inf").Result() if err != nil && err != redis.Nil { return false, err } return count < int64(r.config.MaxAttemptsPerIP), nil } // recordIPAttempt records an IP attempt func (r *loginRateLimiter) recordIPAttempt(ctx context.Context, ipKey string) error { now := time.Now() timestamp := now.UnixNano() pipe := r.client.Pipeline() pipe.ZAdd(ctx, ipKey, redis.Z{ Score: float64(timestamp), Member: fmt.Sprintf("%d", timestamp), }) pipe.Expire(ctx, ipKey, r.config.IPWindow+time.Minute) _, err := pipe.Exec(ctx) return err } // Key generation helpers func (r *loginRateLimiter) getIPKey(ip string) string { return fmt.Sprintf("%s:ip:%s", r.config.KeyPrefix, ip) } func (r *loginRateLimiter) getAccountKey(email string) string { return fmt.Sprintf("%s:account:%s:attempts", r.config.KeyPrefix, hashEmail(email)) } func (r *loginRateLimiter) getLockKey(email string) string { return fmt.Sprintf("%s:account:%s:locked", r.config.KeyPrefix, hashEmail(email)) } // hashEmail creates a consistent hash of an email for use as a key // CWE-532: Prevents PII in Redis keys // Uses SHA-256 for cryptographically secure hashing func hashEmail(email string) string { // Normalize email to lowercase for consistent hashing normalized := strings.ToLower(strings.TrimSpace(email)) // Use SHA-256 for secure, collision-resistant hashing hash := sha256.Sum256([]byte(normalized)) return hex.EncodeToString(hash[:]) }