veza/veza-backend-api/internal/core/auth/service.go
senke e63a0f2720 [FIX] Generate unique slug for user registration
- Implement slug uniqueness check before creating user
- Add numeric suffix if slug already exists (e.g., username1, username2)
- Fallback to timestamp-based slug if too many collisions
- Prevents database constraint violations for duplicate slugs
- Matches the logic used in OAuth service for consistency
2026-01-04 01:44:13 +01:00

668 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
token, err := s.emailVerificationService.GenerateToken()
if err != nil {
s.logger.Error("Failed to generate email verification token", zap.Error(err))
return user, fmt.Errorf("failed to generate verification token: %w", err)
}
// Stocker le token
if err := s.emailVerificationService.StoreToken(user.ID, user.Email, token); err != nil {
s.logger.Error("Failed to store email verification token", zap.Error(err))
return user, fmt.Errorf("failed to store verification token: %w", err)
}
// 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()))
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
}