package middleware import ( "context" "fmt" "net/http" "os" "strconv" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/redis/go-redis/v9" "golang.org/x/time/rate" ) // RateLimiterConfig configuration pour le rate limiter type RateLimiterConfig struct { // Limites par IP (non authentifié) IPRequestsPerMinute int IPBurst int // Limites par utilisateur authentifié UserRequestsPerMinute int UserBurst int // Configuration Redis RedisClient *redis.Client KeyPrefix string } // RateLimiter middleware pour limiter le taux de requêtes type RateLimiter struct { config *RateLimiterConfig ipLimiter *rate.Limiter userLimiter *rate.Limiter } // NewRateLimiter crée un nouveau rate limiter func NewRateLimiter(config *RateLimiterConfig) *RateLimiter { return &RateLimiter{ config: config, ipLimiter: rate.NewLimiter( rate.Every(time.Minute/time.Duration(config.IPRequestsPerMinute)), config.IPBurst, ), userLimiter: rate.NewLimiter( rate.Every(time.Minute/time.Duration(config.UserRequestsPerMinute)), config.UserBurst, ), } } // Routes exclues du rate limiting (routes critiques) var excludedRateLimitPathsRedis = []string{ "/health", "/healthz", "/readyz", "/api/v1/health", "/api/v1/healthz", "/api/v1/readyz", "/api/v1/csrf-token", "/api/v1/auth/register", "/api/v1/auth/login", "/api/v1/auth/refresh", "/api/v1/auth/verify-email", "/api/v1/auth/resend-verification", "/api/v1/auth/check-username", "/swagger", "/docs", } // isExcludedPathRedis vérifie si un chemin est exclu du rate limiting (version Redis) func isExcludedPathRedis(path string) bool { for _, excluded := range excludedRateLimitPathsRedis { if path == excluded || (len(path) > len(excluded) && path[:len(excluded)] == excluded) { return true } } return false } // RateLimitMiddleware middleware principal de rate limiting func (rl *RateLimiter) RateLimitMiddleware() gin.HandlerFunc { return func(c *gin.Context) { // Exclure les routes critiques du rate limiting if isExcludedPathRedis(c.Request.URL.Path) { c.Next() return } // DÉSACTIVER le rate limiting en mode test/e2e/development pour les tests E2E et développement // Vérifier via header ou variable d'environnement if c.GetHeader("X-Test-Mode") == "true" || c.GetHeader("X-E2E-Test") == "true" || os.Getenv("NODE_ENV") == "test" || os.Getenv("NODE_ENV") == "e2e" || os.Getenv("NODE_ENV") == "development" || os.Getenv("APP_ENV") == "test" || os.Getenv("APP_ENV") == "e2e" || os.Getenv("APP_ENV") == "development" { c.Next() return } // Déterminer si l'utilisateur est authentifié userIDInterface, isAuthenticated := c.Get("user_id") var limiter *rate.Limiter var key string var limit int if isAuthenticated { // Utilisateur authentifié - limite plus élevée // BE-SVC-002: Support UUID for user rate limiting limiter = rl.userLimiter // Convertir userID en string pour la clé Redis var userIDStr string switch v := userIDInterface.(type) { case uuid.UUID: userIDStr = v.String() case string: userIDStr = v default: userIDStr = fmt.Sprintf("%v", v) } key = fmt.Sprintf("%s:user:%s", rl.config.KeyPrefix, userIDStr) limit = rl.config.UserRequestsPerMinute } else { // IP non authentifiée - limite plus stricte limiter = rl.ipLimiter key = fmt.Sprintf("%s:ip:%s", rl.config.KeyPrefix, c.ClientIP()) limit = rl.config.IPRequestsPerMinute } // Vérifier la limite avec Redis pour persistance allowed, remaining, err := rl.checkRedisLimit(c.Request.Context(), key, limit) if err != nil { // En cas d'erreur Redis, utiliser le limiter local allowed = limiter.Allow() remaining = int(limiter.Tokens()) } // Ajouter les headers de rate limiting c.Header("X-RateLimit-Limit", strconv.Itoa(limit)) c.Header("X-RateLimit-Remaining", strconv.Itoa(remaining)) c.Header("X-RateLimit-Reset", strconv.FormatInt(time.Now().Add(time.Minute).Unix(), 10)) if !allowed { c.JSON(http.StatusTooManyRequests, gin.H{ "error": "Rate limit exceeded", "retry_after": 60, }) c.Abort() return } c.Next() } } // checkRedisLimit vérifie la limite dans Redis func (rl *RateLimiter) checkRedisLimit(ctx context.Context, key string, limit int) (bool, int, error) { // Utiliser un script Lua pour l'atomicité script := ` local key = KEYS[1] local limit = tonumber(ARGV[1]) local window = tonumber(ARGV[2]) local current = redis.call('GET', key) if current == false then redis.call('SET', key, 1, 'EX', window) return {1, limit - 1} end local count = tonumber(current) if count < limit then redis.call('INCR', key) return {1, limit - count - 1} else return {0, 0} end ` result, err := rl.config.RedisClient.Eval( ctx, script, []string{key}, limit, 60, // 60 secondes ).Result() if err != nil { return false, 0, err } results := result.([]interface{}) allowed := results[0].(int64) == 1 remaining := int(results[1].(int64)) return allowed, remaining, nil } // RateLimitByIP middleware pour limiter par IP uniquement func (rl *RateLimiter) RateLimitByIP() gin.HandlerFunc { return func(c *gin.Context) { key := fmt.Sprintf("%s:ip:%s", rl.config.KeyPrefix, c.ClientIP()) allowed, remaining, err := rl.checkRedisLimit(c.Request.Context(), key, rl.config.IPRequestsPerMinute) if err != nil { allowed = rl.ipLimiter.Allow() remaining = int(rl.ipLimiter.Tokens()) } // INT-013: Standardize rate limit response format resetTime := time.Now().Add(time.Minute).Unix() retryAfter := 60 c.Header("X-RateLimit-Limit", strconv.Itoa(rl.config.IPRequestsPerMinute)) c.Header("X-RateLimit-Remaining", strconv.Itoa(remaining)) c.Header("X-RateLimit-Reset", strconv.FormatInt(resetTime, 10)) if !allowed { c.Header("Retry-After", strconv.Itoa(retryAfter)) c.JSON(http.StatusTooManyRequests, gin.H{ "success": false, "error": gin.H{ "code": 429, "message": "Rate limit exceeded. Please try again later.", "details": []gin.H{ { "field": "rate_limit", "message": fmt.Sprintf("You have exceeded the rate limit of %d requests per minute", rl.config.IPRequestsPerMinute), }, }, "retry_after": retryAfter, "limit": rl.config.IPRequestsPerMinute, "remaining": 0, "reset": resetTime, }, }) c.Abort() return } c.Next() } } // UploadRateLimit middleware pour limiter les uploads de tracks par utilisateur // Limite: 10 uploads par heure par utilisateur func UploadRateLimit(redisClient *redis.Client) gin.HandlerFunc { return func(c *gin.Context) { userID := c.GetInt64("user_id") if userID == 0 { // Si pas d'utilisateur authentifié, passer au suivant c.Next() return } // Clé Redis pour cet utilisateur key := fmt.Sprintf("upload_rate_limit:%d", userID) limit := 10 // 10 uploads par heure window := time.Hour // Script Lua pour l'atomicité script := ` local key = KEYS[1] local limit = tonumber(ARGV[1]) local window = tonumber(ARGV[2]) local current = redis.call('GET', key) if current == false then redis.call('SET', key, 1, 'EX', window) return {1, limit - 1} end local count = tonumber(current) if count < limit then redis.call('INCR', key) return {1, limit - count - 1} else return {0, 0} end ` result, err := redisClient.Eval( c.Request.Context(), script, []string{key}, limit, int(window.Seconds()), ).Result() if err != nil { // En cas d'erreur Redis, autoriser la requête (fail-open) c.Next() return } results := result.([]interface{}) allowed := results[0].(int64) == 1 remaining := int(results[1].(int64)) // Ajouter les headers de rate limiting c.Header("X-RateLimit-Limit", strconv.Itoa(limit)) c.Header("X-RateLimit-Remaining", strconv.Itoa(remaining)) c.Header("X-RateLimit-Reset", strconv.FormatInt(time.Now().Add(window).Unix(), 10)) if !allowed { c.JSON(http.StatusTooManyRequests, gin.H{ "error": "upload rate limit exceeded", "retry_after": int(window.Seconds()), }) c.Abort() return } c.Next() } }