- Applied RegisterRateLimit to POST /auth/register (3 attempts/hour) - Applied PasswordResetRateLimit to password reset endpoints (3 attempts/hour) - Added VerifyEmailRateLimit for POST /auth/verify-email (5 attempts/hour) - Added ResendVerificationRateLimit for POST /auth/resend-verification (3 attempts/hour) - Login endpoint already had rate limiting (5 attempts/15min) - All rate limits are IP-based and use Redis for persistence - Rate limiting disabled in test/e2e environments Phase: PHASE-4 Priority: P1 Progress: 7/267 (2.6%)
290 lines
7.7 KiB
Go
290 lines
7.7 KiB
Go
package middleware
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
// EndpointLimiterConfig configuration pour les limites par endpoint
|
|
type EndpointLimiterConfig struct {
|
|
RedisClient *redis.Client
|
|
KeyPrefix string
|
|
}
|
|
|
|
// EndpointLimits définit les limites pour chaque endpoint
|
|
type EndpointLimits struct {
|
|
// Login: 5 tentatives/15min par IP
|
|
LoginAttempts int
|
|
LoginWindow time.Duration
|
|
|
|
// Register: 3 comptes/heure par IP
|
|
RegisterAttempts int
|
|
RegisterWindow time.Duration
|
|
|
|
// Password reset: 3 tentatives/heure
|
|
PasswordResetAttempts int
|
|
PasswordResetWindow time.Duration
|
|
|
|
// Upload: 10 fichiers/heure par user
|
|
UploadAttempts int
|
|
UploadWindow time.Duration
|
|
}
|
|
|
|
// DefaultEndpointLimits retourne les limites par défaut
|
|
func DefaultEndpointLimits() *EndpointLimits {
|
|
return &EndpointLimits{
|
|
LoginAttempts: 5,
|
|
LoginWindow: 15 * time.Minute,
|
|
RegisterAttempts: 3,
|
|
RegisterWindow: time.Hour,
|
|
PasswordResetAttempts: 3,
|
|
PasswordResetWindow: time.Hour,
|
|
UploadAttempts: 10,
|
|
UploadWindow: time.Hour,
|
|
}
|
|
}
|
|
|
|
// EndpointLimiter gère les limites par endpoint
|
|
type EndpointLimiter struct {
|
|
config *EndpointLimiterConfig
|
|
limits *EndpointLimits
|
|
}
|
|
|
|
// NewEndpointLimiter crée un nouveau endpoint limiter
|
|
func NewEndpointLimiter(config *EndpointLimiterConfig, limits *EndpointLimits) *EndpointLimiter {
|
|
return &EndpointLimiter{
|
|
config: config,
|
|
limits: limits,
|
|
}
|
|
}
|
|
|
|
// LoginRateLimit middleware pour limiter les tentatives de login
|
|
func (el *EndpointLimiter) LoginRateLimit() gin.HandlerFunc {
|
|
return el.createEndpointLimit(
|
|
"login",
|
|
el.limits.LoginAttempts,
|
|
el.limits.LoginWindow,
|
|
"Too many login attempts",
|
|
)
|
|
}
|
|
|
|
// RegisterRateLimit middleware pour limiter les inscriptions
|
|
func (el *EndpointLimiter) RegisterRateLimit() gin.HandlerFunc {
|
|
return el.createEndpointLimit(
|
|
"register",
|
|
el.limits.RegisterAttempts,
|
|
el.limits.RegisterWindow,
|
|
"Too many registration attempts",
|
|
)
|
|
}
|
|
|
|
// PasswordResetRateLimit middleware pour limiter les reset de mot de passe
|
|
func (el *EndpointLimiter) PasswordResetRateLimit() gin.HandlerFunc {
|
|
return el.createEndpointLimit(
|
|
"password_reset",
|
|
el.limits.PasswordResetAttempts,
|
|
el.limits.PasswordResetWindow,
|
|
"Too many password reset attempts",
|
|
)
|
|
}
|
|
|
|
// VerifyEmailRateLimit middleware pour limiter les tentatives de vérification d'email
|
|
// BE-SEC-005: Implement rate limiting for authentication endpoints
|
|
func (el *EndpointLimiter) VerifyEmailRateLimit() gin.HandlerFunc {
|
|
return el.createEndpointLimit(
|
|
"verify_email",
|
|
5, // 5 tentatives par heure
|
|
time.Hour, // Fenêtre de 1 heure
|
|
"Too many email verification attempts",
|
|
)
|
|
}
|
|
|
|
// ResendVerificationRateLimit middleware pour limiter les renvois de vérification
|
|
// BE-SEC-005: Implement rate limiting for authentication endpoints
|
|
func (el *EndpointLimiter) ResendVerificationRateLimit() gin.HandlerFunc {
|
|
return el.createEndpointLimit(
|
|
"resend_verification",
|
|
3, // 3 tentatives par heure
|
|
time.Hour, // Fenêtre de 1 heure
|
|
"Too many verification resend attempts",
|
|
)
|
|
}
|
|
|
|
// UploadRateLimit middleware pour limiter les uploads par utilisateur
|
|
func (el *EndpointLimiter) UploadRateLimit() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
// Récupérer l'ID utilisateur depuis le contexte
|
|
userID, exists := c.Get("user_id")
|
|
if !exists {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
key := fmt.Sprintf("%s:upload:user:%v", el.config.KeyPrefix, userID)
|
|
allowed, remaining, err := el.checkLimit(c.Request.Context(), key, el.limits.UploadAttempts, el.limits.UploadWindow)
|
|
|
|
if err != nil {
|
|
// En cas d'erreur Redis, autoriser la requête
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
c.Header("X-UploadLimit-Limit", strconv.Itoa(el.limits.UploadAttempts))
|
|
c.Header("X-UploadLimit-Remaining", strconv.Itoa(remaining))
|
|
c.Header("X-UploadLimit-Reset", strconv.FormatInt(time.Now().Add(el.limits.UploadWindow).Unix(), 10))
|
|
|
|
if !allowed {
|
|
c.JSON(http.StatusTooManyRequests, gin.H{
|
|
"error": "Upload limit exceeded",
|
|
"retry_after": int(el.limits.UploadWindow.Seconds()),
|
|
})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
// createEndpointLimit crée un middleware de limitation pour un endpoint
|
|
func (el *EndpointLimiter) createEndpointLimit(
|
|
endpoint string,
|
|
attempts int,
|
|
window time.Duration,
|
|
errorMessage string,
|
|
) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
// DÉSACTIVER le rate limiting en mode test/e2e pour les tests E2E
|
|
// Vérifier les headers et variables d'environnement (Go et Node.js)
|
|
if c.GetHeader("X-Test-Mode") == "true" ||
|
|
c.GetHeader("X-E2E-Test") == "true" ||
|
|
os.Getenv("GO_ENV") == "test" ||
|
|
os.Getenv("GO_ENV") == "e2e" ||
|
|
os.Getenv("E2E_TEST") == "true" ||
|
|
os.Getenv("NODE_ENV") == "test" ||
|
|
os.Getenv("NODE_ENV") == "e2e" ||
|
|
os.Getenv("APP_ENV") == "test" ||
|
|
os.Getenv("APP_ENV") == "e2e" {
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
key := fmt.Sprintf("%s:%s:ip:%s", el.config.KeyPrefix, endpoint, c.ClientIP())
|
|
allowed, remaining, err := el.checkLimit(c.Request.Context(), key, attempts, window)
|
|
|
|
if err != nil {
|
|
// En cas d'erreur Redis, autoriser la requête
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
headerPrefix := fmt.Sprintf("X-%sLimit", capitalize(endpoint))
|
|
c.Header(headerPrefix+"-Limit", strconv.Itoa(attempts))
|
|
c.Header(headerPrefix+"-Remaining", strconv.Itoa(remaining))
|
|
c.Header(headerPrefix+"-Reset", strconv.FormatInt(time.Now().Add(window).Unix(), 10))
|
|
|
|
if !allowed {
|
|
c.JSON(http.StatusTooManyRequests, gin.H{
|
|
"error": errorMessage,
|
|
"retry_after": int(window.Seconds()),
|
|
})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
// checkLimit vérifie si une limite est respectée
|
|
func (el *EndpointLimiter) checkLimit(ctx context.Context, key string, attempts int, window time.Duration) (bool, int, error) {
|
|
// Script Lua pour l'atomicité
|
|
script := `
|
|
local key = KEYS[1]
|
|
local attempts = 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, attempts - 1}
|
|
end
|
|
|
|
local count = tonumber(current)
|
|
if count < attempts then
|
|
redis.call('INCR', key)
|
|
return {1, attempts - count - 1}
|
|
else
|
|
return {0, 0}
|
|
end
|
|
`
|
|
|
|
result, err := el.config.RedisClient.Eval(
|
|
ctx,
|
|
script,
|
|
[]string{key},
|
|
attempts,
|
|
int(window.Seconds()),
|
|
).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
|
|
}
|
|
|
|
// capitalize met en majuscule la première lettre
|
|
func capitalize(s string) string {
|
|
if len(s) == 0 {
|
|
return s
|
|
}
|
|
return string(s[0]-32) + s[1:]
|
|
}
|
|
|
|
// RateLimitByUser middleware pour limiter par utilisateur (pour endpoints génériques)
|
|
func (el *EndpointLimiter) RateLimitByUser(attempts int, window time.Duration, errorMessage string) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
userID, exists := c.Get("user_id")
|
|
if !exists {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
key := fmt.Sprintf("%s:user:%v", el.config.KeyPrefix, userID)
|
|
allowed, remaining, err := el.checkLimit(c.Request.Context(), key, attempts, window)
|
|
|
|
if err != nil {
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
c.Header("X-UserLimit-Limit", strconv.Itoa(attempts))
|
|
c.Header("X-UserLimit-Remaining", strconv.Itoa(remaining))
|
|
c.Header("X-UserLimit-Reset", strconv.FormatInt(time.Now().Add(window).Unix(), 10))
|
|
|
|
if !allowed {
|
|
c.JSON(http.StatusTooManyRequests, gin.H{
|
|
"error": errorMessage,
|
|
"retry_after": int(window.Seconds()),
|
|
})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
c.Next()
|
|
}
|
|
}
|