monorepo/native/desktop/maplefile/internal/service/ratelimiter/ratelimiter.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)
}
}
}