veza/veza-backend-api/internal/handlers/auth.go
senke 84e92a75e2
Some checks failed
Veza CI / Notify on failure (push) Blocked by required conditions
Security Scan / Secret Scanning (gitleaks) (push) Waiting to run
Veza CI / Backend (Go) (push) Has been cancelled
Veza CI / Rust (Stream Server) (push) Has been cancelled
Veza CI / Frontend (Web) (push) Has been cancelled
E2E Playwright / e2e (full) (push) Has been cancelled
feat(observability): OTel SDK + collector + Tempo + 4 hot path spans (W2 Day 9)
Wires distributed tracing end-to-end. Backend exports OTLP/gRPC to a
collector, which tail-samples (errors + slow always, 10% rest) and
ships to Tempo. Grafana service-map dashboard pivots on the 4
instrumented hot paths.

- internal/tracing/otlp_exporter.go : InitOTLPTracer + Provider.Shutdown,
  BatchSpanProcessor (5s/512 batch), ParentBased(TraceIDRatio) sampler,
  W3C trace-context + baggage propagators. OTEL_SDK_DISABLED=true
  short-circuits to a no-op. Failure to dial collector is non-fatal.
- cmd/api/main.go : init at boot, defer Shutdown(5s) on exit. appVersion
  ldflag-overridable for resource attributes.
- 4 hot paths instrumented :
    * handlers/auth.go::Login           → "auth.login"
    * core/track/track_upload_handler.go::InitiateChunkedUpload → "track.upload.initiate"
    * core/marketplace/service.go::ProcessPaymentWebhook → "payment.webhook"
    * handlers/search_handlers.go::Search → "search.query"
  PII guarded — email masked, query content not recorded (length only).
- infra/ansible/roles/otel_collector : pin v0.116.1 contrib build,
  systemd unit, tail-sampling config (errors + > 500ms always kept).
- infra/ansible/roles/tempo : pin v2.7.1 monolithic, local-disk backend
  (S3 deferred to v1.1), 14d retention.
- infra/ansible/playbooks/observability.yml : provisions both Incus
  containers + applies common baseline + roles in order.
- inventory/lab.yml : new groups observability, otel_collectors, tempo.
- config/grafana/dashboards/service-map.json : node graph + 4 hot-path
  span tables + collector throughput/queue panels.
- docs/ENV_VARIABLES.md §30 : 4 OTEL_* env vars documented.

Acceptance criterion (Day 9) : login → span visible in Tempo UI. Lab
deployment to validate with `ansible-playbook -i inventory/lab.yml
playbooks/observability.yml` once roles/postgres_ha is up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 01:15:11 +02:00

