Security fixes implemented:
CRITICAL:
- CRIT-001: IDOR on chat rooms — added IsRoomMember check before
returning room data or message history (returns 404, not 403)
- CRIT-002: play_count/like_count exposed publicly — changed JSON
tags to "-" so they are never serialized in API responses
HIGH:
- HIGH-001: TOCTOU race on marketplace downloads — transaction +
SELECT FOR UPDATE on GetDownloadURL
- HIGH-002: HS256 in production docker-compose — replaced JWT_SECRET
with JWT_PRIVATE_KEY_PATH / JWT_PUBLIC_KEY_PATH (RS256)
- HIGH-003: context.Background() bypass in user repository — full
context propagation from handlers → services → repository (29 files)
- HIGH-004: Race condition on promo codes — SELECT FOR UPDATE
- HIGH-005: Race condition on exclusive licenses — SELECT FOR UPDATE
- HIGH-006: Rate limiter IP spoofing — SetTrustedProxies(nil) default
- HIGH-007: RGPD hard delete incomplete — added cleanup for sessions,
settings, follows, notifications, audit_logs anonymization
- HIGH-008: RTMP callback auth weak — fail-closed when unconfigured,
header-only (no query param), constant-time compare
- HIGH-009: Co-listening host hijack — UpdateHostState now takes *Conn
and verifies IsHost before processing
- HIGH-010: Moderator self-strike — added issuedBy != userID check
MEDIUM:
- MEDIUM-001: Recovery codes used math/rand — replaced with crypto/rand
- MEDIUM-005: Stream token forgeable — resolved by HIGH-002 (RS256)
Updated REMEDIATION_MATRIX: 14 findings marked ✅ CORRIGÉ.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
730 lines
22 KiB
Go
730 lines
22 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)
|
|
}
|
|
|
|
// 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
|
|
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
|
|
}
|
|
|
|
// 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 = 30 * 24 * time.Hour // Default to 30 days
|
|
}
|
|
|
|
// 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) {
|
|
if userID, ok := am.authenticate(c); ok {
|
|
// 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")
|
|
}()
|
|
}
|
|
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 = 30 * 24 * time.Hour // Default to 30 days
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
}
|
|
|
|
// 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(),
|
|
})
|
|
}
|
|
}
|