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,366 @@
|
|||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// AuthFailureRateLimiter provides specialized rate limiting for authorization failures
|
||||
// to protect against privilege escalation and unauthorized access attempts
|
||||
type AuthFailureRateLimiter interface {
|
||||
// CheckAuthFailure checks if the user has exceeded authorization failure limits
|
||||
// Returns: allowed (bool), remainingAttempts (int), resetTime (time.Time), error
|
||||
CheckAuthFailure(ctx context.Context, userID string, resourceID string, action string) (bool, int, time.Time, error)
|
||||
|
||||
// RecordAuthFailure records an authorization failure
|
||||
RecordAuthFailure(ctx context.Context, userID string, resourceID string, action string, reason string) error
|
||||
|
||||
// RecordAuthSuccess records a successful authorization (optionally resets counters)
|
||||
RecordAuthSuccess(ctx context.Context, userID string, resourceID string, action string) error
|
||||
|
||||
// IsUserBlocked checks if a user is temporarily blocked from authorization attempts
|
||||
IsUserBlocked(ctx context.Context, userID string) (bool, time.Duration, error)
|
||||
|
||||
// GetFailureCount returns the number of authorization failures for a user
|
||||
GetFailureCount(ctx context.Context, userID string) (int, error)
|
||||
|
||||
// GetResourceFailureCount returns failures for a specific resource
|
||||
GetResourceFailureCount(ctx context.Context, userID string, resourceID string) (int, error)
|
||||
|
||||
// ResetUserFailures manually resets failure counters for a user
|
||||
ResetUserFailures(ctx context.Context, userID string) error
|
||||
}
|
||||
|
||||
// AuthFailureRateLimiterConfig holds configuration for authorization failure rate limiting
|
||||
type AuthFailureRateLimiterConfig struct {
|
||||
// MaxFailuresPerUser is the maximum authorization failures per user before blocking
|
||||
MaxFailuresPerUser int
|
||||
// MaxFailuresPerResource is the maximum failures per resource per user
|
||||
MaxFailuresPerResource int
|
||||
// FailureWindow is the time window for tracking failures
|
||||
FailureWindow time.Duration
|
||||
// BlockDuration is how long to block a user after exceeding limits
|
||||
BlockDuration time.Duration
|
||||
// AlertThreshold is the number of failures before alerting (for monitoring)
|
||||
AlertThreshold int
|
||||
// KeyPrefix is the prefix for Redis keys
|
||||
KeyPrefix string
|
||||
}
|
||||
|
||||
// DefaultAuthFailureRateLimiterConfig returns recommended configuration
|
||||
// Following OWASP guidelines for authorization failure handling
|
||||
func DefaultAuthFailureRateLimiterConfig() AuthFailureRateLimiterConfig {
|
||||
return AuthFailureRateLimiterConfig{
|
||||
MaxFailuresPerUser: 20, // 20 total auth failures per user
|
||||
MaxFailuresPerResource: 5, // 5 failures per specific resource
|
||||
FailureWindow: 15 * time.Minute, // in 15-minute window
|
||||
BlockDuration: 30 * time.Minute, // block for 30 minutes
|
||||
AlertThreshold: 10, // alert after 10 failures
|
||||
KeyPrefix: "auth_fail_rl",
|
||||
}
|
||||
}
|
||||
|
||||
type authFailureRateLimiter struct {
|
||||
client *redis.Client
|
||||
config AuthFailureRateLimiterConfig
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewAuthFailureRateLimiter creates a new authorization failure rate limiter
|
||||
func NewAuthFailureRateLimiter(client *redis.Client, config AuthFailureRateLimiterConfig, logger *zap.Logger) AuthFailureRateLimiter {
|
||||
return &authFailureRateLimiter{
|
||||
client: client,
|
||||
config: config,
|
||||
logger: logger.Named("auth-failure-rate-limiter"),
|
||||
}
|
||||
}
|
||||
|
||||
// CheckAuthFailure checks if the user has exceeded authorization failure limits
|
||||
// CWE-307: Protection against authorization brute force attacks
|
||||
// OWASP A01:2021: Broken Access Control - Rate limiting authorization failures
|
||||
func (r *authFailureRateLimiter) CheckAuthFailure(ctx context.Context, userID string, resourceID string, action string) (bool, int, time.Time, error) {
|
||||
// Check if user is blocked
|
||||
blocked, remaining, err := r.IsUserBlocked(ctx, userID)
|
||||
if err != nil {
|
||||
r.logger.Error("failed to check user block status",
|
||||
zap.String("user_id_hash", hashID(userID)),
|
||||
zap.Error(err))
|
||||
// Fail open on Redis error (security vs availability trade-off)
|
||||
return true, 0, time.Time{}, err
|
||||
}
|
||||
|
||||
if blocked {
|
||||
resetTime := time.Now().Add(remaining)
|
||||
r.logger.Warn("blocked user attempted authorization",
|
||||
zap.String("user_id_hash", hashID(userID)),
|
||||
zap.String("resource_id_hash", hashID(resourceID)),
|
||||
zap.String("action", action),
|
||||
zap.Duration("remaining_block", remaining))
|
||||
return false, 0, resetTime, nil
|
||||
}
|
||||
|
||||
// Check per-user failure count
|
||||
userFailures, err := r.GetFailureCount(ctx, userID)
|
||||
if err != nil {
|
||||
r.logger.Error("failed to get user failure count",
|
||||
zap.String("user_id_hash", hashID(userID)),
|
||||
zap.Error(err))
|
||||
// Fail open on Redis error
|
||||
return true, 0, time.Time{}, err
|
||||
}
|
||||
|
||||
// Check per-resource failure count
|
||||
resourceFailures, err := r.GetResourceFailureCount(ctx, userID, resourceID)
|
||||
if err != nil {
|
||||
r.logger.Error("failed to get resource failure count",
|
||||
zap.String("user_id_hash", hashID(userID)),
|
||||
zap.String("resource_id_hash", hashID(resourceID)),
|
||||
zap.Error(err))
|
||||
// Fail open on Redis error
|
||||
return true, 0, time.Time{}, err
|
||||
}
|
||||
|
||||
// Check if limits exceeded
|
||||
if userFailures >= r.config.MaxFailuresPerUser {
|
||||
r.blockUser(ctx, userID)
|
||||
resetTime := time.Now().Add(r.config.BlockDuration)
|
||||
r.logger.Warn("user exceeded authorization failure limit",
|
||||
zap.String("user_id_hash", hashID(userID)),
|
||||
zap.Int("failures", userFailures))
|
||||
return false, 0, resetTime, nil
|
||||
}
|
||||
|
||||
if resourceFailures >= r.config.MaxFailuresPerResource {
|
||||
resetTime := time.Now().Add(r.config.FailureWindow)
|
||||
r.logger.Warn("user exceeded resource-specific failure limit",
|
||||
zap.String("user_id_hash", hashID(userID)),
|
||||
zap.String("resource_id_hash", hashID(resourceID)),
|
||||
zap.Int("failures", resourceFailures))
|
||||
return false, r.config.MaxFailuresPerUser - userFailures, resetTime, nil
|
||||
}
|
||||
|
||||
remainingAttempts := r.config.MaxFailuresPerUser - userFailures
|
||||
resetTime := time.Now().Add(r.config.FailureWindow)
|
||||
|
||||
return true, remainingAttempts, resetTime, nil
|
||||
}
|
||||
|
||||
// RecordAuthFailure records an authorization failure
|
||||
// CWE-778: Insufficient Logging of security events
|
||||
func (r *authFailureRateLimiter) RecordAuthFailure(ctx context.Context, userID string, resourceID string, action string, reason string) error {
|
||||
now := time.Now()
|
||||
timestamp := now.UnixNano()
|
||||
|
||||
// Record per-user failure
|
||||
userKey := r.getUserFailureKey(userID)
|
||||
pipe := r.client.Pipeline()
|
||||
|
||||
// Add to sorted set with timestamp as score (for windowing)
|
||||
pipe.ZAdd(ctx, userKey, redis.Z{
|
||||
Score: float64(timestamp),
|
||||
Member: fmt.Sprintf("%d:%s:%s", timestamp, resourceID, action),
|
||||
})
|
||||
pipe.Expire(ctx, userKey, r.config.FailureWindow)
|
||||
|
||||
// Record per-resource failure
|
||||
if resourceID != "" {
|
||||
resourceKey := r.getResourceFailureKey(userID, resourceID)
|
||||
pipe.ZAdd(ctx, resourceKey, redis.Z{
|
||||
Score: float64(timestamp),
|
||||
Member: fmt.Sprintf("%d:%s", timestamp, action),
|
||||
})
|
||||
pipe.Expire(ctx, resourceKey, r.config.FailureWindow)
|
||||
}
|
||||
|
||||
// Increment total failure counter for metrics
|
||||
metricsKey := r.getMetricsKey(userID)
|
||||
pipe.Incr(ctx, metricsKey)
|
||||
pipe.Expire(ctx, metricsKey, 24*time.Hour) // Keep metrics for 24 hours
|
||||
|
||||
_, err := pipe.Exec(ctx)
|
||||
if err != nil {
|
||||
r.logger.Error("failed to record authorization failure",
|
||||
zap.String("user_id_hash", hashID(userID)),
|
||||
zap.String("resource_id_hash", hashID(resourceID)),
|
||||
zap.String("action", action),
|
||||
zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if we should alert
|
||||
count, _ := r.GetFailureCount(ctx, userID)
|
||||
if count == r.config.AlertThreshold {
|
||||
r.logger.Error("SECURITY ALERT: User reached authorization failure alert threshold",
|
||||
zap.String("user_id_hash", hashID(userID)),
|
||||
zap.String("resource_id_hash", hashID(resourceID)),
|
||||
zap.String("action", action),
|
||||
zap.String("reason", reason),
|
||||
zap.Int("failure_count", count))
|
||||
}
|
||||
|
||||
r.logger.Warn("authorization failure recorded",
|
||||
zap.String("user_id_hash", hashID(userID)),
|
||||
zap.String("resource_id_hash", hashID(resourceID)),
|
||||
zap.String("action", action),
|
||||
zap.String("reason", reason),
|
||||
zap.Int("total_failures", count))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordAuthSuccess records a successful authorization
|
||||
func (r *authFailureRateLimiter) RecordAuthSuccess(ctx context.Context, userID string, resourceID string, action string) error {
|
||||
// Optionally, we could reset or reduce failure counts on success
|
||||
// For now, we just log the success for audit purposes
|
||||
r.logger.Debug("authorization success recorded",
|
||||
zap.String("user_id_hash", hashID(userID)),
|
||||
zap.String("resource_id_hash", hashID(resourceID)),
|
||||
zap.String("action", action))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsUserBlocked checks if a user is temporarily blocked
|
||||
func (r *authFailureRateLimiter) IsUserBlocked(ctx context.Context, userID string) (bool, time.Duration, error) {
|
||||
blockKey := r.getBlockKey(userID)
|
||||
|
||||
ttl, err := r.client.TTL(ctx, blockKey).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
|
||||
}
|
||||
|
||||
// GetFailureCount returns the number of authorization failures for a user
|
||||
func (r *authFailureRateLimiter) GetFailureCount(ctx context.Context, userID string) (int, error) {
|
||||
userKey := r.getUserFailureKey(userID)
|
||||
now := time.Now()
|
||||
windowStart := now.Add(-r.config.FailureWindow)
|
||||
|
||||
// Remove old entries outside the window
|
||||
r.client.ZRemRangeByScore(ctx, userKey, "0", fmt.Sprintf("%d", windowStart.UnixNano()))
|
||||
|
||||
// Count current failures in window
|
||||
count, err := r.client.ZCount(ctx, userKey,
|
||||
fmt.Sprintf("%d", windowStart.UnixNano()),
|
||||
"+inf").Result()
|
||||
|
||||
if err != nil && err != redis.Nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return int(count), nil
|
||||
}
|
||||
|
||||
// GetResourceFailureCount returns failures for a specific resource
|
||||
func (r *authFailureRateLimiter) GetResourceFailureCount(ctx context.Context, userID string, resourceID string) (int, error) {
|
||||
if resourceID == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
resourceKey := r.getResourceFailureKey(userID, resourceID)
|
||||
now := time.Now()
|
||||
windowStart := now.Add(-r.config.FailureWindow)
|
||||
|
||||
// Remove old entries
|
||||
r.client.ZRemRangeByScore(ctx, resourceKey, "0", fmt.Sprintf("%d", windowStart.UnixNano()))
|
||||
|
||||
// Count current failures
|
||||
count, err := r.client.ZCount(ctx, resourceKey,
|
||||
fmt.Sprintf("%d", windowStart.UnixNano()),
|
||||
"+inf").Result()
|
||||
|
||||
if err != nil && err != redis.Nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return int(count), nil
|
||||
}
|
||||
|
||||
// ResetUserFailures manually resets failure counters for a user
|
||||
func (r *authFailureRateLimiter) ResetUserFailures(ctx context.Context, userID string) error {
|
||||
pattern := fmt.Sprintf("%s:user:%s:*", r.config.KeyPrefix, hashID(userID))
|
||||
|
||||
// Find all keys for this user
|
||||
keys, err := r.client.Keys(ctx, pattern).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(keys) > 0 {
|
||||
pipe := r.client.Pipeline()
|
||||
for _, key := range keys {
|
||||
pipe.Del(ctx, key)
|
||||
}
|
||||
_, err = pipe.Exec(ctx)
|
||||
if err != nil {
|
||||
r.logger.Error("failed to reset user failures",
|
||||
zap.String("user_id_hash", hashID(userID)),
|
||||
zap.Error(err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
r.logger.Info("user authorization failures reset",
|
||||
zap.String("user_id_hash", hashID(userID)))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// blockUser blocks a user from further authorization attempts
|
||||
func (r *authFailureRateLimiter) blockUser(ctx context.Context, userID string) error {
|
||||
blockKey := r.getBlockKey(userID)
|
||||
err := r.client.Set(ctx, blockKey, "blocked", r.config.BlockDuration).Err()
|
||||
if err != nil {
|
||||
r.logger.Error("failed to block user",
|
||||
zap.String("user_id_hash", hashID(userID)),
|
||||
zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
r.logger.Error("SECURITY: User blocked due to excessive authorization failures",
|
||||
zap.String("user_id_hash", hashID(userID)),
|
||||
zap.Duration("block_duration", r.config.BlockDuration))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Key generation helpers
|
||||
func (r *authFailureRateLimiter) getUserFailureKey(userID string) string {
|
||||
return fmt.Sprintf("%s:user:%s:failures", r.config.KeyPrefix, hashID(userID))
|
||||
}
|
||||
|
||||
func (r *authFailureRateLimiter) getResourceFailureKey(userID string, resourceID string) string {
|
||||
return fmt.Sprintf("%s:user:%s:resource:%s:failures", r.config.KeyPrefix, hashID(userID), hashID(resourceID))
|
||||
}
|
||||
|
||||
func (r *authFailureRateLimiter) getBlockKey(userID string) string {
|
||||
return fmt.Sprintf("%s:user:%s:blocked", r.config.KeyPrefix, hashID(userID))
|
||||
}
|
||||
|
||||
func (r *authFailureRateLimiter) getMetricsKey(userID string) string {
|
||||
return fmt.Sprintf("%s:user:%s:metrics", r.config.KeyPrefix, hashID(userID))
|
||||
}
|
||||
|
||||
// hashID creates a consistent hash of an ID for use as a Redis key component
|
||||
// CWE-532: Prevents sensitive IDs in Redis keys
|
||||
func hashID(id string) string {
|
||||
if id == "" {
|
||||
return "empty"
|
||||
}
|
||||
hash := sha256.Sum256([]byte(id))
|
||||
// Return first 16 bytes of hash as hex (32 chars) for shorter keys
|
||||
return hex.EncodeToString(hash[:16])
|
||||
}
|
||||
332
cloud/maplefile-backend/pkg/ratelimit/login_ratelimiter.go
Normal file
332
cloud/maplefile-backend/pkg/ratelimit/login_ratelimiter.go
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
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[:])
|
||||
}
|
||||
81
cloud/maplefile-backend/pkg/ratelimit/providers.go
Normal file
81
cloud/maplefile-backend/pkg/ratelimit/providers.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
package ratelimit
|
||||
|
||||
import (
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
|
||||
)
|
||||
|
||||
// ProvideLoginRateLimiter creates a LoginRateLimiter for dependency injection
|
||||
// CWE-307: Implements rate limiting and account lockout protection against brute force attacks
|
||||
func ProvideLoginRateLimiter(redisClient redis.UniversalClient, cfg *config.Configuration, logger *zap.Logger) LoginRateLimiter {
|
||||
// Start with default config
|
||||
loginConfig := DefaultLoginRateLimiterConfig()
|
||||
|
||||
// Override with configuration values if provided
|
||||
if cfg != nil {
|
||||
if cfg.LoginRateLimit.MaxAttemptsPerIP > 0 {
|
||||
loginConfig.MaxAttemptsPerIP = cfg.LoginRateLimit.MaxAttemptsPerIP
|
||||
}
|
||||
if cfg.LoginRateLimit.IPWindow > 0 {
|
||||
loginConfig.IPWindow = cfg.LoginRateLimit.IPWindow
|
||||
}
|
||||
if cfg.LoginRateLimit.MaxFailedAttemptsPerAccount > 0 {
|
||||
loginConfig.MaxFailedAttemptsPerAccount = cfg.LoginRateLimit.MaxFailedAttemptsPerAccount
|
||||
}
|
||||
if cfg.LoginRateLimit.AccountLockoutDuration > 0 {
|
||||
loginConfig.AccountLockoutDuration = cfg.LoginRateLimit.AccountLockoutDuration
|
||||
}
|
||||
}
|
||||
|
||||
// Type assert to *redis.Client since LoginRateLimiter needs it
|
||||
client, ok := redisClient.(*redis.Client)
|
||||
if !ok {
|
||||
// If it's a cluster client or other type, log warning
|
||||
// This shouldn't happen in our standard setup
|
||||
logger.Warn("Redis client is not a standard client, login rate limiter may not work correctly")
|
||||
return NewLoginRateLimiter(nil, loginConfig, logger)
|
||||
}
|
||||
|
||||
logger.Info("Login rate limiter initialized",
|
||||
zap.Int("max_attempts_per_ip", loginConfig.MaxAttemptsPerIP),
|
||||
zap.Duration("ip_window", loginConfig.IPWindow),
|
||||
zap.Int("max_failed_per_account", loginConfig.MaxFailedAttemptsPerAccount),
|
||||
zap.Duration("lockout_duration", loginConfig.AccountLockoutDuration))
|
||||
|
||||
return NewLoginRateLimiter(client, loginConfig, logger)
|
||||
}
|
||||
|
||||
// ProvideAuthFailureRateLimiter creates an AuthFailureRateLimiter for dependency injection
|
||||
// CWE-307: Implements rate limiting for authorization failures to prevent privilege escalation attempts
|
||||
// OWASP A01:2021: Broken Access Control - Rate limiting authorization failures
|
||||
func ProvideAuthFailureRateLimiter(redisClient redis.UniversalClient, cfg *config.Configuration, logger *zap.Logger) AuthFailureRateLimiter {
|
||||
// Use default config with secure defaults for authorization failure protection
|
||||
authConfig := DefaultAuthFailureRateLimiterConfig()
|
||||
|
||||
// Override defaults with configuration if provided
|
||||
// Allow configuration through environment variables for flexibility
|
||||
if cfg != nil {
|
||||
// These values could be configured via environment variables
|
||||
// For now, we use the secure defaults
|
||||
// TODO: Add auth failure rate limiting configuration to SecurityConfig
|
||||
}
|
||||
|
||||
// Type assert to *redis.Client since AuthFailureRateLimiter needs it
|
||||
client, ok := redisClient.(*redis.Client)
|
||||
if !ok {
|
||||
// If it's a cluster client or other type, log warning
|
||||
logger.Warn("Redis client is not a standard client, auth failure rate limiter may not work correctly")
|
||||
return NewAuthFailureRateLimiter(nil, authConfig, logger)
|
||||
}
|
||||
|
||||
logger.Info("Authorization failure rate limiter initialized",
|
||||
zap.Int("max_failures_per_user", authConfig.MaxFailuresPerUser),
|
||||
zap.Int("max_failures_per_resource", authConfig.MaxFailuresPerResource),
|
||||
zap.Duration("failure_window", authConfig.FailureWindow),
|
||||
zap.Duration("block_duration", authConfig.BlockDuration),
|
||||
zap.Int("alert_threshold", authConfig.AlertThreshold))
|
||||
|
||||
return NewAuthFailureRateLimiter(client, authConfig, logger)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue