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.
877 lines
32 KiB
Go
877 lines
32 KiB
Go
package handlers
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"veza-backend-api/internal/config"
|
|
"veza-backend-api/internal/core/auth"
|
|
"veza-backend-api/internal/dto"
|
|
apperrors "veza-backend-api/internal/errors"
|
|
|
|
// "veza-backend-api/internal/response" // Removed this import
|
|
"veza-backend-api/internal/services"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// UnlockAccountRequest is the JSON body for admin unlock account.
|
|
type UnlockAccountRequest struct {
|
|
Email string `json:"email" binding:"required,email"`
|
|
}
|
|
|
|
// Login gère la connexion des utilisateurs
|
|
// @Summary User Login
|
|
// @Description Authenticate user and return access token. Refresh token is set in httpOnly cookie.
|
|
// @Tags Auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body dto.LoginRequest true "Login Credentials"
|
|
// @Success 200 {object} dto.LoginResponse "Access token returned in body, refresh token in httpOnly cookie"
|
|
// @Failure 400 {object} handlers.APIResponse "Validation or Bad Request"
|
|
// @Failure 401 {object} handlers.APIResponse "Invalid credentials"
|
|
// @Failure 500 {object} handlers.APIResponse "Internal Error"
|
|
// @Router /auth/login [post]
|
|
func Login(authService *auth.AuthService, sessionService *services.SessionService, twoFactorService *services.TwoFactorService, logger *zap.Logger, cfg *config.Config) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
commonHandler := NewCommonHandler(logger)
|
|
var req dto.LoginRequest
|
|
if appErr := commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
|
|
RespondWithAppError(c, appErr)
|
|
return
|
|
}
|
|
|
|
// req.RememberMe is a bool, not *bool, so no need to check for nil or indirect
|
|
rememberMe := req.RememberMe
|
|
|
|
if logger != nil {
|
|
logger.Info("Login handler processing request",
|
|
zap.String("email", req.Email),
|
|
zap.Bool("remember_me", rememberMe),
|
|
)
|
|
}
|
|
|
|
// MOD-P1-004: Ajouter timeout context pour opération DB critique (login)
|
|
ctx, cancel := WithTimeout(c.Request.Context(), 5*time.Second)
|
|
defer cancel()
|
|
user, tokens, err := authService.Login(ctx, req.Email, req.Password, rememberMe)
|
|
if err != nil {
|
|
// MOD-P1-002: Improved error handling
|
|
errMsg := err.Error()
|
|
|
|
if strings.Contains(strings.ToLower(errMsg), "email not verified") {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeForbidden, "Email not verified"))
|
|
return
|
|
}
|
|
|
|
if strings.Contains(strings.ToLower(errMsg), "account is locked") {
|
|
// Return 423 Locked or 403 Forbidden
|
|
// Using c.JSON to specific 423 for clarity
|
|
c.JSON(http.StatusLocked, gin.H{
|
|
"success": false,
|
|
"error": gin.H{
|
|
"code": 423,
|
|
"message": "Account is locked. Please try again later.",
|
|
},
|
|
})
|
|
return
|
|
}
|
|
|
|
// Check for invalid credentials (case insensitive)
|
|
if strings.Contains(strings.ToLower(errMsg), "invalid credentials") ||
|
|
strings.Contains(strings.ToLower(errMsg), "user not found") ||
|
|
strings.Contains(strings.ToLower(errMsg), "record not found") {
|
|
// Try direct JSON response to rule out helper issues
|
|
c.JSON(http.StatusUnauthorized, gin.H{
|
|
"success": false,
|
|
"error": gin.H{
|
|
"code": 401,
|
|
"message": "Invalid credentials",
|
|
},
|
|
})
|
|
return
|
|
}
|
|
|
|
// Fallback: log and return 500
|
|
if logger != nil {
|
|
logger.Error("Login error fell through to 500", zap.Error(err))
|
|
}
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to authenticate", err))
|
|
return
|
|
}
|
|
|
|
// BE-API-001: Check if 2FA is enabled for user
|
|
var requires2FA bool
|
|
if twoFactorService != nil {
|
|
requires2FA, err = twoFactorService.GetTwoFactorStatus(ctx, user.ID)
|
|
if err != nil {
|
|
logger.Warn("Failed to check 2FA status", zap.Error(err), zap.String("user_id", user.ID.String()))
|
|
// Continue without 2FA check if error
|
|
requires2FA = false
|
|
}
|
|
}
|
|
|
|
// If 2FA is required, return flag without tokens
|
|
if requires2FA {
|
|
RespondSuccess(c, http.StatusOK, dto.LoginResponse{
|
|
User: dto.UserResponse{
|
|
ID: user.ID,
|
|
Email: user.Email,
|
|
Username: user.Username,
|
|
},
|
|
Requires2FA: true,
|
|
})
|
|
return
|
|
}
|
|
|
|
if sessionService != nil {
|
|
ipAddress := c.ClientIP()
|
|
userAgent := c.GetHeader("User-Agent")
|
|
if userAgent == "" {
|
|
userAgent = "Unknown"
|
|
}
|
|
|
|
expiresIn := 30 * 24 * time.Hour
|
|
if rememberMe {
|
|
expiresIn = 90 * 24 * time.Hour
|
|
}
|
|
|
|
sessionReq := &services.SessionCreateRequest{
|
|
UserID: user.ID,
|
|
Token: tokens.AccessToken,
|
|
IPAddress: ipAddress,
|
|
UserAgent: userAgent,
|
|
ExpiresIn: expiresIn,
|
|
}
|
|
|
|
// MOD-P1-004: Ajouter timeout context pour opération DB (session)
|
|
sessionCtx, sessionCancel := WithTimeout(c.Request.Context(), 3*time.Second)
|
|
defer sessionCancel()
|
|
if _, err := sessionService.CreateSession(sessionCtx, sessionReq); err != nil {
|
|
if logger != nil {
|
|
logger.Warn("Failed to create session after login",
|
|
zap.String("user_id", user.ID.String()),
|
|
zap.String("ip_address", ipAddress),
|
|
zap.Error(err),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// SECURITY: Set refresh token in httpOnly cookie (SEC-006: reduced TTLs)
|
|
refreshTokenExpires := 14 * 24 * time.Hour // 14 jours par défaut
|
|
if rememberMe {
|
|
refreshTokenExpires = 30 * 24 * time.Hour // 30 jours si remember me
|
|
}
|
|
|
|
// Utiliser http.Cookie pour supporter SameSite avec configuration depuis env
|
|
refreshTokenCookie := &http.Cookie{
|
|
Name: "refresh_token",
|
|
Value: tokens.RefreshToken,
|
|
Path: cfg.CookiePath,
|
|
Domain: cfg.CookieDomain,
|
|
MaxAge: int(refreshTokenExpires.Seconds()),
|
|
HttpOnly: cfg.CookieHttpOnly,
|
|
Secure: cfg.ShouldUseSecureCookies(),
|
|
SameSite: cfg.GetCookieSameSite(),
|
|
}
|
|
http.SetCookie(c.Writer, refreshTokenCookie)
|
|
|
|
// SECURITY: Set access token in httpOnly cookie
|
|
accessTokenExpires := authService.JWTService.GetConfig().AccessTokenTTL
|
|
accessTokenCookie := &http.Cookie{
|
|
Name: "access_token",
|
|
Value: tokens.AccessToken,
|
|
Path: cfg.CookiePath,
|
|
Domain: cfg.CookieDomain,
|
|
MaxAge: int(accessTokenExpires.Seconds()),
|
|
HttpOnly: cfg.CookieHttpOnly,
|
|
Secure: cfg.ShouldUseSecureCookies(),
|
|
SameSite: cfg.GetCookieSameSite(),
|
|
}
|
|
http.SetCookie(c.Writer, accessTokenCookie)
|
|
|
|
// Retourner uniquement l'access token dans le body (pas le refresh token)
|
|
RespondSuccess(c, http.StatusOK, dto.LoginResponse{
|
|
User: dto.UserResponse{
|
|
ID: user.ID,
|
|
Email: user.Email,
|
|
Username: user.Username,
|
|
},
|
|
Token: dto.TokenResponse{
|
|
AccessToken: tokens.AccessToken,
|
|
// RefreshToken: tokens.RefreshToken, // ❌ Ne plus retourner dans le body
|
|
ExpiresIn: int(authService.JWTService.GetConfig().AccessTokenTTL.Seconds()),
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
// LoginWith2FA completes login with TOTP code (POST /auth/login/2fa).
|
|
// Body: { "email", "password", "code", "remember_me" }. Returns same shape as Login (user + token).
|
|
func LoginWith2FA(authService *auth.AuthService, sessionService *services.SessionService, twoFactorService *services.TwoFactorService, logger *zap.Logger, cfg *config.Config) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
commonHandler := NewCommonHandler(logger)
|
|
var req dto.Login2FARequest
|
|
if appErr := commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
|
|
RespondWithAppError(c, appErr)
|
|
return
|
|
}
|
|
|
|
ctx, cancel := WithTimeout(c.Request.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
user, tokens, err := authService.LoginWith2FA(ctx, req.Email, req.Password, req.Code, req.RememberMe, twoFactorService)
|
|
if err != nil {
|
|
errMsg := err.Error()
|
|
if strings.Contains(strings.ToLower(errMsg), "email not verified") {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeForbidden, "Email not verified"))
|
|
return
|
|
}
|
|
if strings.Contains(strings.ToLower(errMsg), "account is locked") {
|
|
c.JSON(http.StatusLocked, gin.H{
|
|
"success": false,
|
|
"error": gin.H{"code": 423, "message": "Account is locked. Please try again later."},
|
|
})
|
|
return
|
|
}
|
|
if strings.Contains(strings.ToLower(errMsg), "invalid credentials") ||
|
|
strings.Contains(strings.ToLower(errMsg), "user not found") ||
|
|
strings.Contains(strings.ToLower(errMsg), "record not found") {
|
|
c.JSON(http.StatusUnauthorized, gin.H{
|
|
"success": false,
|
|
"error": gin.H{"code": 401, "message": "Invalid credentials"},
|
|
})
|
|
return
|
|
}
|
|
if strings.Contains(strings.ToLower(errMsg), "invalid 2fa code") ||
|
|
strings.Contains(strings.ToLower(errMsg), "2fa not enabled") {
|
|
c.JSON(http.StatusUnauthorized, gin.H{
|
|
"success": false,
|
|
"error": gin.H{"code": 401, "message": errMsg},
|
|
})
|
|
return
|
|
}
|
|
if logger != nil {
|
|
logger.Error("LoginWith2FA error", zap.Error(err))
|
|
}
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to complete 2FA login", err))
|
|
return
|
|
}
|
|
|
|
if sessionService != nil {
|
|
ipAddress := c.ClientIP()
|
|
userAgent := c.GetHeader("User-Agent")
|
|
if userAgent == "" {
|
|
userAgent = "Unknown"
|
|
}
|
|
expiresIn := 30 * 24 * time.Hour
|
|
if req.RememberMe {
|
|
expiresIn = 90 * 24 * time.Hour
|
|
}
|
|
sessionReq := &services.SessionCreateRequest{
|
|
UserID: user.ID, Token: tokens.AccessToken, IPAddress: ipAddress, UserAgent: userAgent, ExpiresIn: expiresIn,
|
|
}
|
|
sessionCtx, sessionCancel := WithTimeout(c.Request.Context(), 3*time.Second)
|
|
defer sessionCancel()
|
|
if _, err := sessionService.CreateSession(sessionCtx, sessionReq); err != nil && logger != nil {
|
|
logger.Warn("Failed to create session after 2FA login", zap.String("user_id", user.ID.String()), zap.Error(err))
|
|
}
|
|
}
|
|
|
|
refreshTokenExpires := 30 * 24 * time.Hour
|
|
if req.RememberMe {
|
|
refreshTokenExpires = 90 * 24 * time.Hour
|
|
}
|
|
refreshTokenCookie := &http.Cookie{
|
|
Name: "refresh_token", Value: tokens.RefreshToken, Path: cfg.CookiePath, Domain: cfg.CookieDomain,
|
|
MaxAge: int(refreshTokenExpires.Seconds()), HttpOnly: cfg.CookieHttpOnly, Secure: cfg.ShouldUseSecureCookies(), SameSite: cfg.GetCookieSameSite(),
|
|
}
|
|
http.SetCookie(c.Writer, refreshTokenCookie)
|
|
|
|
accessTokenExpires := authService.JWTService.GetConfig().AccessTokenTTL
|
|
accessTokenCookie := &http.Cookie{
|
|
Name: "access_token", Value: tokens.AccessToken, Path: cfg.CookiePath, Domain: cfg.CookieDomain,
|
|
MaxAge: int(accessTokenExpires.Seconds()), HttpOnly: cfg.CookieHttpOnly, Secure: cfg.ShouldUseSecureCookies(), SameSite: cfg.GetCookieSameSite(),
|
|
}
|
|
http.SetCookie(c.Writer, accessTokenCookie)
|
|
|
|
RespondSuccess(c, http.StatusOK, dto.LoginResponse{
|
|
User: dto.UserResponse{ID: user.ID, Email: user.Email, Username: user.Username},
|
|
Token: dto.TokenResponse{
|
|
AccessToken: tokens.AccessToken,
|
|
ExpiresIn: int(authService.JWTService.GetConfig().AccessTokenTTL.Seconds()),
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
// Register gère l'inscription des utilisateurs
|
|
// @Summary User Registration
|
|
// @Description Register a new user account
|
|
// @Tags Auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body dto.RegisterRequest true "Registration Data"
|
|
// @Success 201 {object} dto.RegisterResponse
|
|
// @Failure 400 {object} handlers.APIResponse "Validation Error"
|
|
// @Failure 409 {object} handlers.APIResponse "User already exists"
|
|
// @Failure 500 {object} handlers.APIResponse "Internal Error"
|
|
// @Router /auth/register [post]
|
|
func Register(authService *auth.AuthService, sessionService *services.SessionService, logger *zap.Logger, cfg *config.Config) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
// FIX #6: Utiliser logger.Debug() pour les logs de debug au lieu de logger.Info()
|
|
logger.Debug("Register handler called", zap.String("path", c.Request.URL.Path), zap.String("method", c.Request.Method))
|
|
commonHandler := NewCommonHandler(logger)
|
|
var req dto.RegisterRequest
|
|
logger.Debug("Before BindAndValidateJSON")
|
|
if appErr := commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
|
|
logger.Debug("BindAndValidateJSON failed", zap.Error(appErr))
|
|
RespondWithAppError(c, appErr)
|
|
return
|
|
}
|
|
logger.Debug("After BindAndValidateJSON success", zap.String("email", req.Email), zap.String("username", req.Username))
|
|
|
|
logger.Debug("Handler register start", zap.String("email", req.Email), zap.String("username", req.Username))
|
|
// MOD-P1-004: Ajouter timeout context pour opération DB critique (register)
|
|
ctx, cancel := WithTimeout(c.Request.Context(), 5*time.Second)
|
|
defer cancel()
|
|
logger.Debug("Calling auth service register", zap.String("email", req.Email))
|
|
user, tokens, err := authService.Register(ctx, req.Email, req.Username, req.Password)
|
|
logger.Debug("Auth service register returned", zap.Error(err), zap.Bool("user_nil", user == nil), zap.Bool("tokens_nil", tokens == nil))
|
|
if err != nil {
|
|
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
|
|
switch {
|
|
case services.IsUserAlreadyExistsError(err):
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeConflict, "User already exists"))
|
|
case services.IsInvalidEmail(err):
|
|
RespondWithAppError(c, apperrors.NewValidationError("Invalid email format"))
|
|
case services.IsWeakPassword(err):
|
|
RespondWithAppError(c, apperrors.NewValidationError("Password does not meet requirements"))
|
|
default:
|
|
// Log l'erreur complète pour diagnostic
|
|
commonHandler.logger.Error("Registration failed - FULL ERROR",
|
|
zap.Error(err),
|
|
zap.String("error_type", fmt.Sprintf("%T", err)),
|
|
zap.String("error_string", err.Error()),
|
|
zap.String("email", req.Email),
|
|
zap.String("username", req.Username),
|
|
)
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to create user", err))
|
|
}
|
|
return
|
|
}
|
|
|
|
// MVP: Créer une session en base pour permettre l'utilisation immédiate du token
|
|
// (comme dans Login)
|
|
if sessionService != nil {
|
|
logger.Debug("Creating session after registration", zap.String("user_id", user.ID.String()))
|
|
ipAddress := c.ClientIP()
|
|
userAgent := c.GetHeader("User-Agent")
|
|
if userAgent == "" {
|
|
userAgent = "Unknown"
|
|
}
|
|
|
|
// Session par défaut: 30 jours
|
|
expiresIn := 30 * 24 * time.Hour
|
|
|
|
sessionCtx, sessionCancel := WithTimeout(c.Request.Context(), 3*time.Second)
|
|
defer sessionCancel()
|
|
|
|
sessionReq := &services.SessionCreateRequest{
|
|
UserID: user.ID,
|
|
Token: tokens.AccessToken,
|
|
IPAddress: ipAddress,
|
|
UserAgent: userAgent,
|
|
ExpiresIn: expiresIn,
|
|
}
|
|
|
|
if _, err := sessionService.CreateSession(sessionCtx, sessionReq); err != nil {
|
|
logger.Warn("Failed to create session after registration",
|
|
zap.String("user_id", user.ID.String()),
|
|
zap.String("ip_address", ipAddress),
|
|
zap.Error(err),
|
|
)
|
|
// Non-bloquant: on continue même si la session n'est pas créée
|
|
// L'utilisateur pourra se reconnecter pour créer une session
|
|
} else {
|
|
logger.Debug("Session created successfully after registration", zap.String("user_id", user.ID.String()))
|
|
}
|
|
} else {
|
|
logger.Warn("SessionService not available - skipping session creation after registration")
|
|
}
|
|
|
|
// SECURITY: Set refresh token in httpOnly cookie
|
|
refreshTokenExpires := 30 * 24 * time.Hour // 30 jours par défaut
|
|
|
|
// Utiliser http.Cookie pour supporter SameSite avec configuration depuis env
|
|
refreshTokenCookie := &http.Cookie{
|
|
Name: "refresh_token",
|
|
Value: tokens.RefreshToken,
|
|
Path: cfg.CookiePath,
|
|
Domain: cfg.CookieDomain,
|
|
MaxAge: int(refreshTokenExpires.Seconds()),
|
|
HttpOnly: cfg.CookieHttpOnly,
|
|
Secure: cfg.ShouldUseSecureCookies(),
|
|
SameSite: cfg.GetCookieSameSite(),
|
|
}
|
|
http.SetCookie(c.Writer, refreshTokenCookie)
|
|
|
|
// SECURITY: Set access token in httpOnly cookie
|
|
accessTokenExpires := authService.JWTService.GetConfig().AccessTokenTTL
|
|
accessTokenCookie := &http.Cookie{
|
|
Name: "access_token",
|
|
Value: tokens.AccessToken,
|
|
Path: cfg.CookiePath,
|
|
Domain: cfg.CookieDomain,
|
|
MaxAge: int(accessTokenExpires.Seconds()),
|
|
HttpOnly: cfg.CookieHttpOnly,
|
|
Secure: cfg.ShouldUseSecureCookies(),
|
|
SameSite: cfg.GetCookieSameSite(),
|
|
}
|
|
http.SetCookie(c.Writer, accessTokenCookie)
|
|
|
|
// Construire la réponse avec uniquement l'access token (pas le refresh token)
|
|
response := dto.RegisterResponse{
|
|
User: dto.UserResponse{
|
|
ID: user.ID,
|
|
Email: user.Email,
|
|
Username: user.Username,
|
|
},
|
|
Token: dto.TokenResponse{
|
|
AccessToken: tokens.AccessToken,
|
|
// RefreshToken: tokens.RefreshToken, // ❌ Ne plus retourner dans le body
|
|
ExpiresIn: tokens.ExpiresIn,
|
|
},
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusCreated, response)
|
|
}
|
|
}
|
|
|
|
// Refresh gère le rafraîchissement d'un access token
|
|
// @Summary Refresh Token
|
|
// @Description Get a new access token using a refresh token
|
|
// @Tags Auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body dto.RefreshRequest true "Refresh Token"
|
|
// @Success 200 {object} dto.TokenResponse
|
|
// @Failure 400 {object} handlers.APIResponse "Validation Error"
|
|
// @Failure 401 {object} handlers.APIResponse "Invalid/Expired Refresh Token"
|
|
// @Failure 500 {object} handlers.APIResponse "Internal Error"
|
|
// @Router /auth/refresh [post]
|
|
func Refresh(authService *auth.AuthService, sessionService *services.SessionService, logger *zap.Logger, cfg *config.Config) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
commonHandler := NewCommonHandler(logger)
|
|
|
|
// SECURITY: Récupérer le refresh token depuis le cookie httpOnly (priorité)
|
|
// Fallback sur le body JSON pour compatibilité avec l'ancien système
|
|
var refreshToken string
|
|
if cookie, err := c.Cookie("refresh_token"); err == nil && cookie != "" {
|
|
refreshToken = cookie
|
|
} else {
|
|
// Fallback: lire depuis le body JSON (mode legacy)
|
|
var req dto.RefreshRequest
|
|
if appErr := commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
|
|
RespondWithAppError(c, appErr)
|
|
return
|
|
}
|
|
refreshToken = req.RefreshToken
|
|
}
|
|
|
|
if refreshToken == "" {
|
|
RespondWithAppError(c, apperrors.NewUnauthorizedError("Refresh token is required"))
|
|
return
|
|
}
|
|
|
|
tokens, err := authService.Refresh(c.Request.Context(), refreshToken)
|
|
if err != nil {
|
|
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
|
|
if strings.Contains(err.Error(), "invalid refresh token") ||
|
|
strings.Contains(err.Error(), "not found") ||
|
|
strings.Contains(err.Error(), "expired") ||
|
|
strings.Contains(err.Error(), "token version mismatch") {
|
|
RespondWithAppError(c, apperrors.NewUnauthorizedError("Invalid refresh token"))
|
|
return
|
|
}
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to refresh token", err))
|
|
return
|
|
}
|
|
|
|
// INT-017: Créer une nouvelle session lors du refresh token
|
|
if sessionService != nil {
|
|
// Récupérer l'ID utilisateur depuis le refresh token
|
|
claims, err := authService.JWTService.ValidateToken(refreshToken)
|
|
if err == nil && claims != nil {
|
|
ipAddress := c.ClientIP()
|
|
userAgent := c.GetHeader("User-Agent")
|
|
if userAgent == "" {
|
|
userAgent = "Unknown"
|
|
}
|
|
|
|
// Utiliser la même durée d'expiration que le token d'accès
|
|
expiresIn := 30 * 24 * time.Hour
|
|
if authService.JWTService != nil {
|
|
expiresIn = authService.JWTService.GetConfig().AccessTokenTTL
|
|
}
|
|
|
|
sessionReq := &services.SessionCreateRequest{
|
|
UserID: claims.UserID,
|
|
Token: tokens.AccessToken,
|
|
IPAddress: ipAddress,
|
|
UserAgent: userAgent,
|
|
ExpiresIn: expiresIn,
|
|
}
|
|
|
|
// MOD-P1-004: Ajouter timeout context pour opération DB (session)
|
|
sessionCtx, sessionCancel := WithTimeout(c.Request.Context(), 3*time.Second)
|
|
defer sessionCancel()
|
|
if _, err := sessionService.CreateSession(sessionCtx, sessionReq); err != nil {
|
|
if logger != nil {
|
|
logger.Warn("Failed to create session after refresh",
|
|
zap.String("user_id", claims.UserID.String()),
|
|
zap.String("ip_address", ipAddress),
|
|
zap.Error(err),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// SECURITY: Set refresh token in httpOnly cookie
|
|
// Utiliser la même durée que le refresh token original (30 jours par défaut)
|
|
refreshTokenExpires := 30 * 24 * time.Hour
|
|
|
|
// Utiliser http.Cookie pour supporter SameSite avec configuration depuis env
|
|
refreshTokenCookie := &http.Cookie{
|
|
Name: "refresh_token",
|
|
Value: tokens.RefreshToken,
|
|
Path: cfg.CookiePath,
|
|
Domain: cfg.CookieDomain,
|
|
MaxAge: int(refreshTokenExpires.Seconds()),
|
|
HttpOnly: cfg.CookieHttpOnly,
|
|
Secure: cfg.ShouldUseSecureCookies(),
|
|
SameSite: cfg.GetCookieSameSite(),
|
|
}
|
|
http.SetCookie(c.Writer, refreshTokenCookie)
|
|
|
|
// SECURITY: Set access token in httpOnly cookie
|
|
accessTokenExpires := authService.JWTService.GetConfig().AccessTokenTTL
|
|
accessTokenCookie := &http.Cookie{
|
|
Name: "access_token",
|
|
Value: tokens.AccessToken,
|
|
Path: cfg.CookiePath,
|
|
Domain: cfg.CookieDomain,
|
|
MaxAge: int(accessTokenExpires.Seconds()),
|
|
HttpOnly: cfg.CookieHttpOnly,
|
|
Secure: cfg.ShouldUseSecureCookies(),
|
|
SameSite: cfg.GetCookieSameSite(),
|
|
}
|
|
http.SetCookie(c.Writer, accessTokenCookie)
|
|
|
|
// Calculate ExpiresIn from tokens if available, otherwise use JWTService config
|
|
expiresIn := tokens.ExpiresIn
|
|
if expiresIn == 0 && authService.JWTService != nil {
|
|
expiresIn = int(authService.JWTService.GetConfig().AccessTokenTTL.Seconds())
|
|
}
|
|
|
|
// Retourner uniquement l'access token dans le body (pas le refresh token)
|
|
RespondSuccess(c, http.StatusOK, dto.TokenResponse{
|
|
AccessToken: tokens.AccessToken,
|
|
// RefreshToken: tokens.RefreshToken, // ❌ Ne plus retourner dans le body
|
|
ExpiresIn: expiresIn,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Logout gère la déconnexion des utilisateurs
|
|
// @Summary Logout
|
|
// @Description Revoke refresh token and current session
|
|
// @Tags Auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security BearerAuth
|
|
// @Param request body object{refresh_token=string} true "Refresh Token to revoke"
|
|
// @Success 200 {object} handlers.APIResponse "Success message"
|
|
// @Failure 400 {object} handlers.APIResponse "Validation Error"
|
|
// @Failure 401 {object} handlers.APIResponse "Unauthorized"
|
|
// @Router /auth/logout [post]
|
|
func Logout(authService *auth.AuthService, sessionService *services.SessionService, logger *zap.Logger, cfg *config.Config) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
userIDInterface, exists := c.Get("user_id")
|
|
if !exists {
|
|
RespondWithAppError(c, apperrors.NewUnauthorizedError("User not authenticated"))
|
|
return
|
|
}
|
|
|
|
userID, ok := userIDInterface.(uuid.UUID)
|
|
if !ok {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "Invalid user ID type in context"))
|
|
return
|
|
}
|
|
|
|
// Read refresh_token from httpOnly cookie (frontend cannot access it via JS)
|
|
refreshToken, _ := c.Cookie("refresh_token")
|
|
|
|
if err := authService.Logout(c.Request.Context(), userID, refreshToken); err != nil {
|
|
// Log the error but don't fail the request to prevent leaking info
|
|
}
|
|
|
|
if sessionService != nil {
|
|
authHeader := c.GetHeader("Authorization")
|
|
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
|
|
token := strings.TrimPrefix(authHeader, "Bearer ")
|
|
if err := sessionService.RevokeSession(c.Request.Context(), token); err != nil {
|
|
// Log the error but don't fail the request
|
|
}
|
|
}
|
|
}
|
|
|
|
// SECURITY: Supprimer le cookie refresh_token lors du logout
|
|
refreshTokenCookie := &http.Cookie{
|
|
Name: "refresh_token",
|
|
Value: "",
|
|
Path: cfg.CookiePath,
|
|
Domain: cfg.CookieDomain,
|
|
MaxAge: -1, // Supprimer le cookie
|
|
HttpOnly: cfg.CookieHttpOnly,
|
|
Secure: cfg.ShouldUseSecureCookies(),
|
|
SameSite: cfg.GetCookieSameSite(),
|
|
}
|
|
http.SetCookie(c.Writer, refreshTokenCookie)
|
|
|
|
RespondSuccess(c, http.StatusOK, gin.H{"message": "Logged out successfully"})
|
|
}
|
|
}
|
|
|
|
// VerifyEmail gère la vérification de l'email
|
|
// @Summary Verify Email
|
|
// @Description Verify user email address using a token
|
|
// @Tags Auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param token query string true "Verification Token"
|
|
// @Success 200 {object} handlers.APIResponse "Success message"
|
|
// @Failure 400 {object} handlers.APIResponse "Invalid Token"
|
|
// @Router /auth/verify-email [post]
|
|
func VerifyEmail(authService *auth.AuthService) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
// SEC-021: Token in query string — consider migrating to POST body in V0.2 to avoid token leakage in Referer/logs
|
|
token := c.Query("token")
|
|
if token == "" {
|
|
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
|
|
RespondWithAppError(c, apperrors.NewValidationError("Token required"))
|
|
return
|
|
}
|
|
|
|
if err := authService.VerifyEmail(c.Request.Context(), token); err != nil {
|
|
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeValidation, "Email verification failed", err))
|
|
return
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusOK, gin.H{"message": "Email verified successfully"})
|
|
}
|
|
}
|
|
|
|
// ResendVerification gère la demande de renvoi d'email de vérification
|
|
// @Summary Resend Verification Email
|
|
// @Description Resend the email verification link
|
|
// @Tags Auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body dto.ResendVerificationRequest true "Email"
|
|
// @Success 200 {object} handlers.APIResponse "Success message"
|
|
// @Failure 400 {object} handlers.APIResponse "Validation Error"
|
|
// @Router /auth/resend-verification [post]
|
|
func ResendVerification(authService *auth.AuthService, logger *zap.Logger) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
commonHandler := NewCommonHandler(logger)
|
|
var req dto.ResendVerificationRequest
|
|
if appErr := commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
|
|
RespondWithAppError(c, appErr)
|
|
return
|
|
}
|
|
|
|
if err := authService.ResendVerificationEmail(c.Request.Context(), req.Email); err != nil {
|
|
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
|
|
if strings.Contains(err.Error(), "email already verified") {
|
|
RespondWithAppError(c, apperrors.NewValidationError(err.Error()))
|
|
return
|
|
}
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to resend verification email", err))
|
|
return
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusOK, gin.H{"message": "Verification email sent if account exists"})
|
|
}
|
|
}
|
|
|
|
// CheckUsername vérifie la disponibilité d'un nom d'utilisateur
|
|
// @Summary Check Username Availability
|
|
// @Description Check if a username is already taken
|
|
// @Tags Auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param username query string true "Username to check"
|
|
// @Success 200 {object} handlers.APIResponse{data=object{available=boolean,username=string}}
|
|
// @Failure 400 {object} handlers.APIResponse "Missing Username"
|
|
// @Router /auth/check-username [get]
|
|
func CheckUsername(authService *auth.AuthService) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
username := c.Query("username")
|
|
if username == "" {
|
|
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
|
|
RespondWithAppError(c, apperrors.NewValidationError("Username is required"))
|
|
return
|
|
}
|
|
|
|
_, err := authService.GetUserByUsername(c.Request.Context(), username)
|
|
available := err != nil
|
|
|
|
RespondSuccess(c, http.StatusOK, gin.H{
|
|
"available": available,
|
|
"username": username,
|
|
})
|
|
}
|
|
}
|
|
|
|
// GetMe retourne les informations de l'utilisateur connecté
|
|
// @Summary Get Current User
|
|
// @Description Get profile information of the currently logged-in user
|
|
// @Tags Auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security BearerAuth
|
|
// @Success 200 {object} handlers.APIResponse{data=object}
|
|
// @Failure 401 {object} handlers.APIResponse "Unauthorized"
|
|
// @Failure 404 {object} handlers.APIResponse "User not found"
|
|
// @Router /auth/me [get]
|
|
func GetMe(userService *services.UserService) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
userID, exists := c.Get("user_id")
|
|
if !exists {
|
|
// MOD-P1-002: Utiliser RespondWithAppError au lieu de gin.H{"error"}
|
|
RespondWithAppError(c, apperrors.NewUnauthorizedError("Unauthorized"))
|
|
return
|
|
}
|
|
|
|
// Convert userID to uuid.UUID
|
|
userUUID, ok := userID.(uuid.UUID)
|
|
if !ok {
|
|
// Try to parse as string if it's not already a UUID
|
|
userIDStr, ok := userID.(string)
|
|
if !ok {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "invalid user id type"))
|
|
return
|
|
}
|
|
parsedUUID, err := uuid.Parse(userIDStr)
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "invalid user id format"))
|
|
return
|
|
}
|
|
userUUID = parsedUUID
|
|
}
|
|
|
|
// Fetch full user from database
|
|
user, err := userService.GetProfileByID(userUUID)
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeNotFound, "user not found"))
|
|
return
|
|
}
|
|
|
|
// Return full user object
|
|
RespondSuccess(c, http.StatusOK, user)
|
|
}
|
|
}
|
|
|
|
// StreamTokenResponse is the response for POST /auth/stream-token
|
|
type StreamTokenResponse struct {
|
|
Token string `json:"token"`
|
|
ExpiresIn int `json:"expires_in"` // seconds
|
|
}
|
|
|
|
// GenerateStreamToken returns a short-lived JWT for HLS and WebSocket auth.
|
|
// SEC-03: Required because TokenStorage.getAccessToken() returns null with httpOnly cookies.
|
|
// @Summary Get ephemeral stream token
|
|
// @Description Returns a 5-minute JWT for HLS and WebSocket authentication (httpOnly cookies prevent direct token access)
|
|
// @Tags Auth
|
|
// @Security BearerAuth
|
|
// @Success 200 {object} StreamTokenResponse
|
|
// @Failure 401 {object} handlers.APIResponse "Unauthorized"
|
|
// @Router /auth/stream-token [post]
|
|
func GenerateStreamToken(userService *services.UserService, jwtService *services.JWTService) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
userID, exists := c.Get("user_id")
|
|
if !exists {
|
|
RespondWithAppError(c, apperrors.NewUnauthorizedError("Unauthorized"))
|
|
return
|
|
}
|
|
|
|
userUUID, ok := userID.(uuid.UUID)
|
|
if !ok {
|
|
userIDStr, ok := userID.(string)
|
|
if !ok {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "invalid user id type"))
|
|
return
|
|
}
|
|
parsedUUID, err := uuid.Parse(userIDStr)
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "invalid user id format"))
|
|
return
|
|
}
|
|
userUUID = parsedUUID
|
|
}
|
|
|
|
user, err := userService.GetProfileByID(userUUID)
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeNotFound, "user not found"))
|
|
return
|
|
}
|
|
|
|
token, err := jwtService.GenerateStreamToken(user)
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to generate stream token", err))
|
|
return
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusOK, StreamTokenResponse{
|
|
Token: token,
|
|
ExpiresIn: 300, // 5 minutes in seconds
|
|
})
|
|
}
|
|
}
|
|
|
|
// UnlockAccount (admin only) unlocks an account that was locked due to failed login attempts.
|
|
// POST /admin/auth/unlock-account with body { "email": "user@example.com" }
|
|
func UnlockAccount(authService *auth.AuthService, logger *zap.Logger) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
var req UnlockAccountRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "invalid request: email is required and must be valid"))
|
|
return
|
|
}
|
|
email := strings.TrimSpace(strings.ToLower(req.Email))
|
|
if email == "" {
|
|
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "email is required"))
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
if err := authService.UnlockAccount(ctx, email); err != nil {
|
|
if logger != nil {
|
|
logger.Warn("Unlock account failed", zap.String("email", email), zap.Error(err))
|
|
}
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to unlock account", err))
|
|
return
|
|
}
|
|
if logger != nil {
|
|
logger.Info("Account unlocked by admin", zap.String("email", email))
|
|
}
|
|
RespondSuccess(c, http.StatusOK, gin.H{"message": "account unlocked", "email": email})
|
|
}
|
|
}
|