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 "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.JWTServiceInterface emailVerificationService services.EmailVerificationServiceInterface refreshTokenService services.RefreshTokenServiceInterface passwordResetService services.PasswordResetServiceInterface emailValidator *validators.EmailValidator passwordValidator *validators.PasswordValidator passwordService services.PasswordServiceInterface emailService services.EmailServiceInterface jobWorker services.JobWorkerInterface accountLockoutService *services.AccountLockoutService refreshLock *services.RefreshLock // Redis lock for concurrent refresh token requests } func NewAuthService( db *gorm.DB, emailValidator *validators.EmailValidator, passwordValidator *validators.PasswordValidator, passwordService services.PasswordServiceInterface, jwtService services.JWTServiceInterface, refreshTokenService services.RefreshTokenServiceInterface, emailVerificationService services.EmailVerificationServiceInterface, passwordResetService services.PasswordResetServiceInterface, emailService services.EmailServiceInterface, jobWorker services.JobWorkerInterface, refreshLock *services.RefreshLock, 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, refreshLock: refreshLock, } } // 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 } // UnlockAccount déverrouille un compte (appelé par l'admin). No-op si le service de lockout est nil. func (s *AuthService) UnlockAccount(ctx context.Context, email string) error { if s.accountLockoutService == nil { return nil } return s.accountLockoutService.UnlockAccount(ctx, email) } // 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 { // FIX #10: Logger l'erreur avec contexte s.logger.Error("Failed to get user by username", zap.String("username", username), zap.Error(err), ) 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, *models.TokenPair, error) { // FIX #5: Remplacer fmt.Print* par logs structurés s.logger.Debug("Registration started", zap.String("email", email), zap.String("username", username), ) defer func() { if r := recover(); r != nil { s.logger.Error("PANIC in Register", zap.Any("panic", r)) panic(r) // Re-panic pour que le middleware Recovery le capture } }() // Valider l'email s.logger.Debug("Validating email", zap.String("email", email)) if err := s.emailValidator.Validate(email); err != nil { s.logger.Warn("Registration failed: invalid email", zap.String("email", email), zap.Error(err)) // Utiliser le sentinel error pour que IsInvalidEmail() le détecte if strings.Contains(err.Error(), "already exists") { return nil, nil, services.ErrUserAlreadyExists } return nil, nil, fmt.Errorf("%w: %v", services.ErrInvalidEmail, err) } // Vérifier si le username existe déjà s.logger.Debug("Checking username uniqueness", zap.String("username", username)) var usernameCount int64 if err := s.db.WithContext(ctx).Model(&models.User{}).Where("LOWER(username) = LOWER(?)", username).Count(&usernameCount).Error; err != nil { s.logger.Error("Failed to check username uniqueness", zap.String("username", username), zap.Error(err)) return nil, nil, fmt.Errorf("failed to check username: %w", err) } if usernameCount > 0 { s.logger.Warn("Registration failed: username already exists", zap.String("username", username)) return nil, nil, services.ErrUserAlreadyExists } // Valider le mot de passe s.logger.Debug("Validating password", zap.String("email", email)) passwordStrength, err := s.passwordValidator.Validate(password) if err != nil { s.logger.Warn("Registration failed: weak password", zap.String("email", email), zap.Error(err)) return nil, nil, fmt.Errorf("%w: %v", services.ErrWeakPassword, err) } if !passwordStrength.Valid { s.logger.Warn("Registration failed: weak password", zap.String("email", email), zap.Any("details", passwordStrength.Details)) details := strings.Join(passwordStrength.Details, ", ") return nil, nil, fmt.Errorf("%w: %s", services.ErrWeakPassword, details) } // Hacher le mot de passe s.logger.Debug("Hashing password", zap.String("email", email)) hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 12 /* SECURITY(REM-016): Explicit cost 12, aligned with password_service.go */) if err != nil { s.logger.Error("Failed to hash password", zap.Error(err)) return nil, 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 s.logger.Debug("Generating slug", zap.String("username", username)) 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 { s.logger.Error("Failed to check slug uniqueness", zap.String("slug", slug), zap.Error(err)) return nil, nil, err } if 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 } } s.logger.Debug("Slug generated", zap.String("slug", slug), zap.String("username", username)) // Créer l'utilisateur dans la base de données // IMPORTANT: Initialiser explicitement tous les champs NOT NULL pour éviter les erreurs de contrainte s.logger.Debug("Creating user object", zap.String("email", email), zap.String("username", username)) now := time.Now() 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 (doit correspondre à l'ENUM PostgreSQL) IsActive: true, // Valeur par défaut IsVerified: true, // MVP: Auto-verify email pour permettre login immédiat IsBanned: false, // Valeur par défaut (required NOT NULL field) TokenVersion: 0, // Valeur par défaut (required NOT NULL field) LoginCount: 0, // Valeur par défaut (required NOT NULL field) CreatedAt: now, // Explicitement défini pour éviter les problèmes GORM UpdatedAt: now, // Explicitement défini pour éviter les problèmes GORM } s.logger.Debug("User object created", zap.String("user_id", user.ID.String()), zap.String("email", user.Email), zap.String("username", user.Username), zap.String("role", user.Role), zap.Bool("is_banned", user.IsBanned), zap.Int("login_count", user.LoginCount), ) // Log les valeurs avant insertion pour diagnostic s.logger.Info("Creating user with values", zap.String("email", email), zap.String("username", username), zap.Bool("is_banned", user.IsBanned), zap.Int("login_count", user.LoginCount), zap.Int("token_version", user.TokenVersion), zap.Bool("is_active", user.IsActive), zap.Bool("is_verified", user.IsVerified), zap.String("role", user.Role), zap.String("slug", user.Slug), zap.String("user_id", user.ID.String()), ) // Utiliser GORM Create - GORM gère automatiquement les placeholders PostgreSQL // Tous les champs NOT NULL sont maintenant explicitement initialisés // IMPORTANT: Utiliser Omit pour exclure les relations qui pourraient causer des problèmes s.logger.Debug("Inserting user in database", zap.String("user_id", user.ID.String()), zap.String("email", user.Email), zap.String("username", user.Username), zap.String("role", user.Role), zap.Bool("is_active", user.IsActive), zap.Bool("is_verified", user.IsVerified), zap.Bool("is_banned", user.IsBanned), zap.Int("token_version", user.TokenVersion), zap.Int("login_count", user.LoginCount), ) result := s.db.WithContext(ctx).Omit("Roles", "TrackLikes").Create(user) if result.Error != nil { // Log l'erreur complète pour diagnostic err := result.Error errMsg := err.Error() errType := fmt.Sprintf("%T", err) s.logger.Error("Failed to create user in database - FULL ERROR DETAILS", zap.Error(err), zap.String("error_type", errType), zap.String("error_string", errMsg), zap.String("email", email), zap.String("username", username), zap.String("slug", slug), zap.String("role", user.Role), zap.String("user_id", user.ID.String()), zap.Any("user_fields", map[string]interface{}{ "is_banned": user.IsBanned, "login_count": user.LoginCount, "token_version": user.TokenVersion, "is_active": user.IsActive, "is_verified": user.IsVerified, "role": user.Role, "slug": user.Slug, }), ) // Vérifier les erreurs PostgreSQL spécifiques // Contrainte CHECK if strings.Contains(errMsg, "violates check constraint") { if strings.Contains(errMsg, "chk_users_username_format") { s.logger.Warn("Registration failed: username format invalid", zap.String("username", username)) return nil, nil, errors.New("username format invalid: must be 3-30 alphanumeric characters") } if strings.Contains(errMsg, "chk_users_email_format") { s.logger.Warn("Registration failed: email format invalid", zap.String("email", email)) return nil, nil, errors.New("email format invalid") } // Autre contrainte CHECK s.logger.Warn("Registration failed: check constraint violation", zap.Error(err)) return nil, nil, fmt.Errorf("validation failed: %w", err) } // Type ENUM manquant ou valeur invalide if strings.Contains(errMsg, "does not exist") && strings.Contains(errMsg, "user_role") { s.logger.Error("Registration failed: user_role enum missing from database") return nil, nil, fmt.Errorf("database schema error: user_role enum missing - run migrations") } // Erreur de valeur ENUM invalide if strings.Contains(errMsg, "invalid input value for enum") || strings.Contains(errMsg, "invalid input syntax for type user_role") { s.logger.Error("Registration failed: invalid role value for enum", zap.String("role", user.Role), zap.Error(err)) return nil, nil, fmt.Errorf("invalid role value '%s' for enum user_role: %w", user.Role, err) } // Timeout if strings.Contains(errMsg, "context deadline exceeded") || strings.Contains(errMsg, "timeout") { s.logger.Warn("Registration failed: database operation timed out") return nil, nil, fmt.Errorf("database operation timed out: %w", err) } // PostgreSQL error code 23505 is unique_violation // We check for specific constraint names if possible, or fallback to generic "duplicate" 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, 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)) return nil, nil, services.ErrUserAlreadyExists } 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)) return nil, 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, nil, services.ErrUserAlreadyExists } // Pour toutes les autres erreurs, retourner l'erreur originale avec contexte // IMPORTANT: Inclure l'erreur complète pour diagnostic s.logger.Error("Registration failed: unknown database error", zap.Error(err), zap.String("error_type", errType), zap.String("error_string", errMsg), ) return nil, nil, fmt.Errorf("database error [%s]: %w", errType, err) } s.logger.Debug("User inserted successfully", zap.String("user_id", user.ID.String()), zap.Int64("rows_affected", result.RowsAffected), ) // C1-07: Auto-init storage quota for new users quota := &models.StorageQuota{ UserID: user.ID, MaxBytes: 5 * 1024 * 1024 * 1024, // 5GB UsedBytes: 0, } if err := s.db.WithContext(ctx).Create(quota).Error; err != nil { s.logger.Warn("Failed to init storage quota", zap.String("user_id", user.ID.String()), zap.Error(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())) s.logger.Debug("Generating tokens", zap.String("user_id", user.ID.String())) // MVP: Générer les tokens JWT pour permettre l'authentification immédiate if s.JWTService == nil { s.logger.Error("JWTService is nil - cannot generate tokens") return nil, nil, fmt.Errorf("JWT service not available") } accessToken, err := s.JWTService.GenerateAccessToken(user) if err != nil { s.logger.Error("Failed to generate access token after registration", zap.Error(err), zap.String("user_id", user.ID.String())) return nil, nil, fmt.Errorf("failed to generate access token: %w", err) } s.logger.Debug("Access token generated", zap.String("user_id", user.ID.String())) refreshToken, err := s.JWTService.GenerateRefreshToken(user) if err != nil { s.logger.Error("Failed to generate refresh token after registration", zap.Error(err), zap.String("user_id", user.ID.String())) return nil, nil, fmt.Errorf("failed to generate refresh token: %w", err) } s.logger.Debug("Refresh token generated", zap.String("user_id", user.ID.String())) // Stocker le refresh token en base s.logger.Debug("Storing refresh token", zap.String("user_id", user.ID.String())) refreshTokenTTL := s.JWTService.GetConfig().RefreshTokenTTL if s.refreshTokenService != nil { if err := s.refreshTokenService.Store(user.ID, refreshToken, refreshTokenTTL); err != nil { s.logger.Error("Failed to store refresh token after registration", zap.Error(err), zap.String("user_id", user.ID.String())) return nil, nil, fmt.Errorf("failed to store refresh token: %w", err) } s.logger.Debug("Refresh token stored", zap.String("user_id", user.ID.String())) } else { s.logger.Warn("Refresh token service not available - skipping token storage", zap.String("user_id", user.ID.String())) s.logger.Warn("Refresh token service not available - skipping token storage") } // MOD-P2-003: Enregistrer la métrique business monitoring.RecordUserRegistered() tokenPair := &models.TokenPair{ AccessToken: accessToken, RefreshToken: refreshToken, ExpiresIn: int(s.JWTService.GetConfig().AccessTokenTTL.Seconds()), } s.logger.Info("Registration completed successfully", zap.String("user_id", user.ID.String()), zap.String("email", email), zap.String("username", username), ) return user, tokenPair, 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)) // Fail-secure: treat as locked when check fails (e.g. Redis down) return nil, nil, errors.New("account verification temporarily unavailable. Please try again later.") } if locked { if lockedUntil != nil { s.logger.Warn("Login blocked: account is locked", zap.String("email", email), zap.Time("locked_until", *lockedUntil)) } else { s.logger.Warn("Login blocked: account is locked", zap.String("email", email)) } return nil, nil, errors.New("account is locked due to too many failed login attempts. Please try again later.") } } 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.GetConfig().RefreshTokenTTL if rememberMe { refreshTokenTTL = s.JWTService.GetConfig().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.GetConfig().AccessTokenTTL.Seconds()), }, nil } // LoginWith2FA validates email/password, verifies the TOTP code, then generates and returns tokens. // Used when the user has already been told requires_2fa from POST /auth/login and completes 2FA via POST /auth/login/2fa. func (s *AuthService) LoginWith2FA(ctx context.Context, email, password, code string, rememberMe bool, twoFactorService *services.TwoFactorService) (*models.User, *models.TokenPair, error) { s.logger.Info("Attempting login with 2FA", zap.String("email", email)) 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)) } 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("LoginWith2FA failed: user not found", zap.String("email", email)) if s.accountLockoutService != nil { _ = s.accountLockoutService.RecordFailedAttempt(ctx, email) } return nil, nil, errors.New("invalid credentials") } s.logger.Error("Database error during login with 2FA", 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("LoginWith2FA failed: invalid password", zap.String("email", email)) if s.accountLockoutService != nil { _ = s.accountLockoutService.RecordFailedAttempt(ctx, email) } return nil, nil, errors.New("invalid credentials") } if !user.IsVerified { s.logger.Warn("LoginWith2FA failed: email not verified", zap.String("email", email)) if s.accountLockoutService != nil { _ = s.accountLockoutService.RecordFailedAttempt(ctx, email) } return nil, nil, errors.New("email not verified") } if twoFactorService == nil { return nil, nil, fmt.Errorf("2FA service not available") } enabled, err := twoFactorService.GetTwoFactorStatus(ctx, user.ID) if err != nil { s.logger.Warn("Failed to get 2FA status", zap.String("user_id", user.ID.String()), zap.Error(err)) return nil, nil, fmt.Errorf("failed to verify 2FA: %w", err) } if !enabled { return nil, nil, errors.New("2FA not enabled for this account") } valid, err := twoFactorService.VerifyTwoFactor(ctx, user.ID, code) if err != nil { s.logger.Warn("2FA verification error", zap.String("user_id", user.ID.String()), zap.Error(err)) return nil, nil, fmt.Errorf("2FA verification failed: %w", err) } if !valid { s.logger.Warn("Invalid 2FA code", zap.String("user_id", user.ID.String())) if s.accountLockoutService != nil { _ = s.accountLockoutService.RecordFailedAttempt(ctx, email) } return nil, nil, errors.New("invalid 2FA code") } if s.accountLockoutService != nil { _ = s.accountLockoutService.RecordSuccessfulLogin(ctx, email) } accessToken, err := s.JWTService.GenerateAccessToken(&user) if err != nil { s.logger.Error("Failed to generate access token for 2FA login", 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.GetConfig().RefreshTokenTTL if rememberMe { refreshTokenTTL = s.JWTService.GetConfig().RememberMeRefreshTokenTTL } refreshToken, err := s.JWTService.GenerateRefreshToken(&user) if err != nil { s.logger.Error("Failed to generate refresh token for 2FA login", zap.Error(err), zap.String("user_id", user.ID.String())) return nil, nil, fmt.Errorf("failed to generate refresh token: %w", err) } if err := s.refreshTokenService.Store(user.ID, refreshToken, refreshTokenTTL); err != nil { s.logger.Error("Failed to store refresh token for 2FA login", 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 with 2FA", zap.String("user_id", user.ID.String())) return &user, &models.TokenPair{ AccessToken: accessToken, RefreshToken: refreshToken, ExpiresIn: int(s.JWTService.GetConfig().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") } // SECURITY(REM-010): Lock is mandatory — if Redis is down, reject refresh to prevent TOCTOU race. if s.refreshLock == nil { s.logger.Error("Refresh lock not configured — rejecting refresh for safety") return nil, errors.New("refresh service unavailable") } acquired, release := s.refreshLock.AcquireRefreshLock(ctx, claims.UserID, refreshToken) if !acquired { s.logger.Warn("Concurrent refresh attempt blocked by lock") return nil, errors.New("refresh already in progress") } defer release() 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.GetConfig().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.GetConfig().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 == "" { appDomain := os.Getenv("APP_DOMAIN") if appDomain == "" { appDomain = "veza.fr" } baseURL = "http://" + appDomain + ":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), 12 /* SECURITY(REM-016): Explicit cost 12, aligned with password_service.go */) 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 }