veza/veza-backend-api/internal/middleware/auth.go
senke ebf3276daa feat(middleware): wire UserRateLimiter into AuthMiddleware (BE-SVC-002)
UserRateLimiter had been created in initMiddlewares() + stored on
config.UserRateLimiter but never mounted — dead wiring. Per-user rate
limiting was silently not running anywhere.

Applying it as a separate `v1.Use(...)` would fire *before* the JWT
auth middleware sets `user_id`, so the limiter would always skip. The
alternative (add it after every `RequireAuth()` in ~15 route files)
bloats every routes_*.go and invites forgetting.

Solution: centralise it on AuthMiddleware. After a successful
`authenticate()` in `RequireAuth`, invoke the limiter's handler. When
the limiter is nil (tests, early boot), it's a no-op.

Changes:
  - internal/middleware/auth.go
    * new field  AuthMiddleware.userRateLimiter *UserRateLimiter
    * new method AuthMiddleware.SetUserRateLimiter(url)
    * RequireAuth() flow: authenticate → presence → user rate limit
      → c.Next(). Abort surfaces as early-return without c.Next().
  - internal/config/middlewares_init.go
    * call c.AuthMiddleware.SetUserRateLimiter(c.UserRateLimiter)
      right after AuthMiddleware construction.

Behavior:
  - Authenticated requests: per-user limit enforced via Redis, with
    X-RateLimit-Limit / Remaining / Reset headers, 429 + retry-after
    on overflow. Defaults: 1000 req/min, burst 100 (env-tunable via
    USER_RATE_LIMIT_PER_MINUTE / USER_RATE_LIMIT_BURST).
  - Unauthenticated requests: RequireAuth already rejected them → the
    limiter never runs, no behavior change there.

Tests: `go test ./internal/middleware/ -short` green (33s).
`go build ./...` + `go vet ./internal/middleware/` clean.

Refs: AUDIT_REPORT.md §4.3 "UserRateLimiter configuré non wiré"
      + §9 priority #11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 09:52:07 +02:00

847 lines
26 KiB
Go