878 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"
"veza-backend-api/internal/tracing"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"go.uber.org/zap"
)
// UnlockAccountRequest is the JSON body for admin unlock account.
type UnlockAccountRequest struct {
Email string `json:"email" binding:"required,email"`
}
// maskEmail masks an email address for safe logging: "user@example.com" -> "u***@e***.com"
// SECURITY(MEDIUM-011): Prevent PII leakage in application logs.
func maskEmail(email string) string {
parts := strings.SplitN(email, "@", 2)
if len(parts) != 2 {
return "***"
}
local := parts[0]
domain := parts[1]
maskedLocal := string(local[0]) + "***"
dotIdx := strings.LastIndex(domain, ".")
if dotIdx <= 0 {
return maskedLocal + "@***"
}
return maskedLocal + "@" + string(domain[0]) + "***" + domain[dotIdx:]
}
// 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
// SECURITY(MEDIUM-011): Mask email in logs/spans to prevent PII leakage.
maskedEmail := maskEmail(req.Email)
if logger != nil {
logger.Info("Login handler processing request",
zap.String("email", maskedEmail),
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()
// v1.0.9 Day 9 — auth.login span. Hot path: every login request goes
// through here. Email is masked, no password attribute. Failure paths
// below set the span status to error.
ctx, span := otel.Tracer(tracing.TracerName).Start(ctx, "auth.login",
trace.WithAttributes(
attribute.String("auth.email_masked", maskedEmail),
attribute.Bool("auth.remember_me", rememberMe),
),
)
defer span.End()
user, tokens, err := authService.Login(ctx, req.Email, req.Password, rememberMe)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "login failed")
// 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"
}
// SECURITY(SFIX-002): Session aligned with refresh token TTL (7 days per ORIGIN Rule 4)
expiresIn := 7 * 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(SFIX-002): Refresh token cookie TTL = 7 days (ORIGIN Rule 4)
refreshTokenExpires := 7 * 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)
// 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"
}
// SECURITY(SFIX-002): Session aligned with refresh token TTL (7 days per ORIGIN Rule 4)
expiresIn := 7 * 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))
}
}
// SECURITY(SFIX-002): Refresh token cookie TTL = 7 days (ORIGIN Rule 4)
refreshTokenExpires := 7 * 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]
// Register creates an unverified account and dispatches a verification
// email. v1.0.9 item 1.4 — no JWT, no cookies, no session: the user must
// verify and then POST /auth/login. Previously the handler issued tokens
// and set httpOnly cookies, but the access token was rejected immediately
// by RequireAuth on any unverified-gated route, leaving the user with
// dead credentials and a confusing "logged in but locked out" UX.
func Register(authService *auth.AuthService, _ *services.SessionService, logger *zap.Logger, _ *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", maskEmail(req.Email)), zap.String("username", req.Username))
logger.Debug("Handler register start", zap.String("email", maskEmail(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", maskEmail(req.Email)))
user, 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))
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", maskEmail(req.Email)),
zap.String("username", req.Username),
)
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to create user", err))
}
return
}
response := 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.",
}
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(SFIX-002): Refresh token cookie TTL = 7 days (ORIGIN Rule 4)
refreshTokenExpires := 7 * 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
}
}
}
// VEZA-SEC-006: Add access token to blacklist so it is rejected immediately
if cfg.TokenBlacklist != nil {
authHeader := c.GetHeader("Authorization")
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
accessToken := strings.TrimPrefix(authHeader, "Bearer ")
ttl := 5 * time.Minute // default if we cannot parse
if claims, err := authService.JWTService.ValidateToken(accessToken); err == nil && claims.ExpiresAt != nil {
if remaining := time.Until(claims.ExpiresAt.Time); remaining > 0 {
ttl = remaining
}
}
if err := cfg.TokenBlacklist.Add(c.Request.Context(), accessToken, ttl); err != nil {
// Log but don't fail - logout should succeed even if blacklist fails
}
}
}
// 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. v1.0.9 item 1.3:
// @Description the token is read from the X-Verify-Token header (anti-leak
// @Description via Referer / proxy access logs). The query-param form
// @Description remains accepted for backward compatibility with emails sent
// @Description before v1.0.9 — both paths log a deprecation warning when
// @Description the query path is used.
// @Tags Auth
// @Accept json
// @Produce json
// @Param X-Verify-Token header string true "Verification Token (preferred)"
// @Param token query string false "Verification Token (deprecated, accepted for backward compat)"
// @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) {
// v1.0.9 item 1.3 — prefer header to keep the token out of URL access
// logs and Referer leaks at the proxy/CDN layer. The query fallback
// is deliberate: emails dispatched before this release embed
// `?token=…` in the link, and the frontend that handles those links
// has been updated to forward the value as a header — but a user
// who clicks an old link in a context that bypasses the SPA (e.g.,
// a copy-paste into curl) must still be able to verify.
token := c.GetHeader("X-Verify-Token")
if token == "" {
if legacy := c.Query("token"); legacy != "" {
token = legacy
logger := authService.GetLogger()
if logger != nil {
logger.Warn("verify-email called with token in query string (deprecated since v1.0.9)",
zap.String("path", c.Request.URL.Path),
zap.String("client_ip", c.ClientIP()),
)
}
}
}
if token == "" {
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(c.Request.Context(), 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(c.Request.Context(), 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})
}
}