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
327
cloud/maplepress-backend/pkg/ratelimit/login_ratelimiter.go
Normal file
327
cloud/maplepress-backend/pkg/ratelimit/login_ratelimiter.go
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// 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", 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", 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", 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", 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", 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", 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", 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", 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
|
||||
func hashEmail(email string) string {
|
||||
// Use a simple hash for key generation (not for security)
|
||||
// In production, consider using SHA-256
|
||||
hash := 0
|
||||
for _, c := range email {
|
||||
hash = (hash * 31) + int(c)
|
||||
}
|
||||
return fmt.Sprintf("%x", hash)
|
||||
}
|
||||
45
cloud/maplepress-backend/pkg/ratelimit/provider.go
Normal file
45
cloud/maplepress-backend/pkg/ratelimit/provider.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
package ratelimit
|
||||
|
||||
import (
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config"
|
||||
)
|
||||
|
||||
// ProvideRateLimiter provides a rate limiter for dependency injection (registration endpoints)
|
||||
func ProvideRateLimiter(redisClient *redis.Client, cfg *config.Config, logger *zap.Logger) RateLimiter {
|
||||
rateLimitConfig := Config{
|
||||
MaxRequests: cfg.RateLimit.RegistrationMaxRequests,
|
||||
Window: cfg.RateLimit.RegistrationWindow,
|
||||
KeyPrefix: "ratelimit:registration",
|
||||
}
|
||||
|
||||
return NewRateLimiter(redisClient, rateLimitConfig, logger)
|
||||
}
|
||||
|
||||
// ProvideGenericRateLimiter provides a rate limiter for generic CRUD endpoints (CWE-770)
|
||||
// This is used for authenticated endpoints like tenant/user/site management, admin endpoints
|
||||
// Strategy: User-based limiting (authenticated user ID from JWT)
|
||||
func ProvideGenericRateLimiter(redisClient *redis.Client, cfg *config.Config, logger *zap.Logger) RateLimiter {
|
||||
rateLimitConfig := Config{
|
||||
MaxRequests: cfg.RateLimit.GenericMaxRequests,
|
||||
Window: cfg.RateLimit.GenericWindow,
|
||||
KeyPrefix: "ratelimit:generic",
|
||||
}
|
||||
|
||||
return NewRateLimiter(redisClient, rateLimitConfig, logger)
|
||||
}
|
||||
|
||||
// ProvidePluginAPIRateLimiter provides a rate limiter for WordPress plugin API endpoints (CWE-770)
|
||||
// This is used for plugin endpoints that are core business/revenue endpoints
|
||||
// Strategy: Site-based limiting (API key → site_id)
|
||||
func ProvidePluginAPIRateLimiter(redisClient *redis.Client, cfg *config.Config, logger *zap.Logger) RateLimiter {
|
||||
rateLimitConfig := Config{
|
||||
MaxRequests: cfg.RateLimit.PluginAPIMaxRequests,
|
||||
Window: cfg.RateLimit.PluginAPIWindow,
|
||||
KeyPrefix: "ratelimit:plugin",
|
||||
}
|
||||
|
||||
return NewRateLimiter(redisClient, rateLimitConfig, logger)
|
||||
}
|
||||
23
cloud/maplepress-backend/pkg/ratelimit/providers.go
Normal file
23
cloud/maplepress-backend/pkg/ratelimit/providers.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package ratelimit
|
||||
|
||||
import (
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config"
|
||||
)
|
||||
|
||||
// ProvideLoginRateLimiter creates a LoginRateLimiter for dependency injection
|
||||
// CWE-307: Implements rate limiting and account lockout protection against brute force attacks
|
||||
func ProvideLoginRateLimiter(client *redis.Client, cfg *config.Config, logger *zap.Logger) LoginRateLimiter {
|
||||
// Use configuration from environment variables
|
||||
loginConfig := LoginRateLimiterConfig{
|
||||
MaxAttemptsPerIP: cfg.RateLimit.LoginMaxAttemptsPerIP,
|
||||
IPWindow: cfg.RateLimit.LoginIPWindow,
|
||||
MaxFailedAttemptsPerAccount: cfg.RateLimit.LoginMaxFailedAttemptsPerAccount,
|
||||
AccountLockoutDuration: cfg.RateLimit.LoginAccountLockoutDuration,
|
||||
KeyPrefix: "login_rl",
|
||||
}
|
||||
|
||||
return NewLoginRateLimiter(client, loginConfig, logger)
|
||||
}
|
||||
172
cloud/maplepress-backend/pkg/ratelimit/ratelimiter.go
Normal file
172
cloud/maplepress-backend/pkg/ratelimit/ratelimiter.go
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// RateLimiter provides rate limiting functionality using Redis
|
||||
type RateLimiter interface {
|
||||
// Allow checks if a request should be allowed based on the key
|
||||
// Returns true if allowed, false if rate limit exceeded
|
||||
Allow(ctx context.Context, key string) (bool, error)
|
||||
|
||||
// AllowN checks if N requests should be allowed
|
||||
AllowN(ctx context.Context, key string, n int) (bool, error)
|
||||
|
||||
// Reset resets the rate limit for a key
|
||||
Reset(ctx context.Context, key string) error
|
||||
|
||||
// GetRemaining returns the number of remaining requests
|
||||
GetRemaining(ctx context.Context, key string) (int, error)
|
||||
}
|
||||
|
||||
// Config holds rate limiter configuration
|
||||
type Config struct {
|
||||
// MaxRequests is the maximum number of requests allowed
|
||||
MaxRequests int
|
||||
// Window is the time window for rate limiting
|
||||
Window time.Duration
|
||||
// KeyPrefix is the prefix for Redis keys
|
||||
KeyPrefix string
|
||||
}
|
||||
|
||||
type rateLimiter struct {
|
||||
client *redis.Client
|
||||
config Config
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewRateLimiter creates a new rate limiter
|
||||
func NewRateLimiter(client *redis.Client, config Config, logger *zap.Logger) RateLimiter {
|
||||
return &rateLimiter{
|
||||
client: client,
|
||||
config: config,
|
||||
logger: logger.Named("rate-limiter"),
|
||||
}
|
||||
}
|
||||
|
||||
// Allow checks if a request should be allowed
|
||||
func (r *rateLimiter) Allow(ctx context.Context, key string) (bool, error) {
|
||||
return r.AllowN(ctx, key, 1)
|
||||
}
|
||||
|
||||
// AllowN checks if N requests should be allowed using sliding window counter
|
||||
func (r *rateLimiter) AllowN(ctx context.Context, key string, n int) (bool, error) {
|
||||
redisKey := r.getRedisKey(key)
|
||||
now := time.Now()
|
||||
windowStart := now.Add(-r.config.Window)
|
||||
|
||||
// Use Redis transaction to ensure atomicity
|
||||
pipe := r.client.Pipeline()
|
||||
|
||||
// Remove old entries outside the window
|
||||
pipe.ZRemRangeByScore(ctx, redisKey, "0", fmt.Sprintf("%d", windowStart.UnixNano()))
|
||||
|
||||
// Count current requests in window
|
||||
countCmd := pipe.ZCount(ctx, redisKey, fmt.Sprintf("%d", windowStart.UnixNano()), "+inf")
|
||||
|
||||
// Execute pipeline
|
||||
_, err := pipe.Exec(ctx)
|
||||
if err != nil && err != redis.Nil {
|
||||
r.logger.Error("failed to check rate limit",
|
||||
zap.String("key", key),
|
||||
zap.Error(err))
|
||||
// Fail open: allow request if Redis is down
|
||||
return true, err
|
||||
}
|
||||
|
||||
currentCount := countCmd.Val()
|
||||
|
||||
// Check if adding N requests would exceed limit
|
||||
if currentCount+int64(n) > int64(r.config.MaxRequests) {
|
||||
r.logger.Warn("rate limit exceeded",
|
||||
zap.String("key", key),
|
||||
zap.Int64("current_count", currentCount),
|
||||
zap.Int("max_requests", r.config.MaxRequests))
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Add the new request(s) to the sorted set
|
||||
pipe2 := r.client.Pipeline()
|
||||
for i := 0; i < n; i++ {
|
||||
// Use nanosecond timestamp with incremental offset to ensure uniqueness
|
||||
timestamp := now.Add(time.Duration(i) * time.Nanosecond).UnixNano()
|
||||
pipe2.ZAdd(ctx, redisKey, redis.Z{
|
||||
Score: float64(timestamp),
|
||||
Member: fmt.Sprintf("%d-%d", timestamp, i),
|
||||
})
|
||||
}
|
||||
|
||||
// Set expiration on the key (window + buffer)
|
||||
pipe2.Expire(ctx, redisKey, r.config.Window+time.Minute)
|
||||
|
||||
// Execute pipeline
|
||||
_, err = pipe2.Exec(ctx)
|
||||
if err != nil && err != redis.Nil {
|
||||
r.logger.Error("failed to record request",
|
||||
zap.String("key", key),
|
||||
zap.Error(err))
|
||||
// Already counted, so return true
|
||||
return true, err
|
||||
}
|
||||
|
||||
r.logger.Debug("rate limit check passed",
|
||||
zap.String("key", key),
|
||||
zap.Int64("current_count", currentCount),
|
||||
zap.Int("max_requests", r.config.MaxRequests))
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Reset resets the rate limit for a key
|
||||
func (r *rateLimiter) Reset(ctx context.Context, key string) error {
|
||||
redisKey := r.getRedisKey(key)
|
||||
err := r.client.Del(ctx, redisKey).Err()
|
||||
if err != nil {
|
||||
r.logger.Error("failed to reset rate limit",
|
||||
zap.String("key", key),
|
||||
zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
r.logger.Info("rate limit reset",
|
||||
zap.String("key", key))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRemaining returns the number of remaining requests in the current window
|
||||
func (r *rateLimiter) GetRemaining(ctx context.Context, key string) (int, error) {
|
||||
redisKey := r.getRedisKey(key)
|
||||
now := time.Now()
|
||||
windowStart := now.Add(-r.config.Window)
|
||||
|
||||
// Count current requests in window
|
||||
count, err := r.client.ZCount(ctx, redisKey,
|
||||
fmt.Sprintf("%d", windowStart.UnixNano()),
|
||||
"+inf").Result()
|
||||
|
||||
if err != nil && err != redis.Nil {
|
||||
r.logger.Error("failed to get remaining requests",
|
||||
zap.String("key", key),
|
||||
zap.Error(err))
|
||||
return 0, err
|
||||
}
|
||||
|
||||
remaining := r.config.MaxRequests - int(count)
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
|
||||
return remaining, nil
|
||||
}
|
||||
|
||||
// getRedisKey constructs the Redis key with prefix
|
||||
func (r *rateLimiter) getRedisKey(key string) string {
|
||||
return fmt.Sprintf("%s:%s", r.config.KeyPrefix, key)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue