package services import ( "context" "crypto/rand" "database/sql" "encoding/base32" "fmt" "time" "veza-backend-api/internal/database" "github.com/google/uuid" "github.com/pquerna/otp/totp" "go.uber.org/zap" ) // TOTPService gère l'authentification à deux facteurs type TOTPService struct { db *database.Database logger *zap.Logger } // TOTPSecret représente un secret TOTP pour un utilisateur type TOTPSecret struct { ID uuid.UUID `json:"id" db:"id"` UserID uuid.UUID `json:"user_id" db:"user_id"` Secret string `json:"-" db:"secret"` CreatedAt time.Time `json:"created_at" db:"created_at"` Enabled bool `json:"enabled" db:"enabled"` } // TOTPSetupResponse réponse pour la configuration 2FA type TOTPSetupResponse struct { Secret string `json:"secret"` QRCodeURL string `json:"qr_code_url"` BackupCodes []string `json:"backup_codes"` } // TOTPVerificationRequest requête de vérification 2FA type TOTPVerificationRequest struct { UserID uuid.UUID `json:"user_id"` Code string `json:"code"` BackupCode string `json:"backup_code,omitempty"` } // BackupCode représente un code de sauvegarde type BackupCode struct { ID uuid.UUID `json:"id" db:"id"` UserID uuid.UUID `json:"user_id" db:"user_id"` Code string `json:"code" db:"code"` Used bool `json:"used" db:"used"` CreatedAt time.Time `json:"created_at" db:"created_at"` UsedAt *time.Time `json:"used_at" db:"used_at"` } // NewTOTPService crée un nouveau service TOTP func NewTOTPService(db *database.Database, logger *zap.Logger) *TOTPService { return &TOTPService{ db: db, logger: logger, } } // SetupTOTP configure le 2FA pour un utilisateur func (ts *TOTPService) SetupTOTP(ctx context.Context, userID uuid.UUID, email string) (*TOTPSetupResponse, error) { // Vérifier si l'utilisateur a déjà un secret TOTP var existingSecret TOTPSecret err := ts.db.QueryRowContext(ctx, ` SELECT id, user_id, secret, created_at, enabled FROM totp_secrets WHERE user_id = $1 `, userID).Scan( &existingSecret.ID, &existingSecret.UserID, &existingSecret.Secret, &existingSecret.CreatedAt, &existingSecret.Enabled, ) if err != nil && err != sql.ErrNoRows { ts.logger.Error("Failed to check existing TOTP secret", zap.Error(err), zap.String("user_id", userID.String()), ) return nil, fmt.Errorf("failed to check existing TOTP secret: %w", err) } var secret string var secretID uuid.UUID if err == sql.ErrNoRows { // Créer un nouveau secret secret = ts.generateSecret() secretID = uuid.New() _, err = ts.db.ExecContext(ctx, ` INSERT INTO totp_secrets (id, user_id, secret, created_at, enabled) VALUES ($1, $2, $3, $4, $5) `, secretID, userID, secret, time.Now(), false) if err != nil { ts.logger.Error("Failed to create TOTP secret", zap.Error(err), zap.String("user_id", userID.String()), ) return nil, fmt.Errorf("failed to create TOTP secret: %w", err) } } else { // Utiliser le secret existant secret = existingSecret.Secret secretID = existingSecret.ID } // Générer les codes de sauvegarde backupCodes, err := ts.generateBackupCodes(ctx, userID) if err != nil { return nil, fmt.Errorf("failed to generate backup codes: %w", err) } // Générer l'URL QR Code issuer := "Veza Platform" accountName := email qrCodeURL := ts.generateQRCodeURL(issuer, accountName, secret) ts.logger.Info("TOTP setup initiated", zap.String("user_id", userID.String()), zap.String("secret_id", secretID.String()), ) return &TOTPSetupResponse{ Secret: secret, QRCodeURL: qrCodeURL, BackupCodes: backupCodes, }, nil } // VerifyTOTP vérifie un code TOTP func (ts *TOTPService) VerifyTOTP(ctx context.Context, req *TOTPVerificationRequest) (bool, error) { // Récupérer le secret TOTP de l'utilisateur var secret string var enabled bool err := ts.db.QueryRowContext(ctx, ` SELECT secret, enabled FROM totp_secrets WHERE user_id = $1 `, req.UserID).Scan(&secret, &enabled) if err != nil { if err == sql.ErrNoRows { return false, fmt.Errorf("TOTP not configured for user") } ts.logger.Error("Failed to get TOTP secret", zap.Error(err), zap.String("user_id", req.UserID.String()), ) return false, fmt.Errorf("failed to get TOTP secret: %w", err) } // Vérifier le code TOTP valid := totp.Validate(req.Code, secret) if valid { ts.logger.Info("TOTP verification successful", zap.String("user_id", req.UserID.String()), ) return true, nil } // Si le code TOTP n'est pas valide, vérifier les codes de sauvegarde if req.BackupCode != "" { valid, err := ts.verifyBackupCode(ctx, req.UserID, req.BackupCode) if err != nil { return false, fmt.Errorf("failed to verify backup code: %w", err) } if valid { ts.logger.Info("Backup code verification successful", zap.String("user_id", req.UserID.String()), ) return true, nil } } ts.logger.Warn("TOTP verification failed", zap.String("user_id", req.UserID.String()), ) return false, nil } // EnableTOTP active le 2FA pour un utilisateur func (ts *TOTPService) EnableTOTP(ctx context.Context, userID uuid.UUID, code string) error { // Vérifier le code avant d'activer valid, err := ts.VerifyTOTP(ctx, &TOTPVerificationRequest{ UserID: userID, Code: code, }) if err != nil { return fmt.Errorf("failed to verify TOTP code: %w", err) } if !valid { return fmt.Errorf("invalid TOTP code") } // Activer le 2FA _, err = ts.db.ExecContext(ctx, ` UPDATE totp_secrets SET enabled = true WHERE user_id = $1 `, userID) if err != nil { ts.logger.Error("Failed to enable TOTP", zap.Error(err), zap.String("user_id", userID.String()), ) return fmt.Errorf("failed to enable TOTP: %w", err) } ts.logger.Info("TOTP enabled", zap.String("user_id", userID.String()), ) return nil } // DisableTOTP désactive le 2FA pour un utilisateur func (ts *TOTPService) DisableTOTP(ctx context.Context, userID uuid.UUID, code string) error { // Vérifier le code avant de désactiver valid, err := ts.VerifyTOTP(ctx, &TOTPVerificationRequest{ UserID: userID, Code: code, }) if err != nil { return fmt.Errorf("failed to verify TOTP code: %w", err) } if !valid { return fmt.Errorf("invalid TOTP code") } // Désactiver le 2FA _, err = ts.db.ExecContext(ctx, ` UPDATE totp_secrets SET enabled = false WHERE user_id = $1 `, userID) if err != nil { ts.logger.Error("Failed to disable TOTP", zap.Error(err), zap.String("user_id", userID.String()), ) return fmt.Errorf("failed to disable TOTP: %w", err) } // Supprimer les codes de sauvegarde _, err = ts.db.ExecContext(ctx, ` DELETE FROM backup_codes WHERE user_id = $1 `, userID) if err != nil { ts.logger.Warn("Failed to delete backup codes", zap.Error(err), zap.String("user_id", userID.String()), ) } ts.logger.Info("TOTP disabled", zap.String("user_id", userID.String()), ) return nil } // IsTOTPEnabled vérifie si le 2FA est activé pour un utilisateur func (ts *TOTPService) IsTOTPEnabled(ctx context.Context, userID uuid.UUID) (bool, error) { var enabled bool err := ts.db.QueryRowContext(ctx, ` SELECT enabled FROM totp_secrets WHERE user_id = $1 `, userID).Scan(&enabled) if err != nil { if err == sql.ErrNoRows { return false, nil } ts.logger.Error("Failed to check TOTP status", zap.Error(err), zap.String("user_id", userID.String()), ) return false, fmt.Errorf("failed to check TOTP status: %w", err) } return enabled, nil } // generateSecret génère un secret TOTP func (ts *TOTPService) generateSecret() string { // Générer 20 bytes aléatoires secret := make([]byte, 20) rand.Read(secret) // Encoder en base32 return base32.StdEncoding.EncodeToString(secret) } // generateQRCodeURL génère l'URL du QR Code func (ts *TOTPService) generateQRCodeURL(issuer, accountName, secret string) string { key, err := totp.Generate(totp.GenerateOpts{ Issuer: issuer, AccountName: accountName, Secret: []byte(secret), }) if err != nil { ts.logger.Error("Failed to generate TOTP key", zap.Error(err), ) return "" } return key.URL() } // generateBackupCodes génère des codes de sauvegarde func (ts *TOTPService) generateBackupCodes(ctx context.Context, userID uuid.UUID) ([]string, error) { // Supprimer les anciens codes _, err := ts.db.ExecContext(ctx, ` DELETE FROM backup_codes WHERE user_id = $1 `, userID) if err != nil { return nil, fmt.Errorf("failed to delete old backup codes: %w", err) } // Générer 10 nouveaux codes codes := make([]string, 10) for i := 0; i < 10; i++ { code := ts.generateBackupCode() codes[i] = code // Insérer en base _, err = ts.db.ExecContext(ctx, ` INSERT INTO backup_codes (id, user_id, code, created_at, used) VALUES ($1, $2, $3, $4, $5) `, uuid.New(), userID, code, time.Now(), false) if err != nil { ts.logger.Error("Failed to insert backup code", zap.Error(err), zap.String("user_id", userID.String()), zap.Int("code_index", i), ) return nil, fmt.Errorf("failed to insert backup code: %w", err) } } return codes, nil } // generateBackupCode génère un code de sauvegarde func (ts *TOTPService) generateBackupCode() string { // Générer 8 bytes aléatoires code := make([]byte, 8) rand.Read(code) // Encoder en base32 et prendre les 8 premiers caractères encoded := base32.StdEncoding.EncodeToString(code) return encoded[:8] } // verifyBackupCode vérifie un code de sauvegarde func (ts *TOTPService) verifyBackupCode(ctx context.Context, userID uuid.UUID, code string) (bool, error) { var backupCode BackupCode err := ts.db.QueryRowContext(ctx, ` SELECT id, user_id, code, used, created_at, used_at FROM backup_codes WHERE user_id = $1 AND code = $2 AND used = false `, userID, code).Scan( &backupCode.ID, &backupCode.UserID, &backupCode.Code, &backupCode.Used, &backupCode.CreatedAt, &backupCode.UsedAt, ) if err != nil { if err == sql.ErrNoRows { return false, nil } ts.logger.Error("Failed to verify backup code", zap.Error(err), zap.String("user_id", userID.String()), ) return false, fmt.Errorf("failed to verify backup code: %w", err) } // Marquer le code comme utilisé _, err = ts.db.ExecContext(ctx, ` UPDATE backup_codes SET used = true, used_at = NOW() WHERE id = $1 `, backupCode.ID) if err != nil { ts.logger.Error("Failed to mark backup code as used", zap.Error(err), zap.String("backup_code_id", backupCode.ID.String()), ) return false, fmt.Errorf("failed to mark backup code as used: %w", err) } ts.logger.Info("Backup code used", zap.String("user_id", userID.String()), zap.String("backup_code_id", backupCode.ID.String()), ) return true, nil } // GetBackupCodes récupère les codes de sauvegarde d'un utilisateur func (ts *TOTPService) GetBackupCodes(ctx context.Context, userID uuid.UUID) ([]string, error) { rows, err := ts.db.QueryContext(ctx, ` SELECT code FROM backup_codes WHERE user_id = $1 AND used = false ORDER BY created_at ASC `, userID) if err != nil { ts.logger.Error("Failed to get backup codes", zap.Error(err), zap.String("user_id", userID.String()), ) return nil, fmt.Errorf("failed to get backup codes: %w", err) } defer rows.Close() var codes []string for rows.Next() { var code string err := rows.Scan(&code) if err != nil { ts.logger.Error("Failed to scan backup code", zap.Error(err), ) continue } codes = append(codes, code) } return codes, nil }