veza/veza-backend-api/internal/handlers/password_reset_handler.go
senke 0e72172291 feat(openapi): annotate queue + password-reset handlers + regen
Closes the two annotation gaps that blocked finishing the orval
migration in v1.0.8 :

  - queue_handler.go (5 routes — GetQueue, UpdateQueue, AddQueueItem,
    RemoveQueueItem, ClearQueue) — under @Tags Queue with @Security
    BearerAuth, @Param body/path, @Success/@Failure on the standard
    APIResponse envelope.
  - queue_session_handler.go (5 routes — CreateSession, GetSession,
    DeleteSession, AddToSession, RemoveFromSession). GetSession is
    public (no @Security tag) since the share-token URL is meant for
    join-via-link from outside the auth wall.
  - password_reset_handler.go (2 routes — RequestPasswordReset and
    ResetPassword factory functions). Both are public (no @Security)
    since they're the entry-points for users who can't log in. The
    request-side annotation documents the intentional generic 200
    response (anti-enumeration: same body whether the email exists or
    not).

After regen :
  - openapi.yaml gains 7 queue paths (/queue, /queue/items[/{id}],
    /queue/session[/{token}[/items[/{id}]]]) and 2 password paths
    (/auth/password/reset, /auth/password/reset-request). +568 LOC.
  - docs/{docs.go,swagger.json,swagger.yaml} updated identically by
    swag init.
  - apps/web/src/services/generated/queue/queue.ts created (10
    HTTP funcs + matching React Query hooks). model/ index extended
    with the queue + password-reset request/response shapes.

Validates with `swag init` (Swagger 2.0). go build ./... clean. No
runtime behaviour change — annotations are pure metadata read by the
spec generator. The orval regen IS the wiring point for the
follow-up frontend commit (queue.ts migration + authService finish).

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

353 lines
13 KiB
Go

