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