Registration was setting `IsVerified: true` at user-create time and the
"send email" block was a `logger.Info("Sending verification email")` — no
SMTP call. On production this meant any attacker-typo or typosquat email
got a fully-verified account because the user never had to prove
ownership. In development the hack let people "log in" without checking
MailHog, masking SMTP misconfiguration.
Changes:
* `core/auth/service.go`: new users start with `IsVerified: false`. The
existing `POST /auth/verify-email` flow (unchanged) flips the bit
when the user clicks the link.
* Registration now calls `emailService.SendVerificationEmail(...)` for
real. On SMTP failure the handler returns `500` in production (no
stuck account with no recovery path) and logs a warning in
development (local sign-ups keep flowing).
* Same treatment for `password_reset_handler.RequestPasswordReset` —
production fails loud instead of returning the generic success
message after a silent SMTP drop.
* New helper `isProductionEnv()` centralises the
`APP_ENV=="production"` check in both `core/auth` and `handlers`.
* `docker-compose.yml` + `docker-compose.dev.yml` now ship MailHog
(`mailhog/mailhog:v1.0.1`, SMTP 1025, UI 8025). Backend dev env
vars `SMTP_HOST=mailhog SMTP_PORT=1025` pre-wired so dev sign-ups
actually deliver.
Tests: auth test mocks updated (`expectRegister` adds a
`SendVerificationEmail` mock). `TestAuthService_Login_Success` +
`TestAuthHandler_Login_Success` flip `is_verified` directly after
`Register` to simulate the verification click.
`TestLogin_EmailNotVerified` now asserts `403` (previously asserted
`200` — the test was codifying the bug this commit fixes).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
333 lines
11 KiB
Go
333 lines
11 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
|
|
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
|
|
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
|
|
}
|