260 lines
7.4 KiB
Go
260 lines
7.4 KiB
Go
// 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)
|
|
}
|
|
}
|
|
}
|