- Modified internal/core/auth/service.go to make token generation non-blocking - If token generation/storage fails, registration still succeeds - User can request a new verification token later - Backend needs to be restarted for changes to take effect Note: This fixes the 'Failed to create user' error when email verification service fails. The registration will now succeed even if token generation fails.
672 lines
24 KiB
Go
672 lines
24 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"veza-backend-api/internal/models"
|
|
"veza-backend-api/internal/monitoring"
|
|
"veza-backend-api/internal/services" // Added import for services
|
|
"veza-backend-api/internal/workers"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"go.uber.org/zap"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"gorm.io/gorm"
|
|
|
|
"veza-backend-api/internal/validators" // Import the validators package
|
|
)
|
|
|
|
type AuthService struct {
|
|
db *gorm.DB
|
|
logger *zap.Logger
|
|
JWTService *services.JWTService // Changed to pointer
|
|
emailVerificationService *services.EmailVerificationService // Changed to pointer
|
|
refreshTokenService *services.RefreshTokenService // Changed to pointer
|
|
passwordResetService *services.PasswordResetService // Added for password reset
|
|
emailValidator *validators.EmailValidator
|
|
passwordValidator *validators.PasswordValidator
|
|
passwordService *services.PasswordService // Changed to pointer
|
|
emailService *services.EmailService // Changed to pointer
|
|
jobWorker *workers.JobWorker // Job worker pour envoi d'emails asynchrones
|
|
accountLockoutService *services.AccountLockoutService // BE-SEC-007: Account lockout service
|
|
}
|
|
|
|
func NewAuthService(
|
|
db *gorm.DB,
|
|
emailValidator *validators.EmailValidator,
|
|
passwordValidator *validators.PasswordValidator,
|
|
passwordService *services.PasswordService, // Changed to pointer
|
|
jwtService *services.JWTService, // Changed to pointer
|
|
refreshTokenService *services.RefreshTokenService, // Changed to pointer
|
|
emailVerificationService *services.EmailVerificationService, // Changed to pointer
|
|
passwordResetService *services.PasswordResetService, // Added for password reset
|
|
emailService *services.EmailService, // Changed to pointer
|
|
jobWorker *workers.JobWorker, // Job worker pour emails asynchrones
|
|
logger *zap.Logger,
|
|
) *AuthService {
|
|
return &AuthService{
|
|
db: db,
|
|
logger: logger,
|
|
JWTService: jwtService,
|
|
emailVerificationService: emailVerificationService,
|
|
refreshTokenService: refreshTokenService,
|
|
passwordResetService: passwordResetService,
|
|
emailValidator: emailValidator,
|
|
passwordValidator: passwordValidator,
|
|
passwordService: passwordService,
|
|
emailService: emailService,
|
|
jobWorker: jobWorker,
|
|
accountLockoutService: nil, // Will be set via SetAccountLockoutService
|
|
}
|
|
}
|
|
|
|
// SetAccountLockoutService définit le service de verrouillage de compte
|
|
// BE-SEC-007: Implement account lockout after failed login attempts
|
|
func (s *AuthService) SetAccountLockoutService(lockoutService *services.AccountLockoutService) {
|
|
s.accountLockoutService = lockoutService
|
|
}
|
|
|
|
// GetUserByUsername récupère un utilisateur par son nom d'utilisateur
|
|
func (s *AuthService) GetUserByUsername(ctx context.Context, username string) (*models.User, error) {
|
|
var user models.User
|
|
if err := s.db.WithContext(ctx).Where("username = ?", username).First(&user).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &user, nil
|
|
}
|
|
|
|
// Refresh est un alias pour RefreshToken
|
|
func (s *AuthService) Refresh(ctx context.Context, refreshToken string) (*models.TokenPair, error) {
|
|
return s.RefreshToken(ctx, refreshToken)
|
|
}
|
|
|
|
func (s *AuthService) Register(ctx context.Context, email, username, password string) (*models.User, error) {
|
|
s.logger.Info("Attempting to register new user", zap.String("email", email))
|
|
|
|
// Valider l'email
|
|
if err := s.emailValidator.Validate(email); err != nil {
|
|
s.logger.Warn("Registration failed: invalid email", zap.String("email", email), zap.Error(err))
|
|
return nil, errors.New("invalid email: " + err.Error())
|
|
}
|
|
|
|
// Valider le mot de passe
|
|
passwordStrength, err := s.passwordValidator.Validate(password)
|
|
if err != nil || !passwordStrength.Valid { // Vérifiez également si la force n'est pas suffisante
|
|
s.logger.Warn("Registration failed: weak password", zap.String("email", email), zap.Error(err))
|
|
// Si l'erreur est nil mais pas valide, utilisez les détails de la force
|
|
if err == nil {
|
|
err = errors.New("weak password: " + strings.Join(passwordStrength.Details, ", "))
|
|
}
|
|
return nil, errors.New("weak password: " + err.Error())
|
|
}
|
|
|
|
// Hacher le mot de passe
|
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
s.logger.Error("Failed to hash password", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
// Générer un slug unique à partir du username
|
|
// Le slug doit être unique, donc on vérifie et on ajoute un suffixe si nécessaire
|
|
baseSlug := strings.ToLower(username)
|
|
slug := baseSlug
|
|
counter := 1
|
|
for {
|
|
var count int64
|
|
err := s.db.WithContext(ctx).Model(&models.User{}).Where("slug = ?", slug).Count(&count).Error
|
|
if err == nil && count == 0 {
|
|
break
|
|
}
|
|
slug = fmt.Sprintf("%s%d", baseSlug, counter)
|
|
counter++
|
|
if counter > 1000 {
|
|
// Fallback: utiliser un timestamp si trop de collisions
|
|
slug = fmt.Sprintf("user_%d", time.Now().Unix())
|
|
break
|
|
}
|
|
}
|
|
|
|
// Créer l'utilisateur dans la base de données
|
|
user := &models.User{
|
|
ID: uuid.New(), // Générer un nouvel UUID
|
|
Email: email,
|
|
Username: username,
|
|
Slug: slug,
|
|
PasswordHash: string(hashedPassword),
|
|
Role: "user", // Valeur par défaut
|
|
IsActive: true, // Valeur par défaut
|
|
IsVerified: false, // Valeur par défaut
|
|
}
|
|
|
|
if err := s.db.WithContext(ctx).Create(user).Error; err != nil {
|
|
// PostgreSQL error code 23505 is unique_violation
|
|
// We check for specific constraint names if possible, or fallback to generic "duplicate"
|
|
errMsg := err.Error()
|
|
if strings.Contains(errMsg, "users_email_key") || strings.Contains(errMsg, "idx_users_email") {
|
|
s.logger.Warn("Registration failed: email already exists", zap.String("email", email))
|
|
return nil, services.ErrUserAlreadyExists
|
|
}
|
|
if strings.Contains(errMsg, "users_username_key") || strings.Contains(errMsg, "idx_users_username") {
|
|
s.logger.Warn("Registration failed: username already exists", zap.String("username", username))
|
|
// We can return the same error or a more specific one if needed
|
|
return nil, errors.New("username already exists")
|
|
}
|
|
if strings.Contains(errMsg, "users_slug_key") || strings.Contains(errMsg, "idx_users_slug") {
|
|
s.logger.Warn("Registration failed: slug collision", zap.String("slug", user.Slug))
|
|
// In a real robust system, we would retry with a suffix here
|
|
// For now, fail explicitly so the user knows
|
|
return nil, errors.New("username unavailable (slug collision)")
|
|
}
|
|
|
|
// Fallback for generic unique constraint
|
|
if strings.Contains(errMsg, "unique constraint") || strings.Contains(errMsg, "duplicate key") {
|
|
s.logger.Warn("Registration failed: unique constraint violation", zap.Error(err))
|
|
return nil, services.ErrUserAlreadyExists
|
|
}
|
|
|
|
s.logger.Error("Failed to create user in database", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
// Générer le token de vérification d'email (non-bloquant)
|
|
// Si la génération échoue, on continue quand même avec l'inscription
|
|
// L'utilisateur pourra demander un nouveau token plus tard
|
|
if s.emailVerificationService != nil {
|
|
token, err := s.emailVerificationService.GenerateToken()
|
|
if err != nil {
|
|
s.logger.Warn("Failed to generate email verification token (non-blocking)", zap.Error(err))
|
|
} else {
|
|
// Stocker le token
|
|
if err := s.emailVerificationService.StoreToken(user.ID, user.Email, token); err != nil {
|
|
s.logger.Warn("Failed to store email verification token (non-blocking)", zap.Error(err))
|
|
} else {
|
|
// Envoyer l'email de vérification (simulation pour l'instant)
|
|
s.logger.Info("Sending verification email",
|
|
zap.String("email", user.Email),
|
|
zap.String("token", token),
|
|
zap.String("user_id", user.ID.String()))
|
|
}
|
|
}
|
|
} else {
|
|
s.logger.Warn("Email verification service not available - skipping token generation")
|
|
}
|
|
|
|
s.logger.Info("User registered successfully", zap.String("user_id", user.ID.String()))
|
|
|
|
// MOD-P2-003: Enregistrer la métrique business
|
|
monitoring.RecordUserRegistered()
|
|
|
|
return user, nil
|
|
}
|
|
|
|
func (s *AuthService) Login(ctx context.Context, email, password string, rememberMe bool) (*models.User, *models.TokenPair, error) {
|
|
s.logger.Info("Attempting login", zap.String("email", email))
|
|
|
|
// BE-SEC-007: Check if account is locked
|
|
if s.accountLockoutService != nil {
|
|
locked, lockedUntil, err := s.accountLockoutService.IsAccountLocked(ctx, email)
|
|
if err != nil {
|
|
s.logger.Warn("Failed to check account lockout status",
|
|
zap.String("email", email),
|
|
zap.Error(err))
|
|
// Continue with login attempt if check fails (fail-open)
|
|
} else if locked {
|
|
if lockedUntil != nil {
|
|
remaining := time.Until(*lockedUntil)
|
|
s.logger.Warn("Login blocked: account is locked",
|
|
zap.String("email", email),
|
|
zap.Time("locked_until", *lockedUntil),
|
|
zap.Duration("remaining", remaining))
|
|
return nil, nil, fmt.Errorf("account is locked. Please try again after %v", remaining.Round(time.Minute))
|
|
}
|
|
return nil, nil, errors.New("account is locked due to too many failed login attempts")
|
|
}
|
|
}
|
|
|
|
var user models.User
|
|
if err := s.db.WithContext(ctx).Where("email = ?", email).First(&user).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
s.logger.Warn("Login failed: user not found", zap.String("email", email))
|
|
// BE-SEC-007: Record failed attempt even if user not found (to prevent email enumeration)
|
|
if s.accountLockoutService != nil {
|
|
if err := s.accountLockoutService.RecordFailedAttempt(ctx, email); err != nil {
|
|
s.logger.Warn("Failed to record failed login attempt",
|
|
zap.String("email", email),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
return nil, nil, errors.New("invalid credentials")
|
|
}
|
|
s.logger.Error("Database error during login", zap.Error(err), zap.String("email", email))
|
|
return nil, nil, err
|
|
}
|
|
|
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
|
s.logger.Warn("Login failed: invalid password", zap.String("email", email))
|
|
// BE-SEC-007: Record failed attempt
|
|
if s.accountLockoutService != nil {
|
|
if err := s.accountLockoutService.RecordFailedAttempt(ctx, email); err != nil {
|
|
s.logger.Warn("Failed to record failed login attempt",
|
|
zap.String("email", email),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
return nil, nil, errors.New("invalid credentials")
|
|
}
|
|
|
|
if !user.IsVerified {
|
|
s.logger.Warn("Login failed: email not verified", zap.String("email", email))
|
|
// BE-SEC-007: Record failed attempt (email not verified is also a failed attempt)
|
|
if s.accountLockoutService != nil {
|
|
if err := s.accountLockoutService.RecordFailedAttempt(ctx, email); err != nil {
|
|
s.logger.Warn("Failed to record failed login attempt",
|
|
zap.String("email", email),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
return nil, nil, errors.New("email not verified")
|
|
}
|
|
|
|
// BE-SEC-007: Record successful login (reset failed attempts counter)
|
|
if s.accountLockoutService != nil {
|
|
if err := s.accountLockoutService.RecordSuccessfulLogin(ctx, email); err != nil {
|
|
s.logger.Warn("Failed to record successful login",
|
|
zap.String("email", email),
|
|
zap.Error(err))
|
|
// Non-critique, on continue
|
|
}
|
|
}
|
|
|
|
// Générer les tokens JWT
|
|
accessToken, err := s.JWTService.GenerateAccessToken(&user)
|
|
if err != nil {
|
|
s.logger.Error("Failed to generate access token", zap.Error(err), zap.String("user_id", user.ID.String()))
|
|
return nil, nil, fmt.Errorf("failed to generate access token: %w", err)
|
|
}
|
|
|
|
refreshTokenTTL := s.JWTService.Config.RefreshTokenTTL
|
|
if rememberMe {
|
|
refreshTokenTTL = s.JWTService.Config.RememberMeRefreshTokenTTL // Assurez-vous que ce champ existe dans models.JWTConfig
|
|
}
|
|
refreshToken, err := s.JWTService.GenerateRefreshToken(&user)
|
|
if err != nil {
|
|
s.logger.Error("Failed to generate refresh token", zap.Error(err), zap.String("user_id", user.ID.String()))
|
|
return nil, nil, fmt.Errorf("failed to generate refresh token: %w", err)
|
|
}
|
|
|
|
// Stocker le refresh token en base
|
|
if err := s.refreshTokenService.Store(user.ID, refreshToken, refreshTokenTTL); err != nil {
|
|
s.logger.Error("Failed to store refresh token", zap.Error(err), zap.String("user_id", user.ID.String()))
|
|
return nil, nil, fmt.Errorf("failed to store refresh token: %w", err)
|
|
}
|
|
|
|
s.logger.Info("User logged in successfully", zap.String("user_id", user.ID.String()))
|
|
|
|
return &user, &models.TokenPair{
|
|
AccessToken: accessToken,
|
|
RefreshToken: refreshToken,
|
|
ExpiresIn: int(s.JWTService.Config.AccessTokenTTL.Seconds()),
|
|
}, nil
|
|
}
|
|
|
|
func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*models.TokenPair, error) {
|
|
claims, err := s.JWTService.ValidateToken(refreshToken)
|
|
if err != nil {
|
|
s.logger.Warn("Invalid refresh token format", zap.Error(err))
|
|
return nil, errors.New("invalid refresh token")
|
|
}
|
|
|
|
if !claims.IsRefresh {
|
|
s.logger.Warn("Token is not a refresh token")
|
|
return nil, errors.New("invalid token type")
|
|
}
|
|
|
|
if err := s.refreshTokenService.Validate(claims.UserID, refreshToken); err != nil {
|
|
s.logger.Warn("Refresh token invalid or revoked", zap.Error(err))
|
|
return nil, errors.New("invalid or revoked refresh token")
|
|
}
|
|
|
|
var user models.User
|
|
if err := s.db.WithContext(ctx).First(&user, claims.UserID).Error; err != nil {
|
|
s.logger.Error("User not found for refresh token", zap.Error(err))
|
|
return nil, errors.New("user not found")
|
|
}
|
|
|
|
newAccessToken, err := s.JWTService.GenerateAccessToken(&user)
|
|
if err != nil {
|
|
s.logger.Error("Failed to generate new access token", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
newRefreshToken, err := s.JWTService.GenerateRefreshToken(&user)
|
|
if err != nil {
|
|
s.logger.Error("Failed to generate new refresh token", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
if err := s.refreshTokenService.Rotate(user.ID, refreshToken, newRefreshToken, s.JWTService.Config.RefreshTokenTTL); err != nil {
|
|
s.logger.Error("Failed to rotate refresh token", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
return &models.TokenPair{
|
|
AccessToken: newAccessToken,
|
|
RefreshToken: newRefreshToken,
|
|
ExpiresIn: int(s.JWTService.Config.AccessTokenTTL.Seconds()),
|
|
}, nil
|
|
}
|
|
|
|
func (s *AuthService) VerifyEmail(ctx context.Context, token string) error {
|
|
userID, err := s.emailVerificationService.VerifyToken(token)
|
|
if err != nil {
|
|
s.logger.Warn("Email verification failed", zap.Error(err))
|
|
return err
|
|
}
|
|
|
|
if err := s.db.WithContext(ctx).Model(&models.User{}).Where("id = ?", userID).Update("is_verified", true).Error; err != nil {
|
|
s.logger.Error("Failed to update user verification status", zap.Error(err))
|
|
return err
|
|
}
|
|
|
|
if err := s.emailVerificationService.InvalidateOldTokens(userID); err != nil {
|
|
s.logger.Warn("Failed to invalidate old verification tokens", zap.Error(err))
|
|
}
|
|
|
|
s.logger.Info("Email verified successfully", zap.String("user_id", userID.String()))
|
|
return nil
|
|
}
|
|
|
|
func (s *AuthService) ResendVerificationEmail(ctx context.Context, email string) error {
|
|
var user models.User
|
|
if err := s.db.WithContext(ctx).Where("email = ?", email).First(&user).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
if user.IsVerified {
|
|
return errors.New("email already verified")
|
|
}
|
|
|
|
if err := s.emailVerificationService.InvalidateOldTokens(user.ID); err != nil {
|
|
s.logger.Error("Failed to invalidate old tokens", zap.Error(err))
|
|
}
|
|
|
|
token, err := s.emailVerificationService.GenerateToken()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := s.emailVerificationService.StoreToken(user.ID, user.Email, token); err != nil {
|
|
return err
|
|
}
|
|
|
|
s.logger.Info("Resending verification email",
|
|
zap.String("email", user.Email),
|
|
zap.String("token", token),
|
|
zap.String("user_id", user.ID.String()))
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *AuthService) Logout(ctx context.Context, userID uuid.UUID, refreshToken string) error {
|
|
// Valider le refresh token
|
|
claims, err := s.JWTService.ValidateToken(refreshToken)
|
|
if err != nil {
|
|
s.logger.Warn("Invalid refresh token during logout", zap.Error(err), zap.String("user_id", userID.String()))
|
|
return nil // Ne pas retourner d'erreur pour ne pas bloquer le logout côté UI
|
|
}
|
|
|
|
if claims.UserID != userID {
|
|
s.logger.Warn("User ID mismatch for logout request", zap.String("requested_user_id", userID.String()), zap.String("token_user_id", claims.UserID.String()))
|
|
return errors.New("user ID mismatch")
|
|
}
|
|
|
|
if err := s.refreshTokenService.Revoke(claims.UserID, refreshToken); err != nil {
|
|
s.logger.Error("Failed to revoke refresh token during logout", zap.Error(err), zap.String("user_id", userID.String()))
|
|
return err
|
|
}
|
|
|
|
s.logger.Info("User logged out successfully", zap.String("user_id", userID.String()))
|
|
return nil
|
|
}
|
|
|
|
func (s *AuthService) InvalidateAllUserSessions(ctx context.Context, userID uuid.UUID, sessionService interface {
|
|
RevokeAllUserSessions(ctx context.Context, userID uuid.UUID) (int64, error)
|
|
}) error {
|
|
if err := s.refreshTokenService.RevokeAll(userID); err != nil {
|
|
s.logger.Error("Failed to revoke all refresh tokens", zap.Error(err))
|
|
return err
|
|
}
|
|
|
|
if sessionService != nil {
|
|
count, err := sessionService.RevokeAllUserSessions(ctx, userID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to revoke user sessions", zap.Error(err))
|
|
} else {
|
|
s.logger.Info("Revoked user sessions", zap.Int64("count", count), zap.String("user_id", userID.String()))
|
|
}
|
|
}
|
|
|
|
s.logger.Info("All user sessions invalidated", zap.String("user_id", userID.String()))
|
|
return nil
|
|
}
|
|
|
|
// MIGRATION UUID: userID migré vers uuid.UUID
|
|
func (s *AuthService) AdminVerifyUser(ctx context.Context, userID uuid.UUID) error {
|
|
result := s.db.WithContext(ctx).Model(&models.User{}).Where("id = ?", userID).Update("is_verified", true)
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
return errors.New("user not found")
|
|
}
|
|
|
|
_ = s.emailVerificationService.InvalidateOldTokens(userID)
|
|
|
|
s.logger.Info("User verified by admin", zap.String("user_id", userID.String()))
|
|
return nil
|
|
}
|
|
|
|
// MIGRATION UUID: userID migré vers uuid.UUID
|
|
func (s *AuthService) AdminBlockUser(ctx context.Context, userID uuid.UUID) error {
|
|
if err := s.refreshTokenService.RevokeAll(userID); err != nil {
|
|
return err
|
|
}
|
|
|
|
s.logger.Info("User blocked by admin", zap.String("user_id", userID.String()))
|
|
return nil
|
|
}
|
|
|
|
func (s *AuthService) RequestPasswordReset(ctx context.Context, email string) error {
|
|
var user models.User
|
|
if err := s.db.WithContext(ctx).Where("email = ?", email).First(&user).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
// Return nil to prevent email enumeration - always return success
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Invalidate old tokens for this user
|
|
if err := s.passwordResetService.InvalidateOldTokens(user.ID); err != nil {
|
|
s.logger.Warn("Failed to invalidate old password reset tokens",
|
|
zap.String("user_id", user.ID.String()),
|
|
zap.Error(err),
|
|
)
|
|
// Continue anyway, not critical
|
|
}
|
|
|
|
// Generate new reset token
|
|
token, err := s.passwordResetService.GenerateToken()
|
|
if err != nil {
|
|
s.logger.Error("Failed to generate password reset token",
|
|
zap.String("user_id", user.ID.String()),
|
|
zap.Error(err),
|
|
)
|
|
return fmt.Errorf("failed to generate reset token: %w", err)
|
|
}
|
|
|
|
// Store token in database
|
|
if err := s.passwordResetService.StoreToken(user.ID, token); err != nil {
|
|
s.logger.Error("Failed to store password reset token",
|
|
zap.String("user_id", user.ID.String()),
|
|
zap.Error(err),
|
|
)
|
|
return fmt.Errorf("failed to store reset token: %w", err)
|
|
}
|
|
|
|
// Send password reset email via job worker (asynchrone)
|
|
if s.jobWorker != nil {
|
|
// Construire l'URL de reset
|
|
baseURL := os.Getenv("FRONTEND_URL")
|
|
if baseURL == "" {
|
|
baseURL = "http://localhost:5173"
|
|
}
|
|
resetURL := fmt.Sprintf("%s/reset-password?token=%s", baseURL, token)
|
|
|
|
// Préparer les données du template
|
|
templateData := map[string]interface{}{
|
|
"Username": user.Username,
|
|
"ResetURL": resetURL,
|
|
}
|
|
|
|
// Enqueue le job d'email avec template
|
|
s.jobWorker.EnqueueEmailJobWithTemplate(
|
|
user.Email,
|
|
"Reset your Veza password",
|
|
"password_reset",
|
|
templateData,
|
|
)
|
|
|
|
s.logger.Info("Password reset email job enqueued",
|
|
zap.String("user_id", user.ID.String()),
|
|
zap.String("email", user.Email),
|
|
)
|
|
} else {
|
|
// Fallback sur l'ancien système si job worker non disponible
|
|
s.logger.Warn("Job worker not available, using direct email service")
|
|
if err := s.emailService.SendPasswordResetEmail(user.ID, user.Email, token); err != nil {
|
|
s.logger.Error("Failed to send password reset email",
|
|
zap.String("user_id", user.ID.String()),
|
|
zap.String("email", user.Email),
|
|
zap.Error(err),
|
|
)
|
|
}
|
|
}
|
|
|
|
s.logger.Info("Password reset requested successfully",
|
|
zap.String("email", email),
|
|
zap.String("user_id", user.ID.String()),
|
|
zap.String("token_preview", token[:min(len(token), 8)]+"..."),
|
|
)
|
|
return nil
|
|
}
|
|
|
|
func (s *AuthService) ResetPassword(ctx context.Context, token, newPassword string) error {
|
|
// Verify the reset token
|
|
userID, err := s.passwordResetService.VerifyToken(token)
|
|
if err != nil {
|
|
s.logger.Warn("Password reset token verification failed",
|
|
zap.String("token_preview", token[:min(len(token), 8)]+"..."),
|
|
zap.Error(err),
|
|
)
|
|
return fmt.Errorf("invalid or expired token: %w", err)
|
|
}
|
|
|
|
// Validate password strength
|
|
if err := s.passwordService.ValidatePassword(newPassword); err != nil {
|
|
s.logger.Warn("Password validation failed during reset",
|
|
zap.String("user_id", userID.String()),
|
|
zap.Error(err),
|
|
)
|
|
return fmt.Errorf("invalid password: %w", err)
|
|
}
|
|
|
|
// Update password using PasswordService
|
|
if err := s.passwordService.UpdatePassword(userID, newPassword); err != nil {
|
|
s.logger.Error("Failed to update password during reset",
|
|
zap.String("user_id", userID.String()),
|
|
zap.Error(err),
|
|
)
|
|
return fmt.Errorf("failed to update password: %w", err)
|
|
}
|
|
|
|
// Mark token as used
|
|
if err := s.passwordResetService.MarkTokenAsUsed(token); err != nil {
|
|
// Log but don't fail - password is already updated
|
|
s.logger.Warn("Failed to mark password reset token as used",
|
|
zap.String("user_id", userID.String()),
|
|
zap.String("token_preview", token[:min(len(token), 8)]+"..."),
|
|
zap.Error(err),
|
|
)
|
|
}
|
|
|
|
// Invalidate all user sessions (revoke refresh tokens)
|
|
if err := s.refreshTokenService.RevokeAll(userID); err != nil {
|
|
s.logger.Warn("Failed to revoke refresh tokens after password reset",
|
|
zap.String("user_id", userID.String()),
|
|
zap.Error(err),
|
|
)
|
|
// Don't fail - password is already updated
|
|
}
|
|
|
|
s.logger.Info("Password reset completed successfully",
|
|
zap.String("user_id", userID.String()),
|
|
)
|
|
return nil
|
|
}
|
|
|
|
// MIGRATION UUID: userID migré vers uuid.UUID
|
|
func (s *AuthService) ChangePassword(ctx context.Context, userID uuid.UUID, currentPassword, newPassword string) error {
|
|
var user models.User
|
|
if err := s.db.WithContext(ctx).First(&user, userID).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(currentPassword)); err != nil {
|
|
return errors.New("invalid current password")
|
|
}
|
|
|
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := s.db.WithContext(ctx).Model(&user).Update("password_hash", string(hashedPassword)).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := s.refreshTokenService.RevokeAll(userID); err != nil {
|
|
s.logger.Warn("Failed to revoke refresh tokens after password change", zap.Error(err))
|
|
}
|
|
|
|
s.logger.Info("Password changed successfully", zap.String("user_id", userID.String()))
|
|
return nil
|
|
}
|
|
|
|
func (s *AuthService) ValidateAccessToken(tokenString string) (*models.CustomClaims, error) {
|
|
return s.JWTService.ValidateToken(tokenString)
|
|
}
|
|
|
|
func (s *AuthService) UpdateLastLogin(ctx context.Context, userID uuid.UUID) error {
|
|
return s.db.WithContext(ctx).Model(&models.User{}).
|
|
Where("id = ?", userID).
|
|
Update("last_login_at", time.Now()).Error
|
|
}
|
|
|
|
// min returns the minimum of two integers (helper function)
|
|
func min(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|