package services import ( "fmt" "time" "veza-backend-api/internal/models" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" ) type JWTService struct { secretKey []byte issuer string audience string Config *models.JWTConfig } func NewJWTService(secret, issuer, audience string) (*JWTService, error) { if secret == "" { return nil, fmt.Errorf("JWT secret is required") } // SEC-005: Enforce minimum secret length to prevent brute-force if len(secret) < 32 { return nil, fmt.Errorf("JWT secret must be at least 32 characters") } if issuer == "" { issuer = "veza-api" } if audience == "" { audience = "veza-app" } // Default config - SEC-006: Reduced TTLs for improved security config := &models.JWTConfig{ AccessTokenTTL: 5 * time.Minute, RefreshTokenTTL: 14 * 24 * time.Hour, // 14 days (was 30) RememberMeRefreshTokenTTL: 30 * 24 * time.Hour, // 30 days (was 90) } return &JWTService{ secretKey: []byte(secret), issuer: issuer, audience: audience, Config: config, }, nil } func (s *JWTService) GetConfig() *models.JWTConfig { return s.Config } func (s *JWTService) GenerateAccessToken(user *models.User) (string, error) { claims := models.CustomClaims{ UserID: user.ID, Email: user.Email, Username: user.Username, Role: user.Role, TokenVersion: user.TokenVersion, TokenType: "access", RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(s.Config.AccessTokenTTL)), IssuedAt: jwt.NewNumericDate(time.Now()), Issuer: s.issuer, Audience: jwt.ClaimStrings{s.audience}, ID: uuid.NewString(), }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString(s.secretKey) } func (s *JWTService) GenerateRefreshToken(user *models.User) (string, error) { claims := models.CustomClaims{ UserID: user.ID, TokenVersion: user.TokenVersion, IsRefresh: true, // Mark as refresh token TokenType: "refresh", TokenFamily: uuid.NewString(), // Nouvelle famille de token RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(s.Config.RefreshTokenTTL)), IssuedAt: jwt.NewNumericDate(time.Now()), Issuer: s.issuer, Audience: jwt.ClaimStrings{s.audience}, ID: uuid.NewString(), }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString(s.secretKey) } // GenerateTokenPair génère une paire de tokens (access + refresh) en une seule opération func (s *JWTService) GenerateTokenPair(user *models.User) (*models.TokenPair, error) { // Generate access token accessToken, err := s.GenerateAccessToken(user) if err != nil { return nil, fmt.Errorf("failed to generate access token: %w", err) } // Generate refresh token refreshToken, err := s.GenerateRefreshToken(user) if err != nil { return nil, fmt.Errorf("failed to generate refresh token: %w", err) } return &models.TokenPair{ AccessToken: accessToken, RefreshToken: refreshToken, ExpiresIn: int(s.Config.AccessTokenTTL.Seconds()), }, nil } // VerifyToken valide et parse un token JWT func (s *JWTService) VerifyToken(tokenString string) (*models.CustomClaims, error) { return s.ValidateToken(tokenString) } // ValidateToken valide un token JWT et retourne les claims func (s *JWTService) ValidateToken(tokenString string) (*models.CustomClaims, error) { // Parse avec validation des claims standards (exp, iat, nbf) ET custom (iss, aud) token, err := jwt.ParseWithClaims(tokenString, &models.CustomClaims{}, func(token *jwt.Token) (interface{}, error) { // Validation stricte de l'algorithme (MOD-P2-002) if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } if token.Method.Alg() != "HS256" { return nil, fmt.Errorf("invalid signing algorithm: %v, expected HS256", token.Method.Alg()) } return s.secretKey, nil }, // Options de validation stricte jwt.WithIssuer(s.issuer), jwt.WithAudience(s.audience), jwt.WithExpirationRequired(), ) if err != nil { return nil, fmt.Errorf("failed to parse token: %w", err) } if claims, ok := token.Claims.(*models.CustomClaims); ok && token.Valid { return claims, nil } return nil, fmt.Errorf("invalid token") } // ParseToken parse un token JWT sans validation complète (utilise ValidateToken) func (s *JWTService) ParseToken(tokenString string) (*models.CustomClaims, error) { return s.ValidateToken(tokenString) } // ExtractClaims extrait les claims d'un token JWT func (s *JWTService) ExtractClaims(tokenString string) (*models.CustomClaims, error) { return s.ValidateToken(tokenString) } // ExtractUserID extrait l'ID utilisateur depuis un token JWT // MIGRATION UUID: retourne uuid.UUID au lieu de int64 func (s *JWTService) ExtractUserID(tokenString string) (uuid.UUID, error) { claims, err := s.ValidateToken(tokenString) if err != nil { return uuid.Nil, fmt.Errorf("failed to extract user ID: %w", err) } return claims.UserID, nil } // VerifyTokenVersion vérifie si la version du token correspond à celle de l'utilisateur func (s *JWTService) VerifyTokenVersion(claims *models.CustomClaims, userTokenVersion int) error { if claims.TokenVersion != userTokenVersion { return fmt.Errorf("token version mismatch: token version %d does not match user version %d", claims.TokenVersion, userTokenVersion) } return nil } // GenerateStreamToken generates a short-lived JWT for HLS/WebSocket auth. // Uses issuer "veza-platform" and audience "veza-services" for compatibility with stream server. // TTL: 5 minutes. Claims match stream server Claims struct (sub, username, roles, etc.). func (s *JWTService) GenerateStreamToken(user *models.User) (string, error) { ttl := 5 * time.Minute now := time.Now() role := mapUserRoleToStreamRole(user.Role) claims := jwt.MapClaims{ "sub": user.ID.String(), "user_id": user.ID.String(), // Chat server expects user_id "username": user.Username, "email": user.Email, "roles": []string{role}, "permissions": []string{"StreamAudio"}, "exp": now.Add(ttl).Unix(), "iat": now.Unix(), "iss": "veza-platform", "aud": "veza-services", "session_id": uuid.NewString(), } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString(s.secretKey) } func mapUserRoleToStreamRole(role string) string { switch role { case "admin": return "Admin" case "moderator": return "Moderator" case "premium": return "Premium" case "artist": return "Artist" case "guest": return "Guest" default: return "User" } }