SEC-03: TokenStorage.getAccessToken() returns null with httpOnly cookies. New POST /api/v1/auth/stream-token returns a 5-min JWT compatible with both stream server (Claims struct) and chat server (JwtClaims struct). Frontend hlsService and websocket updated to use fetchStreamToken() fallback.
219 lines
6.6 KiB
Go
219 lines
6.6 KiB
Go
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"
|
|
}
|
|
}
|