438 lines
15 KiB
Go
438 lines
15 KiB
Go
|
|
package auth
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"errors"
|
||
|
|
"fmt" // Ajoutez cette ligne
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/google/uuid"
|
||
|
|
"veza-backend-api/internal/models"
|
||
|
|
"veza-backend-api/internal/services" // Added import for services
|
||
|
|
|
||
|
|
"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
|
||
|
|
emailValidator *validators.EmailValidator
|
||
|
|
passwordValidator *validators.PasswordValidator
|
||
|
|
passwordService *services.PasswordService // Changed to pointer
|
||
|
|
emailService *services.EmailService // Changed to pointer
|
||
|
|
}
|
||
|
|
|
||
|
|
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
|
||
|
|
emailService *services.EmailService, // Changed to pointer
|
||
|
|
logger *zap.Logger,
|
||
|
|
) *AuthService {
|
||
|
|
return &AuthService{
|
||
|
|
db: db,
|
||
|
|
logger: logger,
|
||
|
|
JWTService: jwtService,
|
||
|
|
emailVerificationService: emailVerificationService,
|
||
|
|
refreshTokenService: refreshTokenService,
|
||
|
|
emailValidator: emailValidator,
|
||
|
|
passwordValidator: passwordValidator,
|
||
|
|
passwordService: passwordService,
|
||
|
|
emailService: emailService,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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, 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
|
||
|
|
}
|
||
|
|
|
||
|
|
// Créer l'utilisateur dans la base de données
|
||
|
|
user := &models.User{
|
||
|
|
ID: uuid.New(), // Générer un nouvel UUID
|
||
|
|
Email: email,
|
||
|
|
PasswordHash: string(hashedPassword),
|
||
|
|
// Le nom d'utilisateur sera généré par défaut ou défini plus tard
|
||
|
|
// IsVerified: false par défaut
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := s.db.WithContext(ctx).Create(user).Error; err != nil {
|
||
|
|
if strings.Contains(err.Error(), "unique constraint") || strings.Contains(err.Error(), "duplicate key") {
|
||
|
|
s.logger.Warn("Registration failed: email already exists", zap.String("email", email))
|
||
|
|
return nil, errors.New("email already exists")
|
||
|
|
}
|
||
|
|
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, 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()))
|
||
|
|
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))
|
||
|
|
|
||
|
|
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))
|
||
|
|
return nil, nil, errors.New("invalid credentials")
|
||
|
|
}
|
||
|
|
s.logger.Error("Database error during login", zap.Error(err))
|
||
|
|
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))
|
||
|
|
return nil, nil, errors.New("invalid credentials")
|
||
|
|
}
|
||
|
|
|
||
|
|
if !user.IsVerified {
|
||
|
|
s.logger.Warn("Login failed: email not verified", zap.String("email", email))
|
||
|
|
return nil, nil, errors.New("email not verified")
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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))
|
||
|
|
return nil, nil, 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))
|
||
|
|
return nil, nil, 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))
|
||
|
|
return nil, nil, 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, 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
|
||
|
|
}
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
token, err := s.emailVerificationService.GenerateToken()
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
// TODO(P2-GO-010): Store reset token - Implémenter table password_reset_tokens selon ORIGIN_DATABASE_SCHEMA
|
||
|
|
s.logger.Info("Password reset requested", zap.String("email", email), zap.String("token_preview", token[:5]+"..."))
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *AuthService) ResetPassword(ctx context.Context, token, newPassword string) error {
|
||
|
|
// TODO(P2-GO-010): Verify reset token - Implémenter vérification token selon ORIGIN_SECURITY_FRAMEWORK
|
||
|
|
// userID := ...
|
||
|
|
// For now, assume verification is done or stubbed
|
||
|
|
|
||
|
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
// Update password in DB (example with stubbed userID)
|
||
|
|
// if err := s.db.Model(&models.User{}).Where("id = ?", userID).Update("password_hash", string(hashedPassword)).Error; err != nil { return err }
|
||
|
|
|
||
|
|
s.logger.Warn("ResetPassword not fully implemented yet - password hash generated but not saved", zap.String("hash_preview", string(hashedPassword)[:10]))
|
||
|
|
|
||
|
|
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
|
||
|
|
}
|