// Package ratelimiter provides client-side rate limiting for sensitive operations. // // Security Note: This is a defense-in-depth measure. The backend MUST also implement // rate limiting as the authoritative control. Client-side rate limiting provides: // - Protection against accidental rapid requests (e.g., user double-clicking) // - Reduced load on backend during legitimate high-frequency usage // - Better UX by failing fast with clear error messages // - Deterrent against simple automated attacks (though not a security boundary) // // This does NOT replace server-side rate limiting, which remains the security control. package ratelimiter import ( "fmt" "sync" "time" ) // Operation represents a rate-limited operation type type Operation string const ( // OpRequestOTT is the operation for requesting a one-time token OpRequestOTT Operation = "request_ott" // OpVerifyOTT is the operation for verifying a one-time token OpVerifyOTT Operation = "verify_ott" // OpCompleteLogin is the operation for completing login OpCompleteLogin Operation = "complete_login" // OpRegister is the operation for user registration OpRegister Operation = "register" // OpVerifyEmail is the operation for email verification OpVerifyEmail Operation = "verify_email" ) // RateLimitError is returned when an operation is rate limited type RateLimitError struct { Operation Operation RetryAfter time.Duration AttemptsMade int MaxAttempts int } func (e *RateLimitError) Error() string { return fmt.Sprintf( "rate limited: %s operation exceeded %d attempts, retry after %v", e.Operation, e.MaxAttempts, e.RetryAfter.Round(time.Second), ) } // operationLimit defines the rate limit configuration for an operation type operationLimit struct { maxAttempts int // Maximum attempts allowed in the window window time.Duration // Time window for the limit cooldown time.Duration // Cooldown period after hitting the limit } // operationState tracks the current state of rate limiting for an operation type operationState struct { attempts int // Current attempt count windowStart time.Time // When the current window started lockedUntil time.Time // If rate limited, when the cooldown ends } // Service provides rate limiting functionality type Service struct { mu sync.Mutex limits map[Operation]operationLimit state map[string]*operationState // key: operation + identifier (e.g., email) } // New creates a new rate limiter service with default limits. // // Default limits are designed to: // - Allow normal user behavior (typos, retries) // - Prevent rapid automated attempts // - Provide reasonable cooldown periods func New() *Service { return &Service{ limits: map[Operation]operationLimit{ // OTT request: 3 attempts per 60 seconds, 2 minute cooldown // Rationale: Users might request OTT multiple times if email is slow OpRequestOTT: { maxAttempts: 3, window: 60 * time.Second, cooldown: 2 * time.Minute, }, // OTT verification: 5 attempts per 60 seconds, 1 minute cooldown // Rationale: Users might mistype the 8-digit code OpVerifyOTT: { maxAttempts: 5, window: 60 * time.Second, cooldown: 1 * time.Minute, }, // Complete login: 5 attempts per 60 seconds, 1 minute cooldown // Rationale: Password decryption might fail due to typos OpCompleteLogin: { maxAttempts: 5, window: 60 * time.Second, cooldown: 1 * time.Minute, }, // Registration: 3 attempts per 5 minutes, 5 minute cooldown // Rationale: Registration is a one-time operation, limit abuse OpRegister: { maxAttempts: 3, window: 5 * time.Minute, cooldown: 5 * time.Minute, }, // Email verification: 5 attempts per 60 seconds, 1 minute cooldown // Rationale: Users might mistype the verification code OpVerifyEmail: { maxAttempts: 5, window: 60 * time.Second, cooldown: 1 * time.Minute, }, }, state: make(map[string]*operationState), } } // ProvideService creates the rate limiter service for Wire dependency injection func ProvideService() *Service { return New() } // Check verifies if an operation is allowed and records the attempt. // The identifier is typically the user's email address. // Returns nil if the operation is allowed, or a RateLimitError if rate limited. func (s *Service) Check(op Operation, identifier string) error { s.mu.Lock() defer s.mu.Unlock() limit, ok := s.limits[op] if !ok { // Unknown operation, allow by default (fail open for usability) return nil } key := string(op) + ":" + identifier now := time.Now() state, exists := s.state[key] if !exists { // First attempt for this operation+identifier s.state[key] = &operationState{ attempts: 1, windowStart: now, } return nil } // Check if currently in cooldown if now.Before(state.lockedUntil) { return &RateLimitError{ Operation: op, RetryAfter: state.lockedUntil.Sub(now), AttemptsMade: state.attempts, MaxAttempts: limit.maxAttempts, } } // Check if window has expired (reset if so) if now.Sub(state.windowStart) > limit.window { state.attempts = 1 state.windowStart = now state.lockedUntil = time.Time{} return nil } // Increment attempt count state.attempts++ // Check if limit exceeded if state.attempts > limit.maxAttempts { state.lockedUntil = now.Add(limit.cooldown) return &RateLimitError{ Operation: op, RetryAfter: limit.cooldown, AttemptsMade: state.attempts, MaxAttempts: limit.maxAttempts, } } return nil } // Reset clears the rate limit state for a specific operation and identifier. // Call this after a successful operation where you want to allow fresh attempts // (e.g., after successful OTT verification). Do NOT call this for operations // where success shouldn't reset the limit (e.g., OTT request, registration). func (s *Service) Reset(op Operation, identifier string) { s.mu.Lock() defer s.mu.Unlock() key := string(op) + ":" + identifier delete(s.state, key) } // ResetAll clears all rate limit state for an identifier (e.g., after successful login). func (s *Service) ResetAll(identifier string) { s.mu.Lock() defer s.mu.Unlock() for op := range s.limits { key := string(op) + ":" + identifier delete(s.state, key) } } // GetRemainingAttempts returns the number of remaining attempts for an operation. // Returns -1 if the operation is currently rate limited. func (s *Service) GetRemainingAttempts(op Operation, identifier string) int { s.mu.Lock() defer s.mu.Unlock() limit, ok := s.limits[op] if !ok { return -1 } key := string(op) + ":" + identifier state, exists := s.state[key] if !exists { return limit.maxAttempts } now := time.Now() // In cooldown if now.Before(state.lockedUntil) { return -1 } // Window expired if now.Sub(state.windowStart) > limit.window { return limit.maxAttempts } remaining := limit.maxAttempts - state.attempts if remaining < 0 { return 0 } return remaining } // Cleanup removes expired state entries to prevent memory growth. // This should be called periodically (e.g., every hour). func (s *Service) Cleanup() { s.mu.Lock() defer s.mu.Unlock() now := time.Now() maxAge := 24 * time.Hour // Remove entries older than 24 hours for key, state := range s.state { // Remove if window started more than maxAge ago and not in cooldown if now.Sub(state.windowStart) > maxAge && now.After(state.lockedUntil) { delete(s.state, key) } } }