package middleware import ( "context" "fmt" "net/http" "strings" "sync" "time" "github.com/gin-gonic/gin" "github.com/redis/go-redis/v9" "github.com/gochat/gochat/internal/config" "github.com/gochat/gochat/pkg/response" ) type securityLimitPolicy struct { name string limit int window time.Duration } // SecurityRateLimit applies independent Redis-backed limits to credential and public ingress routes. func SecurityRateLimit(rdb *redis.Client, cfg config.RateLimitConfig) gin.HandlerFunc { if !cfg.Enabled { return func(c *gin.Context) { c.Next() } } fallbacks := map[string]*inMemoryLimiter{} var mu sync.Mutex return func(c *gin.Context) { policy, ok := securityPolicy(c.Request.Method, c.Request.URL.Path, cfg) if !ok || policy.limit <= 0 || policy.window <= 0 { c.Next() return } key := fmt.Sprintf("gochat:security_limit:%s:%s", policy.name, c.ClientIP()) allowed, err := checkSecurityLimit(c.Request.Context(), rdb, key, policy) if rdb == nil { mu.Lock() limiter := fallbacks[policy.name] if limiter == nil { limiter = newInMemoryLimiter(policy.limit, policy.window) fallbacks[policy.name] = limiter } mu.Unlock() allowed, _ = limiter.checkInMemory(key) err = nil } if err != nil { c.AbortWithStatusJSON(http.StatusServiceUnavailable, response.APIResponse{Success: false, Error: &response.ErrorBody{Code: response.ErrInternal, Message: "rate limit service unavailable"}}) return } if !allowed { c.Header("Retry-After", fmt.Sprintf("%d", int(policy.window.Seconds()))) c.AbortWithStatusJSON(http.StatusTooManyRequests, response.APIResponse{Success: false, Error: &response.ErrorBody{Code: response.ErrRateLimit, Message: "Rate limit exceeded"}}) return } c.Next() } } func checkSecurityLimit(ctx context.Context, rdb *redis.Client, key string, policy securityLimitPolicy) (bool, error) { if rdb == nil { return true, nil } ctx, cancel := context.WithTimeout(ctx, time.Second) defer cancel() count, err := redis.NewScript(` local count = redis.call("INCR", KEYS[1]) if count == 1 then redis.call("EXPIRE", KEYS[1], ARGV[1]) end return count `).Run(ctx, rdb, []string{key}, int(policy.window.Seconds())).Int64() return count <= int64(policy.limit), err } func securityPolicy(method, path string, cfg config.RateLimitConfig) (securityLimitPolicy, bool) { toPolicy := func(name string, limit config.RouteLimitConfig) (securityLimitPolicy, bool) { return securityLimitPolicy{name: name, limit: limit.Requests, window: time.Duration(limit.WindowSeconds) * time.Second}, true } if method == http.MethodPost && (path == "/auth/sign_in" || path == "/api/v1/auth/login") { return toPolicy("login", cfg.Login) } if (method == http.MethodPost || method == http.MethodPut) && (path == "/auth/password" || path == "/api/v1/auth/reset_password") { return toPolicy("password_reset", cfg.PasswordReset) } if strings.HasPrefix(path, "/webhooks/") { return toPolicy("webhook", cfg.Webhook) } if strings.Contains(path, "/upload") || strings.Contains(path, "/direct_uploads") { return toPolicy("public_upload", cfg.PublicUpload) } return securityLimitPolicy{}, false }