veza/veza-backend-api/internal/core/auth/handler.go
senke 083b5718a7 feat(auth): defer JWT to post-verify + verify-email header (v1.0.9 items 1.3+1.4)
Item 1.4 — Register no longer issues an access+refresh token pair. The
prior flow set httpOnly cookies at register but the AuthMiddleware
refused them on every protected route until the user had verified
their email (`core/auth/service.go:527`). Users ended up with dead
credentials and a "logged in but locked out" UX. Register now returns
{user, verification_required: true, message} and the SPA's existing
"check your email" notice fires naturally.

Item 1.3 — `POST /auth/verify-email` reads the token from the
`X-Verify-Token` header in preference to the `?token=…` query param.
Query param logged a deprecation warning but stays accepted so emails
dispatched before this release still work. Headers don't leak through
proxy/CDN access logs that record URL but not headers.

Tests: 18 test files updated (sed `_, _, err :=` → `_, err :=` for the
new Register signature). `core/auth/handler_test.go` gets a
`registerVerifyLogin` helper for tests that exercise post-login flows
(refresh, logout). Two new E2E `@critical` specs lock in the defer-JWT
contract and the header read-path.

OpenAPI + orval regenerated to reflect the new RegisterResponse shape
and the verify-email header parameter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 22:56:31 +02:00

324 lines
10 KiB
Go

