Initial commit: Open sourcing all of the Maple Open Technologies code.
This commit is contained in:
commit
755d54a99d
2010 changed files with 448675 additions and 0 deletions
|
|
@ -0,0 +1,260 @@
|
|||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue