728 lines
26 KiB
Go
728 lines
26 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") {
|
|
fmt.Println("DEBUG: Using c.JSON(401)")
|
|
// 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
|
|
refreshTokenExpires := 30 * 24 * time.Hour // 30 jours par défaut
|
|
if rememberMe {
|
|
refreshTokenExpires = 90 * 24 * time.Hour // 90 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()),
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
commonHandler := NewCommonHandler(logger)
|
|
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
|
|
}
|
|
|
|
var req struct {
|
|
RefreshToken string `json:"refresh_token" binding:"required"`
|
|
}
|
|
|
|
if appErr := commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
|
|
RespondWithAppError(c, appErr)
|
|
return
|
|
}
|
|
|
|
if err := authService.Logout(c.Request.Context(), userID, req.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) {
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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})
|
|
}
|
|
}
|