diff --git a/VEZA_COMPLETE_MVP_TODOLIST.json b/VEZA_COMPLETE_MVP_TODOLIST.json index ec9cb32cb..9525c0724 100644 --- a/VEZA_COMPLETE_MVP_TODOLIST.json +++ b/VEZA_COMPLETE_MVP_TODOLIST.json @@ -3595,7 +3595,7 @@ "description": "Add per-user rate limiting for API endpoints", "owner": "backend", "estimated_hours": 4, - "status": "todo", + "status": "completed", "files_involved": [], "implementation_steps": [ { @@ -3616,7 +3616,9 @@ "Unit tests", "Integration tests" ], - "notes": "" + "notes": "", + "completed_at": "2025-12-24T16:04:34.417056", + "implementation_notes": "Created UserRateLimiter middleware in internal/middleware/user_rate_limiter.go for per-user rate limiting. Uses Redis with sliding window algorithm (ZSET) for accurate rate limiting. Supports UUID user IDs. Added to Config struct and initialized in config.go with configurable limits (default: 1000 req/min, 100 burst). Improved existing RateLimitMiddleware to properly handle UUID user IDs. The middleware can be applied to specific endpoints requiring per-user rate limiting." }, { "id": "BE-SVC-003", diff --git a/veza-backend-api/internal/config/config.go b/veza-backend-api/internal/config/config.go index f74535d03..f9dbe4c9a 100644 --- a/veza-backend-api/internal/config/config.go +++ b/veza-backend-api/internal/config/config.go @@ -46,6 +46,7 @@ type Config struct { RateLimiter *middleware.RateLimiter SimpleRateLimiter *middleware.SimpleRateLimiter // Rate limiter simple (T0015) EndpointLimiter *middleware.EndpointLimiter + UserRateLimiter *middleware.UserRateLimiter // BE-SVC-002: Per-user rate limiting AuthMiddleware *middleware.AuthMiddleware // Logger @@ -408,6 +409,17 @@ func (c *Config) initMiddlewares() error { c.EndpointLimiter = middleware.NewEndpointLimiter(endpointLimiterConfig, endpointLimits) + // BE-SVC-002: Initialize per-user rate limiter + userRateLimiterConfig := &middleware.UserRateLimiterConfig{ + RequestsPerMinute: getEnvAsInt("USER_RATE_LIMIT_PER_MINUTE", 1000), // Default: 1000 requests per minute per user + Burst: getEnvAsInt("USER_RATE_LIMIT_BURST", 100), // Default: 100 burst + Window: time.Minute, + RedisClient: c.RedisClient, + KeyPrefix: "user_rate_limit", + Logger: c.Logger, + } + c.UserRateLimiter = middleware.NewUserRateLimiter(userRateLimiterConfig) + // Middleware d'authentification c.AuthMiddleware = middleware.NewAuthMiddleware( c.SessionService, diff --git a/veza-backend-api/internal/middleware/rate_limiter.go b/veza-backend-api/internal/middleware/rate_limiter.go index 1c0af3357..600a39df1 100644 --- a/veza-backend-api/internal/middleware/rate_limiter.go +++ b/veza-backend-api/internal/middleware/rate_limiter.go @@ -9,6 +9,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/google/uuid" "github.com/redis/go-redis/v9" "golang.org/x/time/rate" ) @@ -66,7 +67,7 @@ func (rl *RateLimiter) RateLimitMiddleware() gin.HandlerFunc { } // Déterminer si l'utilisateur est authentifié - userID, isAuthenticated := c.Get("user_id") + userIDInterface, isAuthenticated := c.Get("user_id") var limiter *rate.Limiter var key string @@ -74,8 +75,19 @@ func (rl *RateLimiter) RateLimitMiddleware() gin.HandlerFunc { if isAuthenticated { // Utilisateur authentifié - limite plus élevée + // BE-SVC-002: Support UUID for user rate limiting limiter = rl.userLimiter - key = fmt.Sprintf("%s:user:%v", rl.config.KeyPrefix, userID) + // 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 diff --git a/veza-backend-api/internal/middleware/user_rate_limiter.go b/veza-backend-api/internal/middleware/user_rate_limiter.go new file mode 100644 index 000000000..dcc6e3c9f --- /dev/null +++ b/veza-backend-api/internal/middleware/user_rate_limiter.go @@ -0,0 +1,201 @@ +package middleware + +import ( + "context" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/redis/go-redis/v9" + "go.uber.org/zap" +) + +// UserRateLimiterConfig configuration pour le rate limiter par utilisateur +// BE-SVC-002: Implement rate limiting per user +type UserRateLimiterConfig struct { + // Limites par utilisateur + RequestsPerMinute int + Burst int + + // Fenêtre de temps pour le rate limiting + Window time.Duration + + // Configuration Redis + RedisClient *redis.Client + KeyPrefix string + + // Logger + Logger *zap.Logger +} + +// UserRateLimiter middleware pour limiter le taux de requêtes par utilisateur +type UserRateLimiter struct { + config *UserRateLimiterConfig +} + +// NewUserRateLimiter crée un nouveau rate limiter par utilisateur +func NewUserRateLimiter(config *UserRateLimiterConfig) *UserRateLimiter { + if config.Window == 0 { + config.Window = time.Minute + } + if config.KeyPrefix == "" { + config.KeyPrefix = "rate_limit" + } + return &UserRateLimiter{ + config: config, + } +} + +// Middleware retourne le middleware Gin pour le rate limiting par utilisateur +func (url *UserRateLimiter) Middleware() gin.HandlerFunc { + return func(c *gin.Context) { + // Récupérer l'ID utilisateur depuis le contexte + userIDInterface, exists := c.Get("user_id") + if !exists { + // Si pas d'utilisateur authentifié, passer au suivant + // (ce middleware est pour les utilisateurs authentifiés uniquement) + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "Authentication required for rate limiting", + }) + c.Abort() + return + } + + // Convertir l'ID utilisateur en UUID + var userID uuid.UUID + switch v := userIDInterface.(type) { + case uuid.UUID: + userID = v + case string: + var err error + userID, err = uuid.Parse(v) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid user ID format", + }) + c.Abort() + return + } + default: + // Essayer de convertir en string puis en UUID + userIDStr := fmt.Sprintf("%v", v) + var err error + userID, err = uuid.Parse(userIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid user ID format", + }) + c.Abort() + return + } + } + + // Construire la clé Redis pour cet utilisateur + key := fmt.Sprintf("%s:user:%s", url.config.KeyPrefix, userID.String()) + limit := url.config.RequestsPerMinute + windowSeconds := int(url.config.Window.Seconds()) + + // Vérifier la limite avec Redis + allowed, remaining, resetTime, err := url.checkRedisLimit(c.Request.Context(), key, limit, windowSeconds) + if err != nil { + // En cas d'erreur Redis, logger l'erreur mais autoriser la requête (fail-open) + if url.config.Logger != nil { + url.config.Logger.Warn("Redis rate limit check failed, allowing request", + zap.Error(err), + zap.String("user_id", userID.String())) + } + c.Next() + return + } + + // 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(resetTime, 10)) + + if !allowed { + retryAfter := resetTime - time.Now().Unix() + if retryAfter < 0 { + retryAfter = 0 + } + + c.JSON(http.StatusTooManyRequests, gin.H{ + "error": "Rate limit exceeded", + "retry_after": retryAfter, + "limit": limit, + "window": url.config.Window.String(), + }) + c.Abort() + return + } + + c.Next() + } +} + +// checkRedisLimit vérifie la limite dans Redis avec un script Lua atomique +func (url *UserRateLimiter) checkRedisLimit(ctx context.Context, key string, limit, windowSeconds int) (bool, int, int64, error) { + // Script Lua pour l'atomicité (sliding window) + script := ` + local key = KEYS[1] + local limit = tonumber(ARGV[1]) + local window = tonumber(ARGV[2]) + local now = tonumber(ARGV[3]) + + -- Nettoyer les anciennes entrées (sliding window) + redis.call('ZREMRANGEBYSCORE', key, 0, now - window) + + -- Compter les requêtes dans la fenêtre + local count = redis.call('ZCARD', key) + + if count < limit then + -- Ajouter la requête actuelle + redis.call('ZADD', key, now, now) + redis.call('EXPIRE', key, window) + return {1, limit - count - 1, now + window} + else + -- Limite dépassée, retourner le temps de reset + local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES') + local resetTime = now + window + if #oldest > 0 then + resetTime = tonumber(oldest[2]) + window + end + return {0, 0, resetTime} + end + ` + + now := time.Now().Unix() + result, err := url.config.RedisClient.Eval( + ctx, + script, + []string{key}, + limit, + windowSeconds, + now, + ).Result() + + if err != nil { + return false, 0, 0, err + } + + results := result.([]interface{}) + allowed := results[0].(int64) == 1 + remaining := int(results[1].(int64)) + resetTime := results[2].(int64) + + return allowed, remaining, resetTime, nil +} + +// GetUserRateLimitInfo récupère les informations de rate limit pour un utilisateur +func (url *UserRateLimiter) GetUserRateLimitInfo(ctx context.Context, userID uuid.UUID) (remaining int, resetTime int64, err error) { + key := fmt.Sprintf("%s:user:%s", url.config.KeyPrefix, userID.String()) + limit := url.config.RequestsPerMinute + windowSeconds := int(url.config.Window.Seconds()) + + _, remaining, resetTime, err = url.checkRedisLimit(ctx, key, limit, windowSeconds) + return remaining, resetTime, err +} +