package auth
import (
"net/http"
"strings"
"time"
"veza-backend-api/internal/dto"
"veza-backend-api/internal/response"
"veza-backend-api/internal/services"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// AuthHandler gère les requêtes d'authentification pour T0151
type AuthHandler struct {
authService *AuthService // Changed to *AuthService (from the current package)
sessionService *services.SessionService
logger *zap.Logger
}
// NewAuthHandler crée une nouvelle instance d'AuthHandler
func NewAuthHandler(authService *AuthService, sessionService *services.SessionService, logger *zap.Logger) *AuthHandler { // Changed to *AuthService
return &AuthHandler{
authService: authService,
sessionService: sessionService,
logger: logger,
}
}
// Register gère l'inscription d'un nouvel utilisateur
func (h *AuthHandler) Register(c *gin.Context) {
var req dto.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
errorMsg := err.Error()
if strings.Contains(errorMsg, "Password") && strings.Contains(errorMsg, "min") {
errorMsg = "Le mot de passe doit contenir au moins 12 caractères"
} else if strings.Contains(errorMsg, "PasswordConfirm") && strings.Contains(errorMsg, "eqfield") {
errorMsg = "Les mots de passe ne correspondent pas"
} else if strings.Contains(errorMsg, "Email") && strings.Contains(errorMsg, "email") {
errorMsg = "Format d'email invalide"
} else if strings.Contains(errorMsg, "required") {
if strings.Contains(errorMsg, "Password") {
errorMsg = "Le mot de passe est requis"
} else if strings.Contains(errorMsg, "Email") {
errorMsg = "L'email est requis"
} else if strings.Contains(errorMsg, "PasswordConfirm") {
errorMsg = "La confirmation du mot de passe est requise"
}
}
h.logger.Warn("Invalid registration request", zap.Error(err), zap.String("error_message", errorMsg))
// MOD-P2-003: Utiliser AppError au lieu de gin.H
response.Error(c, http.StatusBadRequest, errorMsg)
return
}
h.logger.Info("Received registration request", zap.Any("req", req))
// v1.0.9 item 1.4 — Register no longer issues tokens; the user must
// verify email and then login. This handler is the (legacy, unrouted)
// counterpart of `internal/handlers/auth.go:Register` — kept in sync
// for the test suite under `internal/core/auth/handler_test.go`.
user, err := h.authService.Register(c.Request.Context(), req.Email, req.Username, req.Password)
if err != nil {
if strings.Contains(err.Error(), "already exists") {
// MOD-P2-003: Utiliser AppError au lieu de gin.H
response.Error(c, http.StatusConflict, err.Error())
return
}
if strings.Contains(err.Error(), "validation") || strings.Contains(err.Error(), "invalid") {
// MOD-P2-003: Utiliser AppError au lieu de gin.H
response.Error(c, http.StatusBadRequest, err.Error())
return
}
// MOD-P2-003: Utiliser AppError au lieu de gin.H
response.Error(c, http.StatusInternalServerError, "Failed to create user")
return
}
resp := dto.RegisterResponse{
User: dto.UserResponse{
ID: user.ID,
Email: user.Email,
Username: user.Username,
},
VerificationRequired: true,
Message: "Account created. Check your email to verify, then sign in.",
}
c.JSON(http.StatusCreated, resp)
}
// Login gère la connexion d'un utilisateur
func (h *AuthHandler) Login(c *gin.Context) {
var req dto.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
// MOD-P2-003: Utiliser AppError au lieu de gin.H
response.Error(c, http.StatusBadRequest, err.Error())
return
}
user, tokens, err := h.authService.Login(c.Request.Context(), req.Email, req.Password, req.RememberMe)
if err != nil {
if strings.Contains(err.Error(), "email not verified") {
// MOD-P2-003: Utiliser AppError au lieu de gin.H (403 -> ErrCodeForbidden)
response.Error(c, http.StatusForbidden, err.Error())
return
}
if strings.Contains(err.Error(), "invalid credentials") {
// MOD-P2-003: Utiliser AppError au lieu de gin.H
response.Error(c, http.StatusUnauthorized, "Invalid credentials")
return
}
// MOD-P2-003: Utiliser AppError au lieu de gin.H
response.Error(c, http.StatusInternalServerError, "Failed to authenticate")
return
}
if h.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,
}
if _, err := h.sessionService.CreateSession(c.Request.Context(), sessionReq); err != nil {
h.logger.Warn("Failed to create session after login",
zap.String("user_id", user.ID.String()),
zap.String("ip_address", ipAddress),
zap.Error(err),
)
}
}
response := dto.LoginResponse{
User: dto.UserResponse{
ID: user.ID,
Email: user.Email,
},
Token: dto.TokenResponse{
AccessToken: tokens.AccessToken,
RefreshToken: tokens.RefreshToken,
ExpiresIn: int(h.authService.JWTService.GetConfig().AccessTokenTTL.Seconds()),
},
}
c.JSON(http.StatusOK, response)
}
// Refresh gère le rafraîchissement d'un access token
func (h *AuthHandler) Refresh(c *gin.Context) {
var req dto.RefreshRequest
if err := c.ShouldBindJSON(&req); err != nil {
// MOD-P2-003: Utiliser AppError au lieu de gin.H
response.Error(c, http.StatusBadRequest, err.Error())
return
}
tokens, err := h.authService.Refresh(c.Request.Context(), req.RefreshToken)
if err != nil {
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") {
// MOD-P2-003: Utiliser AppError au lieu de gin.H
response.Error(c, http.StatusUnauthorized, "Invalid refresh token")
return
}
// MOD-P2-003: Utiliser AppError au lieu de gin.H
response.Error(c, http.StatusInternalServerError, "Failed to refresh token")
return
}
response := dto.TokenResponse{
AccessToken: tokens.AccessToken,
RefreshToken: tokens.RefreshToken,
ExpiresIn: 900,
}
c.JSON(http.StatusOK, response)
}
// CheckUsername vérifie la disponibilité d'un nom d'utilisateur
func (h *AuthHandler) CheckUsername(c *gin.Context) {
username := c.Query("username")
if username == "" {
// MOD-P2-003: Utiliser AppError au lieu de gin.H
response.Error(c, http.StatusBadRequest, "Username is required")
return
}
_, err := h.authService.GetUserByUsername(c.Request.Context(), username)
available := err != nil
c.JSON(http.StatusOK, gin.H{
"available": available,
"username": username,
})
}
// GetMe retourne les informations de l'utilisateur connecté
func (h *AuthHandler) GetMe(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
// MOD-P2-003: Utiliser AppError au lieu de gin.H
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
c.JSON(http.StatusOK, gin.H{
"id": userID,
"email": c.GetString("email"),
"role": c.GetString("role"),
})
}
// Logout déconnecte l'utilisateur
func (h *AuthHandler) Logout(c *gin.Context) {
userIDInterface, exists := c.Get("user_id")
if !exists {
// MOD-P2-003: Utiliser AppError au lieu de gin.H
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
userID, ok := userIDInterface.(uuid.UUID)
if !ok {
// MOD-P2-003: Utiliser AppError au lieu de gin.H
response.Error(c, http.StatusInternalServerError, "Invalid user ID type in context")
return
}
var req struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
// MOD-P2-003: Utiliser AppError au lieu de gin.H
response.Error(c, http.StatusBadRequest, "Refresh token is required")
return
}
if err := h.authService.Logout(c.Request.Context(), userID, req.RefreshToken); err != nil {
h.logger.Error("Failed to logout (revoke token)", zap.Error(err))
}
if h.sessionService != nil {
authHeader := c.GetHeader("Authorization")
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
token := strings.TrimPrefix(authHeader, "Bearer ")
if err := h.sessionService.RevokeSession(c.Request.Context(), token); err != nil {
h.logger.Warn("Failed to revoke session on logout", zap.Error(err))
}
}
}
c.JSON(http.StatusOK, gin.H{"message": "Logged out successfully"})
}
// VerifyEmail gère la vérification de l'email
func (h *AuthHandler) VerifyEmail(c *gin.Context) {
token := c.Query("token")
if token == "" {
// MOD-P2-003: Utiliser AppError au lieu de gin.H
response.Error(c, http.StatusBadRequest, "Token required")
return
}
if err := h.authService.VerifyEmail(c.Request.Context(), token); err != nil {
// MOD-P2-003: Utiliser AppError au lieu de gin.H
response.Error(c, http.StatusBadRequest, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Email verified successfully"})
}
// ResendVerification gère la demande de renvoi d'email de vérification
func (h *AuthHandler) ResendVerification(c *gin.Context) {
var req struct {
Email string `json:"email" binding:"required,email"`
}
if err := c.ShouldBindJSON(&req); err != nil {
// MOD-P2-003: Utiliser AppError au lieu de gin.H
response.Error(c, http.StatusBadRequest, err.Error())
return
}
if err := h.authService.ResendVerificationEmail(c.Request.Context(), req.Email); err != nil {
if err.Error() == "email already verified" {
// MOD-P2-003: Utiliser AppError au lieu de gin.H
response.Error(c, http.StatusBadRequest, err.Error())
return
}
}
c.JSON(http.StatusOK, gin.H{"message": "Verification email sent if account exists"})
}
// GetUserByUsername gets a user by username
func (h *AuthHandler) GetUserByUsername(c *gin.Context) {
username := c.Param("username")
user, err := h.authService.GetUserByUsername(c.Request.Context(), username)
if err != nil {
response.NotFound(c, "User not found")
return
}
response.Success(c, user)
}