- Migration 082: api_keys table (user_id, name, prefix, hashed_key, scopes, last_used_at, expires_at) - APIKey model, APIKeyService (Create, List, Delete, ValidateAPIKey) - APIKeyHandler: GET/POST/DELETE /api/v1/developer/api-keys - AuthMiddleware: X-API-Key and Bearer vza_* accepted as alternative to JWT - CSRF: skip for API key auth (stateless) - Key format: vza_ prefix, SHA-256 hashed storage
202 lines
5.6 KiB
Go
202 lines
5.6 KiB
Go
package middleware
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/redis/go-redis/v9"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// CSRFMiddleware crée un middleware pour la protection CSRF
|
|
// Utilise Redis pour stocker les tokens CSRF associés aux utilisateurs
|
|
type CSRFMiddleware struct {
|
|
redisClient *redis.Client
|
|
logger *zap.Logger
|
|
ttl time.Duration // TTL pour les tokens CSRF (défaut: 1 heure)
|
|
env string // Environnement (development, staging, production)
|
|
}
|
|
|
|
// NewCSRFMiddleware crée une nouvelle instance du middleware CSRF
|
|
func NewCSRFMiddleware(redisClient *redis.Client, logger *zap.Logger) *CSRFMiddleware {
|
|
return &CSRFMiddleware{
|
|
redisClient: redisClient,
|
|
logger: logger,
|
|
ttl: 1 * time.Hour, // Tokens CSRF valides pendant 1 heure
|
|
env: "", // Sera défini via SetEnvironment
|
|
}
|
|
}
|
|
|
|
// SetEnvironment définit l'environnement pour le middleware
|
|
func (m *CSRFMiddleware) SetEnvironment(env string) {
|
|
m.env = env
|
|
}
|
|
|
|
// Middleware retourne le handler Gin pour la protection CSRF
|
|
func (m *CSRFMiddleware) Middleware() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
// CSRF can only be disabled via explicit opt-in (dev/test only).
|
|
// NEVER set CSRF_DISABLED=true in production.
|
|
if os.Getenv("CSRF_DISABLED") == "true" && (m.env == "development" || m.env == "dev" || m.env == "test") {
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
// Ignorer GET, HEAD, OPTIONS (méthodes sûres)
|
|
method := c.Request.Method
|
|
if method == "GET" || method == "HEAD" || method == "OPTIONS" {
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
// v0.102: Skip CSRF for API key auth (X-API-Key) - no cookies, stateless
|
|
if _, hasAPIKey := c.Get("api_key"); hasAPIKey {
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
// Récupérer le userID depuis le contexte (défini par AuthMiddleware)
|
|
userIDInterface, exists := c.Get("user_id")
|
|
if !exists {
|
|
// Si pas d'utilisateur authentifié, pas besoin de CSRF
|
|
// (les routes publiques comme login/register sont exclues)
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
userID, ok := userIDInterface.(uuid.UUID)
|
|
if !ok {
|
|
m.logger.Warn("Invalid user_id type in context for CSRF check")
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
// Récupérer le token CSRF depuis le header
|
|
token := c.GetHeader("X-CSRF-Token")
|
|
if token == "" {
|
|
c.JSON(403, gin.H{
|
|
"success": false,
|
|
"error": gin.H{
|
|
"code": 403,
|
|
"message": "CSRF token required",
|
|
},
|
|
})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
// Vérifier le token dans Redis
|
|
ctx := c.Request.Context()
|
|
key := m.getCSRFKey(userID)
|
|
storedToken, err := m.redisClient.Get(ctx, key).Result()
|
|
if err != nil {
|
|
if err == redis.Nil {
|
|
m.logger.Warn("CSRF token not found in Redis",
|
|
zap.String("user_id", userID.String()),
|
|
zap.String("ip", c.ClientIP()),
|
|
)
|
|
c.JSON(403, gin.H{
|
|
"success": false,
|
|
"error": gin.H{
|
|
"code": 403,
|
|
"message": "Invalid or expired CSRF token",
|
|
},
|
|
})
|
|
c.Abort()
|
|
return
|
|
}
|
|
m.logger.Error("Redis unavailable for CSRF validation - service temporarily degraded",
|
|
zap.Error(err),
|
|
zap.String("user_id", userID.String()),
|
|
)
|
|
c.JSON(503, gin.H{
|
|
"success": false,
|
|
"error": gin.H{
|
|
"code": 503,
|
|
"message": "Service temporarily unavailable. Please retry later.",
|
|
},
|
|
})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
// Comparer les tokens (timing-safe pour éviter les attaques par timing)
|
|
if subtle.ConstantTimeCompare([]byte(storedToken), []byte(token)) != 1 {
|
|
m.logger.Warn("CSRF token mismatch",
|
|
zap.String("user_id", userID.String()),
|
|
zap.String("ip", c.ClientIP()),
|
|
)
|
|
c.JSON(403, gin.H{
|
|
"success": false,
|
|
"error": gin.H{
|
|
"code": 403,
|
|
"message": "Invalid CSRF token",
|
|
},
|
|
})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
// Token valide, continuer
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
// getCSRFKey génère la clé Redis pour un token CSRF
|
|
func (m *CSRFMiddleware) getCSRFKey(userID uuid.UUID) string {
|
|
return fmt.Sprintf("csrf:token:%s", userID.String())
|
|
}
|
|
|
|
// GenerateToken génère un nouveau token CSRF et le stocke dans Redis
|
|
func (m *CSRFMiddleware) GenerateToken(ctx context.Context, userID uuid.UUID) (string, error) {
|
|
// Générer un token aléatoire de 32 bytes (64 caractères hex)
|
|
tokenBytes := make([]byte, 32)
|
|
if _, err := rand.Read(tokenBytes); err != nil {
|
|
return "", fmt.Errorf("failed to generate CSRF token: %w", err)
|
|
}
|
|
token := hex.EncodeToString(tokenBytes)
|
|
|
|
// MVP: Si Redis n'est pas disponible, on retourne quand même le token
|
|
// mais on ne peut pas le stocker pour validation ultérieure.
|
|
// En développement, le middleware de validation ignorera cette absence.
|
|
if m.redisClient == nil {
|
|
if m.env == "development" || m.env == "dev" || m.env == "" {
|
|
return token, nil
|
|
}
|
|
return "", fmt.Errorf("redis client is nil and environment is not development")
|
|
}
|
|
|
|
// Stocker le token dans Redis avec TTL
|
|
key := m.getCSRFKey(userID)
|
|
if err := m.redisClient.Set(ctx, key, token, m.ttl).Err(); err != nil {
|
|
return "", fmt.Errorf("failed to store CSRF token: %w", err)
|
|
}
|
|
|
|
return token, nil
|
|
}
|
|
|
|
// GetToken récupère le token CSRF actuel pour un utilisateur
|
|
func (m *CSRFMiddleware) GetToken(ctx context.Context, userID uuid.UUID) (string, error) {
|
|
// Si Redis n'est pas disponible, on en génère un nouveau sans stocker
|
|
if m.redisClient == nil {
|
|
return m.GenerateToken(ctx, userID)
|
|
}
|
|
|
|
key := m.getCSRFKey(userID)
|
|
token, err := m.redisClient.Get(ctx, key).Result()
|
|
if err != nil {
|
|
if err == redis.Nil {
|
|
// Pas de token existant, en générer un nouveau
|
|
return m.GenerateToken(ctx, userID)
|
|
}
|
|
return "", fmt.Errorf("failed to get CSRF token: %w", err)
|
|
}
|
|
return token, nil
|
|
}
|