veza/veza-backend-api/internal/services/jwt_service.go
senke de5b3bc542 feat(auth): add ephemeral stream-token endpoint for HLS and WebSocket authentication
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.
2026-02-22 17:28:00 +01:00

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"
}
}