- C1-01: Create CloudService with CRUD folders/files, quota, ownership - C1-02: Create CloudHandler with 11 REST endpoints - C1-03: Register cloud routes in Go router - C1-04: Implement file streaming with HTTP Range support - C1-05: Add publish cloud file as track endpoint - C1-06: Add MSW mock handlers for cloud API - C1-07: Auto-init 5GB storage quota on user registration - C1-08: Add 12 unit tests for CloudService
1019 lines
38 KiB
Go
1019 lines
38 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
|
|
|
|
"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), bcrypt.DefaultCost)
|
|
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")
|
|
}
|
|
|
|
if s.refreshLock != nil {
|
|
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), 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
|
|
}
|