package handlers
import (
"context"
"net/http"
"os"
"strings"
"veza-backend-api/internal/core/auth" // Added import for authcore
"veza-backend-api/internal/services"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// isProductionEnv reports whether APP_ENV is set to "production". Used to
// decide whether SMTP delivery failures should surface as HTTP 500.
func isProductionEnv() bool {
return strings.EqualFold(os.Getenv("APP_ENV"), "production")
}
// RequestPasswordResetRequest represents a request to reset password
// T0193: Request structure for password reset endpoint
// MOD-P1-001: Ajout tags validate pour validation systématique
type RequestPasswordResetRequest struct {
Email string `json:"email" binding:"required,email" validate:"required,email"`
}
// PasswordResetServiceInterface defines methods needed for password reset handler
type PasswordResetServiceInterface interface {
GenerateToken() (string, error)
StoreToken(userID uuid.UUID, token string) error
VerifyToken(token string) (uuid.UUID, error)
MarkTokenAsUsed(token string) error
InvalidateOldTokens(userID uuid.UUID) error
}
// PasswordServiceInterface defines methods needed for password operations
type PasswordServiceInterface interface {
GetUserByEmail(email string) (*services.UserInfo, error)
ValidatePassword(password string) error
UpdatePassword(userID uuid.UUID, password string) error
}
// EmailServiceInterface defines methods needed for email operations
type EmailServiceInterface interface {
SendPasswordResetEmail(userID uuid.UUID, email, token string) error
}
// AuditServiceInterface defines methods needed for audit operations
type AuditServiceInterface interface {
LogPasswordResetRequest(ctx context.Context, userID *uuid.UUID, email, ip, userAgent string) error
LogPasswordReset(ctx context.Context, userID uuid.UUID, success bool, ip, userAgent string) error
}
// SessionServiceInterface defines methods needed for session operations
type SessionServiceInterface interface {
RevokeAllUserSessions(ctx context.Context, userID uuid.UUID) (int64, error)
}
// AuthServiceInterface defines methods needed for auth operations
type AuthServiceInterface interface {
InvalidateAllUserSessions(ctx context.Context, userID uuid.UUID, sessionService SessionServiceInterface) error
}
// RequestPasswordReset handles password reset request
// T0193: Creates endpoint POST /api/v1/auth/password/reset-request
// BE-SEC-013: Added audit logging for password reset requests
// @Summary Request password reset
// @Description Sends a password reset link to the user's email if the address exists. Always returns 200 with a generic message to prevent email enumeration.
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body RequestPasswordResetRequest true "Email of the account to reset"
// @Success 200 {object} APIResponse{data=object{message=string}} "If the email exists, a reset link has been sent"
// @Failure 400 {object} APIResponse "Validation error"
// @Failure 500 {object} APIResponse "Token generation/storage failed (or SMTP failure in production)"
// @Router /auth/password/reset-request [post]
func RequestPasswordReset(
passwordResetService *services.PasswordResetService,
passwordService *services.PasswordService,
emailService *services.EmailService,
auditService *services.AuditService,
logger *zap.Logger,
) gin.HandlerFunc {
return RequestPasswordResetWithInterfaces(
passwordResetService,
passwordService,
emailService,
auditService,
logger,
)
}
// RequestPasswordResetWithInterfaces handles password reset request with interfaces (for testing)
func RequestPasswordResetWithInterfaces(
passwordResetService PasswordResetServiceInterface,
passwordService PasswordServiceInterface,
emailService EmailServiceInterface,
auditService AuditServiceInterface,
logger *zap.Logger,
) gin.HandlerFunc {
return func(c *gin.Context) {
commonHandler := NewCommonHandler(logger)
var req RequestPasswordResetRequest
if appErr := commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
RespondWithAppError(c, appErr)
return
}
// Find user by email
user, err := passwordService.GetUserByEmail(req.Email)
if err != nil {
// Always return success for security (prevent email enumeration)
RespondSuccess(c, http.StatusOK, gin.H{"message": "If the email exists, a reset link has been sent"})
return
}
// Invalidate old tokens
if err := passwordResetService.InvalidateOldTokens(user.ID); err != nil {
logger.Error("Failed to invalidate old tokens",
zap.String("user_id", user.ID.String()),
zap.Error(err),
)
// Continue anyway, not critical
}
// Generate token
token, err := passwordResetService.GenerateToken()
if err != nil {
logger.Error("Failed to generate password reset token",
zap.String("user_id", user.ID.String()),
zap.Error(err),
)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"})
return
}
// Store token
if err := passwordResetService.StoreToken(user.ID, token); err != nil {
logger.Error("Failed to store password reset token",
zap.String("user_id", user.ID.String()),
zap.Error(err),
)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store token"})
return
}
// Send email. In production SMTP must work — a silent log-only failure
// would leave the user stuck with no way to reset. In development we
// keep the generic success response so local sign-ups keep flowing.
if err := emailService.SendPasswordResetEmail(user.ID, user.Email, token); err != nil {
logger.Error("Failed to send password reset email",
zap.String("user_id", user.ID.String()),
zap.String("email", user.Email),
zap.Error(err),
)
if isProductionEnv() {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to send password reset email"})
return
}
}
// BE-SEC-013: Log password reset request
if auditService != nil {
userID := user.ID
if err := auditService.LogPasswordResetRequest(c.Request.Context(), &userID, user.Email, c.ClientIP(), c.GetHeader("User-Agent")); err != nil {
logger.Warn("Failed to log password reset request",
zap.String("user_id", user.ID.String()),
zap.Error(err),
)
}
}
// Always return generic success message for security
RespondSuccess(c, http.StatusOK, gin.H{"message": "If the email exists, a reset link has been sent"})
}
}
// ResetPasswordRequest represents a request to complete password reset
// T0194: Request structure for password reset completion
// MOD-P1-001: Ajout tags validate pour validation systématique
type ResetPasswordRequest struct {
Token string `json:"token" binding:"required" validate:"required"`
NewPassword string `json:"new_password" binding:"required,min=12" validate:"required,min=12"`
}
// ResetPassword handles password reset completion
// T0194: Creates endpoint POST /api/v1/auth/password/reset
// T0200: Uses AuthService.InvalidateAllUserSessions to invalidate sessions and update token_version
// BE-SEC-013: Added audit logging for password reset completion
// @Summary Reset password with token
// @Description Completes a password reset using a valid token previously emailed to the user. Invalidates all the user's existing sessions on success.
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body ResetPasswordRequest true "Reset token + new password"
// @Success 200 {object} APIResponse{data=object{message=string}} "Password reset successfully"
// @Failure 400 {object} APIResponse "Invalid or expired token, or password validation failed"
// @Failure 500 {object} APIResponse "Failed to update password"
// @Router /auth/password/reset [post]
func ResetPassword(
passwordResetService *services.PasswordResetService,
passwordService *services.PasswordService,
authService *auth.AuthService, // Changed to *auth.AuthService
sessionService *services.SessionService,
auditService *services.AuditService,
logger *zap.Logger,
) gin.HandlerFunc {
// Convert concrete types to interfaces
var authServiceInterface AuthServiceInterface
if authService != nil {
authServiceInterface = &authServiceAdapter{authService: authService}
}
var sessionServiceInterface SessionServiceInterface
if sessionService != nil {
sessionServiceInterface = &sessionServiceAdapter{sessionService: sessionService}
}
return ResetPasswordWithInterfaces(
passwordResetService,
passwordService,
authServiceInterface,
sessionServiceInterface,
auditService,
logger,
)
}
// authServiceAdapter adapts *auth.AuthService to AuthServiceInterface
type authServiceAdapter struct {
authService *auth.AuthService
}
func (a *authServiceAdapter) InvalidateAllUserSessions(ctx context.Context, userID uuid.UUID, sessionService SessionServiceInterface) error {
return a.authService.InvalidateAllUserSessions(ctx, userID, sessionService)
}
// sessionServiceAdapter adapts *services.SessionService to SessionServiceInterface
type sessionServiceAdapter struct {
sessionService *services.SessionService
}
func (s *sessionServiceAdapter) RevokeAllUserSessions(ctx context.Context, userID uuid.UUID) (int64, error) {
return s.sessionService.RevokeAllUserSessions(ctx, userID)
}
// ResetPasswordWithInterfaces handles password reset completion with interfaces (for testing)
func ResetPasswordWithInterfaces(
passwordResetService PasswordResetServiceInterface,
passwordService PasswordServiceInterface,
authService AuthServiceInterface,
sessionService SessionServiceInterface,
auditService AuditServiceInterface,
logger *zap.Logger,
) gin.HandlerFunc {
return func(c *gin.Context) {
commonHandler := NewCommonHandler(logger)
var req ResetPasswordRequest
if appErr := commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
RespondWithAppError(c, appErr)
return
}
// Verify token
userID, err := passwordResetService.VerifyToken(req.Token)
if err != nil {
logger.Warn("Password reset token verification failed",
zap.String("token", req.Token[:min(len(req.Token), 8)]+"..."),
zap.Error(err),
)
// BE-SEC-013: Log failed password reset attempt
if auditService != nil {
if err := auditService.LogPasswordReset(c.Request.Context(), uuid.Nil, false, c.ClientIP(), c.GetHeader("User-Agent")); err != nil {
logger.Warn("Failed to log password reset failure", zap.Error(err))
}
}
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid or expired token"})
return
}
// Validate password strength
if err := passwordService.ValidatePassword(req.NewPassword); err != nil {
logger.Warn("Password validation failed",
zap.String("user_id", userID.String()),
zap.Error(err),
)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Update password
if err := passwordService.UpdatePassword(userID, req.NewPassword); err != nil {
logger.Error("Failed to update password",
zap.String("user_id", userID.String()),
zap.Error(err),
)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update password"})
return
}
// Mark token as used
if err := passwordResetService.MarkTokenAsUsed(req.Token); err != nil {
// Log but don't fail - password is already updated
logger.Warn("Failed to mark token as used",
zap.String("user_id", userID.String()),
zap.String("token", req.Token[:min(len(req.Token), 8)]+"..."),
zap.Error(err),
)
}
// T0200: Invalidate all user sessions via AuthService
// This updates token_version and revokes all sessions
if authService != nil && sessionService != nil {
err := authService.InvalidateAllUserSessions(c.Request.Context(), userID, sessionService)
if err != nil {
// Log but don't fail - password is already updated
logger.Warn("Failed to invalidate user sessions",
zap.String("user_id", userID.String()),
zap.Error(err),
)
} else {
logger.Info("User sessions invalidated after password reset",
zap.String("user_id", userID.String()),
)
}
}
logger.Info("Password reset completed successfully",
zap.String("user_id", userID.String()),
)
// BE-SEC-013: Log successful password reset
if auditService != nil {
if err := auditService.LogPasswordReset(c.Request.Context(), userID, true, c.ClientIP(), c.GetHeader("User-Agent")); err != nil {
logger.Warn("Failed to log password reset success",
zap.String("user_id", userID.String()),
zap.Error(err),
)
}
}
RespondSuccess(c, http.StatusOK, gin.H{"message": "Password reset successfully"})
}
}
// min returns the minimum of two integers (helper function)
func min(a, b int) int {
if a < b {
return a
}
return b
}