package middleware
import (
"context"
"net/http"
"os"
"reflect"
"strings"
"time"
"veza-backend-api/internal/response"
"veza-backend-api/internal/services"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// ÉTAPE 3.4: Interfaces pour permettre l'injection de dépendances et les tests avec mocks
// SessionValidator définit l'interface pour valider les sessions
type SessionValidator interface {
ValidateSession(ctx context.Context, token string) (*services.Session, error)
RefreshSession(ctx context.Context, token string, newExpiresIn time.Duration) error
}
// AuditRecorder définit l'interface pour enregistrer les actions d'audit
type AuditRecorder interface {
LogAction(ctx context.Context, req *services.AuditLogCreateRequest) error
}
// PermissionChecker définit l'interface pour vérifier les permissions
type PermissionChecker interface {
HasRole(ctx context.Context, userID uuid.UUID, roleName string) (bool, error)
HasPermission(ctx context.Context, userID uuid.UUID, permissionName string) (bool, error)
}
// PresenceUpdater updates user presence (v0.301 Lot P1)
type PresenceUpdater interface {
UpdatePresence(ctx context.Context, userID uuid.UUID, status string) error
}
// TokenBlacklistChecker checks if a token is blacklisted (VEZA-SEC-006)
type TokenBlacklistChecker interface {
IsBlacklisted(ctx context.Context, token string) (bool, error)
}
// TwoFactorChecker checks if a user has 2FA enabled (SFIX-001)
type TwoFactorChecker interface {
GetTwoFactorStatus(ctx context.Context, userID uuid.UUID) (bool, error)
}
// AuthMiddleware middleware d'authentification avec validation de session
// ÉTAPE 3.4: Utilise des interfaces pour permettre l'injection de dépendances et les tests
// v0.102: Supports X-API-Key for developer API keys (when apiKeyService is set)
// v0.301: Optional presence update on each authenticated request
type AuthMiddleware struct {
sessionService SessionValidator
auditService AuditRecorder
permissionService PermissionChecker
jwtService *services.JWTService // T0204: Use JWTService for validation
userService *services.UserService // T0204: Check TokenVersion
apiKeyService *services.APIKeyService // v0.102: Optional, for X-API-Key auth
presenceService PresenceUpdater // v0.301: Optional, updates last_seen_at on auth
tokenBlacklist TokenBlacklistChecker // VEZA-SEC-006: Optional, nil if Redis unavailable
twoFactorChecker TwoFactorChecker // SFIX-001: Optional, for MFA enforcement
userRateLimiter *UserRateLimiter // BE-SVC-002: Optional, per-user rate limiting applied post-auth
logger *zap.Logger
}
// NewAuthMiddleware crée un nouveau middleware d'authentification
// apiKeyService can be nil; when set, X-API-Key header is accepted as alternative to JWT
// tokenBlacklist can be nil; when set (Redis available), blacklisted tokens are rejected (VEZA-SEC-006)
func NewAuthMiddleware(
sessionService SessionValidator,
auditService AuditRecorder,
permissionService PermissionChecker,
jwtService *services.JWTService,
userService *services.UserService,
apiKeyService *services.APIKeyService,
tokenBlacklist TokenBlacklistChecker,
logger *zap.Logger,
) *AuthMiddleware {
return &AuthMiddleware{
sessionService: sessionService,
auditService: auditService,
permissionService: permissionService,
jwtService: jwtService,
userService: userService,
apiKeyService: apiKeyService,
tokenBlacklist: tokenBlacklist,
logger: logger,
}
}
// SetPresenceService sets the presence service for updating last_seen_at (v0.301 Lot P1)
func (am *AuthMiddleware) SetPresenceService(ps PresenceUpdater) {
am.presenceService = ps
}
// SetTwoFactorChecker sets the 2FA checker for MFA enforcement (SFIX-001)
func (am *AuthMiddleware) SetTwoFactorChecker(tfc TwoFactorChecker) {
am.twoFactorChecker = tfc
}
// SetUserRateLimiter wires the per-user rate limiter so it runs automatically
// after every successful RequireAuth / RequireAuthWithMFA call. Centralising it
// here avoids sprinkling UserRateLimiter.Middleware() across every protected
// route group. nil is fine — limiter simply skipped when absent.
// (BE-SVC-002)
func (am *AuthMiddleware) SetUserRateLimiter(url *UserRateLimiter) {
am.userRateLimiter = url
}
// isSessionCheckRequest returns true for GET /auth/me (or path ending with /auth/me).
// Used to avoid WARN logs when the frontend probes session without a token (expected case).
func isSessionCheckRequest(path string) bool {
return strings.HasSuffix(path, "/auth/me") || path == "/auth/me"
}
// extractAPIKeyFromRequest extracts API key from X-API-Key or Authorization: Bearer (for developer keys)
func extractAPIKeyFromRequest(c *gin.Context) string {
if k := c.GetHeader("X-API-Key"); k != "" {
return k
}
authHeader := c.GetHeader("Authorization")
if authHeader != "" {
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") {
return parts[1]
}
}
return ""
}
// authenticate performs the core authentication logic
// Returns userID and true if successful, otherwise handles error response and returns false
func (am *AuthMiddleware) authenticate(c *gin.Context) (uuid.UUID, bool) {
// SECURITY: Try cookie first (new method), fallback to Authorization header (backward compatibility)
tokenString := ""
if cookie, err := c.Cookie("access_token"); err == nil && cookie != "" {
tokenString = cookie
} else {
// Fallback to Authorization header (backward compatibility)
authHeader := c.GetHeader("Authorization")
if authHeader != "" {
tokenParts := strings.Split(authHeader, " ")
if len(tokenParts) == 2 && tokenParts[0] == "Bearer" {
tokenString = tokenParts[1]
}
}
}
if tokenString == "" {
// v0.102: Try X-API-Key when no JWT (developer API keys)
if am.apiKeyService != nil {
if apiKey := extractAPIKeyFromRequest(c); apiKey != "" && strings.HasPrefix(apiKey, "vza_") {
key, err := am.apiKeyService.ValidateAPIKey(c.Request.Context(), apiKey)
if err == nil {
c.Set("user_id", key.UserID)
c.Set("api_key", key)
return key.UserID, true
}
am.logger.Warn("Invalid API key", zap.String("ip", c.ClientIP()))
response.Unauthorized(c, "Invalid API key")
c.Abort()
return uuid.Nil, false
}
}
if isSessionCheckRequest(c.Request.URL.Path) {
am.logger.Debug("Missing access token (cookie or Authorization header)",
zap.String("path", c.Request.URL.Path),
zap.String("ip", c.ClientIP()),
)
} else {
am.logger.Warn("Missing access token (cookie or Authorization header)",
zap.String("ip", c.ClientIP()),
zap.String("user_agent", c.GetHeader("User-Agent")),
)
}
response.Unauthorized(c, "Access token required")
c.Abort()
return uuid.Nil, false
}
// v0.102: If Bearer token looks like API key (vza_ prefix), try API key auth
if am.apiKeyService != nil && strings.HasPrefix(tokenString, "vza_") {
key, err := am.apiKeyService.ValidateAPIKey(c.Request.Context(), tokenString)
if err == nil {
c.Set("user_id", key.UserID)
c.Set("api_key", key)
return key.UserID, true
}
}
// T0204: Validate token using JWTService (checks sig, exp, iss, aud, alg)
claims, err := am.jwtService.ValidateToken(tokenString)
if err != nil {
am.logger.Warn("Invalid JWT token",
zap.Error(err),
zap.String("ip", c.ClientIP()),
)
response.Unauthorized(c, "Invalid token")
c.Abort()
return uuid.Nil, false
}
// VEZA-SEC-006: Check token blacklist (revoked tokens)
// Note: am.tokenBlacklist may be non-nil interface holding nil pointer when Redis is unavailable
if am.tokenBlacklist != nil && !reflect.ValueOf(am.tokenBlacklist).IsNil() {
blacklisted, err := am.tokenBlacklist.IsBlacklisted(c.Request.Context(), tokenString)
if err != nil {
am.logger.Warn("Token blacklist check failed", zap.Error(err))
response.Unauthorized(c, "Invalid token")
c.Abort()
return uuid.Nil, false
}
if blacklisted {
response.Unauthorized(c, "Token revoked")
c.Abort()
return uuid.Nil, false
}
}
userID := claims.UserID
// T0204: Check TokenVersion against DB to ensure immediate revocation
user, err := am.userService.GetByID(c.Request.Context(), userID)
if err != nil {
am.logger.Warn("User not found during auth",
zap.Error(err),
zap.String("user_id", userID.String()),
)
response.Unauthorized(c, "User not found")
c.Abort()
return uuid.Nil, false
}
if err := am.jwtService.VerifyTokenVersion(claims, user.TokenVersion); err != nil {
am.logger.Warn("Token version mismatch (revoked)",
zap.Error(err),
zap.String("user_id", userID.String()),
zap.Int("token_version", claims.TokenVersion),
zap.Int("user_version", user.TokenVersion),
)
response.Unauthorized(c, "Token revoked")
c.Abort()
return uuid.Nil, false
}
session, err := am.sessionService.ValidateSession(c.Request.Context(), tokenString)
if err != nil {
// Check if context was cancelled/timed out
if ctxErr := c.Request.Context().Err(); ctxErr != nil {
am.logger.Warn("Context cancelled during session validation",
zap.Error(ctxErr),
zap.String("user_id", userID.String()),
)
c.Abort()
return uuid.Nil, false
}
am.logger.Warn("Invalid session",
zap.Error(err),
zap.String("user_id", userID.String()),
zap.String("ip", c.ClientIP()),
)
response.Unauthorized(c, "Session expired or invalid")
c.Abort()
return uuid.Nil, false
}
if session.UserID != userID {
am.logger.Warn("Session user mismatch",
zap.String("session_user_id", session.UserID.String()),
zap.String("token_user_id", userID.String()),
)
response.Forbidden(c, "Session user mismatch")
c.Abort()
return uuid.Nil, false
}
c.Set("user_id", userID)
c.Set("session_id", session.ID)
c.Set("session_created_at", session.CreatedAt)
c.Set("session_expires_at", session.ExpiresAt)
// BE-SEC-008: Automatic session refresh if session is close to expiration
// Refresh session if it expires within the next 25% of its lifetime
sessionLifetime := session.ExpiresAt.Sub(session.CreatedAt)
timeUntilExpiry := time.Until(session.ExpiresAt)
refreshThreshold := sessionLifetime / 4 // Refresh when 25% of lifetime remains
if timeUntilExpiry < refreshThreshold && timeUntilExpiry > 0 {
// Calculate new expiration (extend by original lifetime)
newExpiresIn := sessionLifetime
if newExpiresIn == 0 {
newExpiresIn = 7 * 24 * time.Hour // SECURITY(SFIX-002): Default 7 days per ORIGIN Rule 4
}
// Refresh the session asynchronously (non-blocking)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := am.sessionService.RefreshSession(ctx, tokenString, newExpiresIn); err != nil {
am.logger.Warn("Failed to auto-refresh session",
zap.Error(err),
zap.String("user_id", userID.String()),
zap.String("session_id", session.ID.String()),
)
} else {
am.logger.Debug("Session auto-refreshed",
zap.String("user_id", userID.String()),
zap.String("session_id", session.ID.String()),
zap.Duration("new_expires_in", newExpiresIn),
)
}
}()
}
// Log audit access
err = am.auditService.LogAction(c.Request.Context(), &services.AuditLogCreateRequest{
UserID: &userID,
Action: "api_access",
Resource: "endpoint",
IPAddress: c.ClientIP(),
UserAgent: c.GetHeader("User-Agent"),
Metadata: map[string]interface{}{
"endpoint": c.Request.URL.Path,
"method": c.Request.Method,
"session_id": session.ID.String(),
},
})
if err != nil {
am.logger.Error("Failed to log API access",
zap.Error(err),
zap.String("user_id", userID.String()),
)
}
return userID, true
}
// RequireAuth middleware qui exige une authentification
func (am *AuthMiddleware) RequireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
userID, ok := am.authenticate(c)
if !ok {
return
}
// v0.301 Lot P1: Update presence (last_seen_at) on each authenticated request
if am.presenceService != nil {
go func() {
_ = am.presenceService.UpdatePresence(context.Background(), userID, "online")
}()
}
// BE-SVC-002: Per-user rate limiting runs after auth so user_id is set.
// Limiter writes 429 + X-RateLimit-* headers and aborts the chain if
// the user exceeds their window; c.Next() below only fires when
// the limiter lets the request through.
if am.userRateLimiter != nil {
am.userRateLimiter.Middleware()(c)
if c.IsAborted() {
return
}
}
c.Next()
}
}
// OptionalAuth middleware d'authentification optionnelle
// MIGRATION UUID: Simplifié, utilise UUID directement
func (am *AuthMiddleware) OptionalAuth() gin.HandlerFunc {
return func(c *gin.Context) {
// SECURITY: Try cookie first (new method), fallback to Authorization header (backward compatibility)
tokenString := ""
if cookie, err := c.Cookie("access_token"); err == nil && cookie != "" {
tokenString = cookie
} else {
// Fallback to Authorization header (backward compatibility)
authHeader := c.GetHeader("Authorization")
if authHeader != "" {
tokenParts := strings.Split(authHeader, " ")
if len(tokenParts) == 2 && tokenParts[0] == "Bearer" {
tokenString = tokenParts[1]
}
}
}
if tokenString == "" {
c.Next()
return
}
claims, err := am.jwtService.ValidateToken(tokenString)
if err != nil {
c.Next()
return
}
userID := claims.UserID
// T0204: Check TokenVersion (optional auth should also respect revocation)
user, err := am.userService.GetByID(c.Request.Context(), userID)
if err != nil {
c.Next()
return
}
if err := am.jwtService.VerifyTokenVersion(claims, user.TokenVersion); err != nil {
c.Next()
return
}
session, err := am.sessionService.ValidateSession(c.Request.Context(), tokenString)
if err != nil {
// Check if context was cancelled/timed out
if ctxErr := c.Request.Context().Err(); ctxErr != nil {
c.Abort()
return
}
c.Next()
return
}
// Ajouter UUID directement au contexte
c.Set("user_id", userID)
c.Set("session_id", session.ID)
c.Set("session_created_at", session.CreatedAt)
c.Set("session_expires_at", session.ExpiresAt)
// BE-SEC-008: Automatic session refresh if session is close to expiration
// Refresh session if it expires within the next 25% of its lifetime
sessionLifetime := session.ExpiresAt.Sub(session.CreatedAt)
timeUntilExpiry := time.Until(session.ExpiresAt)
refreshThreshold := sessionLifetime / 4 // Refresh when 25% of lifetime remains
if timeUntilExpiry < refreshThreshold && timeUntilExpiry > 0 {
// Calculate new expiration (extend by original lifetime)
newExpiresIn := sessionLifetime
if newExpiresIn == 0 {
newExpiresIn = 7 * 24 * time.Hour // SECURITY(SFIX-002): Default 7 days per ORIGIN Rule 4
}
// Refresh the session asynchronously (non-blocking)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := am.sessionService.RefreshSession(ctx, tokenString, newExpiresIn); err != nil {
am.logger.Warn("Failed to auto-refresh session (optional auth)",
zap.Error(err),
zap.String("user_id", userID.String()),
zap.String("session_id", session.ID.String()),
)
} else {
am.logger.Debug("Session auto-refreshed (optional auth)",
zap.String("user_id", userID.String()),
zap.String("session_id", session.ID.String()),
zap.Duration("new_expires_in", newExpiresIn),
)
}
}()
}
c.Next()
}
}
// RequireAdmin middleware qui exige des droits administrateur
// GO-001, GO-005, GO-006: Implémentation RBAC réelle avec PermissionService
// MIGRATION UUID: userID est toujours uuid.UUID, plus de conversion
// Note: RequireAdmin() inclut la vérification d'authentification, pas besoin d'appeler RequireAuth() séparément
func (am *AuthMiddleware) RequireAdmin() gin.HandlerFunc {
return func(c *gin.Context) {
userID, ok := am.authenticate(c)
if !ok {
return
}
// Vérification RBAC réelle
hasRole, err := am.permissionService.HasRole(c.Request.Context(), userID, "admin")
if err != nil {
am.logger.Error("Failed to check admin role", zap.Error(err))
response.InternalServerError(c, "Internal server error")
c.Abort()
return
}
if !hasRole {
am.logger.Warn("Admin access denied",
zap.String("user_id", userID.String()),
zap.String("ip", c.ClientIP()),
)
response.Forbidden(c, "Insufficient permissions")
c.Abort()
return
}
am.logger.Info("Admin access granted",
zap.String("user_id", userID.String()),
zap.String("ip", c.ClientIP()),
zap.String("endpoint", c.Request.URL.Path),
)
c.Next()
}
}
// RequireMFA middleware that enforces MFA for privileged roles (admin, moderator).
// SECURITY(SFIX-001): ORIGIN_SECURITY_FRAMEWORK.md Rule 5 — MFA OBLIGATOIRE pour admin et moderator.
// Must be applied AFTER RequireAuth()/RequireAdmin(). If the user has a privileged role
// and has not enabled 2FA, returns 403 with error code "mfa_setup_required".
func (am *AuthMiddleware) RequireMFA() gin.HandlerFunc {
return func(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
response.Unauthorized(c, "Authentication required")
c.Abort()
return
}
uid, ok := userID.(uuid.UUID)
if !ok {
response.Unauthorized(c, "Invalid user context")
c.Abort()
return
}
// Look up the user's role
user, err := am.userService.GetByID(c.Request.Context(), uid)
if err != nil {
am.logger.Warn("RequireMFA: user not found", zap.String("user_id", uid.String()), zap.Error(err))
response.Unauthorized(c, "User not found")
c.Abort()
return
}
// Only enforce MFA for privileged roles
role := strings.ToLower(user.Role)
if role != "admin" && role != "moderator" {
c.Next()
return
}
// Check 2FA status
if am.twoFactorChecker == nil {
am.logger.Warn("RequireMFA: TwoFactorChecker not configured, blocking privileged access",
zap.String("user_id", uid.String()),
zap.String("role", role),
)
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": gin.H{
"code": "mfa_setup_required",
"message": "Two-factor authentication must be enabled for " + role + " accounts",
},
})
return
}
enabled, err := am.twoFactorChecker.GetTwoFactorStatus(c.Request.Context(), uid)
if err != nil {
am.logger.Error("RequireMFA: failed to check 2FA status",
zap.String("user_id", uid.String()),
zap.Error(err),
)
response.InternalServerError(c, "Failed to verify MFA status")
c.Abort()
return
}
if !enabled {
am.logger.Warn("RequireMFA: privileged user without MFA denied access",
zap.String("user_id", uid.String()),
zap.String("role", role),
zap.String("endpoint", c.Request.URL.Path),
)
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": gin.H{
"code": "mfa_setup_required",
"message": "Two-factor authentication must be enabled for " + role + " accounts. Please set up 2FA via /auth/2fa/setup.",
},
})
return
}
c.Next()
}
}
// RequirePermission middleware qui exige une permission spécifique
// GO-001, GO-005: Implémentation RBAC réelle avec PermissionService
// MIGRATION UUID: userID est toujours uuid.UUID
func (am *AuthMiddleware) RequirePermission(permission string) gin.HandlerFunc {
return func(c *gin.Context) {
userID, ok := am.authenticate(c)
if !ok {
return
}
// Vérification RBAC réelle
hasPermission, err := am.permissionService.HasPermission(c.Request.Context(), userID, permission)
if err != nil {
am.logger.Error("Failed to check permission", zap.Error(err))
response.InternalServerError(c, "Internal server error")
c.Abort()
return
}
if !hasPermission {
am.logger.Warn("Permission denied",
zap.String("user_id", userID.String()),
zap.String("permission", permission),
)
response.Forbidden(c, "Insufficient permissions")
c.Abort()
return
}
am.logger.Info("Permission check passed",
zap.String("user_id", userID.String()),
zap.String("permission", permission),
zap.String("ip", c.ClientIP()),
zap.String("endpoint", c.Request.URL.Path),
)
c.Next()
}
}
// RequireContentCreatorRole middleware qui exige un rôle de créateur de contenu
// GO-012: Vérifie que l'utilisateur a un des rôles: creator, premium, admin
// Selon ORIGIN_SECURITY_FRAMEWORK, seuls ces rôles peuvent créer du contenu
// MVP: En développement, autoriser tous les utilisateurs authentifiés
func (am *AuthMiddleware) RequireContentCreatorRole() gin.HandlerFunc {
return func(c *gin.Context) {
userID, ok := am.authenticate(c)
if !ok {
return
}
// Role bypass only when explicitly opted-in for dev/test.
env := os.Getenv("ENVIRONMENT")
if env == "" {
env = os.Getenv("APP_ENV")
}
if os.Getenv("BYPASS_CONTENT_CREATOR_ROLE") == "true" && (env == "development" || env == "dev" || env == "test") {
am.logger.Debug("Bypassing role check (BYPASS_CONTENT_CREATOR_ROLE=true)",
zap.String("user_id", userID.String()),
zap.String("environment", env),
)
c.Next()
return
}
// Vérifier si l'utilisateur a un des rôles autorisés: creator, premium, admin
allowedRoles := []string{"creator", "premium", "admin", "artist", "producer", "label"}
hasAllowedRole := false
var lastErr error
for _, role := range allowedRoles {
hasRole, err := am.permissionService.HasRole(c.Request.Context(), userID, role)
if err != nil {
lastErr = err
continue
}
if hasRole {
hasAllowedRole = true
break
}
}
if !hasAllowedRole {
am.logger.Warn("Content creation denied - insufficient role",
zap.String("user_id", userID.String()),
zap.String("ip", c.ClientIP()),
zap.String("endpoint", c.Request.URL.Path),
)
response.Forbidden(c, "Insufficient permissions. Content creation requires creator, premium, or admin role.")
c.Abort()
return
}
if lastErr != nil {
am.logger.Error("Error checking roles (but user has allowed role)", zap.Error(lastErr))
}
am.logger.Info("Content creation access granted",
zap.String("user_id", userID.String()),
zap.String("ip", c.ClientIP()),
zap.String("endpoint", c.Request.URL.Path),
)
c.Next()
}
}
// ResourceOwnerResolver est une fonction qui récupère l'ID du propriétaire d'une ressource
// depuis le contexte de la requête (paramètres de route, etc.)
// Retourne l'UUID du propriétaire et une erreur si la ressource n'existe pas
type ResourceOwnerResolver func(c *gin.Context) (uuid.UUID, error)
// RequireOwnershipOrAdmin middleware qui vérifie que l'utilisateur authentifié est le propriétaire
// de la ressource ou qu'il a le rôle admin
// MOD-P0-003: Middleware générique pour vérification ownership centralisée
func (am *AuthMiddleware) RequireOwnershipOrAdmin(resourceType string, resolver ResourceOwnerResolver) gin.HandlerFunc {
return func(c *gin.Context) {
// Authentifier l'utilisateur
userID, ok := am.authenticate(c)
if !ok {
return
}
// Récupérer l'ID du propriétaire de la ressource via le resolver
resourceOwnerID, err := resolver(c)
if err != nil {
am.logger.Warn("Failed to resolve resource owner",
zap.Error(err),
zap.String("resource_type", resourceType),
zap.String("user_id", userID.String()),
)
response.NotFound(c, "Resource not found")
c.Abort()
return
}
// Si l'utilisateur est le propriétaire, autoriser
if userID == resourceOwnerID {
c.Next()
return
}
// Vérifier si l'utilisateur est admin
hasRole, err := am.permissionService.HasRole(c.Request.Context(), userID, "admin")
if err != nil {
am.logger.Error("Failed to check admin role for ownership",
zap.Error(err),
zap.String("resource_type", resourceType),
zap.String("user_id", userID.String()),
)
response.InternalServerError(c, "Internal server error")
c.Abort()
return
}
if hasRole {
am.logger.Info("Admin override for ownership check",
zap.String("resource_type", resourceType),
zap.String("user_id", userID.String()),
zap.String("resource_owner_id", resourceOwnerID.String()),
)
c.Next()
return
}
// L'utilisateur n'est ni le propriétaire ni admin → Forbidden
am.logger.Warn("Ownership check failed",
zap.String("resource_type", resourceType),
zap.String("user_id", userID.String()),
zap.String("resource_owner_id", resourceOwnerID.String()),
zap.String("ip", c.ClientIP()),
)
response.Forbidden(c, "You do not have permission to access this resource")
c.Abort()
}
}
// RefreshToken middleware pour rafraîchir les tokens
// MIGRATION UUID: Simplifié pour UUID
func (am *AuthMiddleware) RefreshToken() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
response.Unauthorized(c, "Authorization header required")
c.Abort()
return
}
tokenParts := strings.Split(authHeader, " ")
if len(tokenParts) != 2 || tokenParts[0] != "Bearer" {
response.Unauthorized(c, "Invalid Authorization header format")
c.Abort()
return
}
tokenString := tokenParts[1]
claims, err := am.jwtService.ValidateToken(tokenString)
if err != nil {
response.Unauthorized(c, "Invalid token")
c.Abort()
return
}
userID := claims.UserID
// T0204: Check TokenVersion
user, err := am.userService.GetByID(c.Request.Context(), userID)
if err != nil {
response.Unauthorized(c, "User not found")
c.Abort()
return
}
if err := am.jwtService.VerifyTokenVersion(claims, user.TokenVersion); err != nil {
response.Unauthorized(c, "Token revoked")
c.Abort()
return
}
session, err := am.sessionService.ValidateSession(c.Request.Context(), tokenString)
if err != nil {
// Check if context was cancelled/timed out
if ctxErr := c.Request.Context().Err(); ctxErr != nil {
c.Abort()
return
}
response.Unauthorized(c, "Session expired or invalid")
c.Abort()
return
}
newExpiresIn := 24 * time.Hour
err = am.sessionService.RefreshSession(c.Request.Context(), tokenString, newExpiresIn)
if err != nil {
am.logger.Error("Failed to refresh session",
zap.Error(err),
zap.String("user_id", userID.String()),
)
response.InternalServerError(c, "Failed to refresh session")
c.Abort()
return
}
// Log le rafraîchissement
am.logger.Info("Token refreshed",
zap.String("user_id", userID.String()),
zap.String("session_id", session.ID.String()),
)
c.JSON(http.StatusOK, gin.H{
"message": "Token refreshed successfully",
"expires_in": newExpiresIn.Seconds(),
})
}
}