Comment gérer l'inscription multi site ?

Par @jbnahan69
Source https://www.pxfuel.com/en/free-photo-xdlyb

Le projet de gestion de plusieurs site (multi tenant) est bien partit, nous avons vu les différentes façons de gérer plusieurs sites web, j'en ai choisi une et j'ai démarrer le projet avec la configuration et les templates, puis est venue la gestion de la bases de données et des migrations.

Il est maintenant temps de gérer l'inscription des utilisateurs et leur connexion.

Le fonctionnement de l'enregistrement d'un utilisateur et sa connexion étant identique quelque soit le client, le code sera donc défini dans le dossier Platform.

Cependant, la plateforme n'a pas connaissance du nom de la classe utilisateur du client en court.

Il faut donc que le code présent dans Platform soit fonctionnel quelques soit le client utilisé. Faisons le tour des difficultés à venir :

  • Dans le contrôleur d'enregistrement, il est nécessaire d'instancier la classe utilisateur pour l'utiliser dans le formulaire. Puis définir son mot de passe haché et enfin, le sauvegarder.
  • Le formulaire d'enregistrement, a besoin de connaitre la classe dans les options de configuration.
  • Le repository doit également connaitre le nom de la classe à laquelle il est lié.
  • Le composant Sécurité de Symfony a besoin de connaitre le nom de la classe Utilisateur pour le fournisseur d'utilisateur.

Ces problèmes seront identique pour toutes les autres MappedSuperclass présente dans le dossier Platform/Entity. Reprenons chacun des problèmes pour les résoudre.

Connaitre le nom des classes d'entité

La première chose à obtenir est le nom complet (FQCN) de la classe utilisateur du client en cours. Ce nom, nous l'avons mis dans la configuration de Doctrine pour chaque client (dans : config/ClientB/packages/doctrine.yaml par exemple).

Nous pouvons mutualiser cette configuration en la déplaçant dans la configuration globale (fichier : config/packages/doctrine.yaml) et en utilisant un paramètre pour définir le nom de l'entité ciblé pour le client en cours.

doctrine:
    orm:
        resolve_target_entities:
            App\Platform\Entity\TodoInterface: '%app.todo.class%'
            App\Platform\Entity\PlatformUserInterface: '%app.platform_user.class%'

Ces paramètres seront défini dans la configuration de chaque client dans le fichier config/ClientB/packages/client_parameters.yaml ajouté pour l'occasion :

parameters:
  app.todo.class: App\ClientB\Entity\Todo
  app.platform_user.class: App\ClientB\Entity\User

Cette solution a un autre avantage, il est possible d'injecté le paramètre dans tous les services ayant un nom de paramètre spécifique dans leur constructeur.

Pour cela, j'ai ajouté cette configuration dans le fichier config/services.yaml

services:
    _defaults:
        bind:
            string $userClassname: '%app.platform_user.class%'
            string $todoClassname: '%app.todo.class%'

Il est nécessaire de l'ajouté également dans le fichier de définition des services spécifiques à un client si nécessaire.

Configuration de la sécurité

Le projet a besoin d'identifier et authentifier les utilisateurs. Comme dans tout projet Symfony 4 et suivant, la configuration est réalisé dans le fichier config/packages/security.yaml.

Sauf que pour la configuration du fournisseur d'utilisateur, il est nécessaire de fournir le nom complet de la classe utilisateur. Cette classe dépendant du client en cours, nous avons le choix entre deux façon de configurer Symfony :

Soit en ajoutant un fichier de configuration security.yaml pour chaque client, soit utiliser le paramètre définir plus haut contenant le nom de la classe utilisateur.

Voici le résultat pour la seconde option :

security:
    providers:
        app_user_provider:
            entity:
                class: '%app.platform_user.class%'
                property: email

La propriété utilisé pour l'identifiant de l'utilisateur sera toujours la même car défini dans l'entité présente dans Platform/Entity.

Initialisation d'une nouvelle instance

En développement logiciel, lorsque nous avons besoin d'un résultat sans connaitre le détail du fonctionnement, nous déléguons.

Dans notre cas, j'ai besoin d'une nouvelle instance d'un utilisateur sans savoir l'instancier. La meilleur méthode est la fabrique (factory).

C'est un service qui se chargera d'instancier l'utilisateur lorsque c'est nécessaire. Toutes les factory aurons la même signature pour toutes les entités présente dans Platform/Entity. Autant réalisé une interface pour unifier les fabriques.

namespace App\Platform\Factory;

interface ObjectFactoryInterface
{
    public function createNew(): object;
}

Et voici l'implémentation de l'interface pour la classe User:

namespace App\Platform\Factory;


final class UserFactory implements ObjectFactoryInterface
{
    public function __construct(private readonly string $userClassname)
    {
    }

    public function createNew(): object
    {
        return new $this->userClassname;
    }
}

J'ai utilisé le nom de paramètre spécifique string $userClassname pour recevoir le nom de la classe User pour le client en cours. Cela évite de devoir définir la fabrique pour chaque client (et risquer de l'oublier).

J'aurai même pu réaliser une fabrique générique et configurer autant de service que d'entité dans le dossier Platform/Entity.

Les repository Doctrine

Depuis quelques temps maintenant, les repository Doctrine sont des services et le constructeur nécessite deux arguments: le ManagerRegistry et le nom de la classe géré par le repository.

Le premier est fourni automatiquement par Symfony et le second est en général défini dans le repository avec le nom de la classe. Sauf que pour les entités User et Todo, il n'est pas possible de connaitre le nom à l'avance.

Je vais donc utiliser le nom de paramètre spécifique pour obtenir le nom de la classe pour le repository User et le fournir au constructeur de la classe parente :

use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;

class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
{
    public function __construct(ManagerRegistry $registry, string $userClassname)
    {
        parent::__construct($registry, $userClassname);
    }
}

 

Les formulaires

Pour les formulaires liés au entité présente dans Platform/Entity c'est le même problème que pour les repository. Il est nécessaire d'avoir le nom de la classe.

Comme les formulaires sont des services Symfony, l'injection du nom de la classe passera par le constructeur avec le nom du paramètre spécifique :

namespace App\Platform\From;


use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

final class UserRegistrationType extends AbstractType
{
    public function __construct(private readonly string $userClassname)
    {
    }

    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->add('email')
            ->add(
                'clearpassword',
                RepeatedType::class,
                ['mapped' => false, 'type' => PasswordType::class, 'label'=>'Password']
            );
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => $this->userClassname,
        ]);
    }
}

Le contrôleur d'enregistrement

Maintenant que presque tout est résolu, passons à l'écriture du contrôleur pour l'enregistrement d'un utilisateur :

namespace App\Platform\Controller;


use App\Platform\Entity\User;
use App\Platform\Factory\ObjectFactoryInterface;
use App\Platform\From\UserRegistrationType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
use Symfony\Component\Routing\Annotation\Route;

final class RegistrationController extends AbstractController
{
    #[Route(path: '/register', name: 'app_register')]
    public function __invoke(
        ObjectFactoryInterface $userFactory,
        Request $request,
        EntityManagerInterface $entityManager,
        PasswordHasherFactoryInterface $passwordHasher
    ): Response
    {
        /** @var User $user */
        $user = $userFactory->createNew();
        $form = $this->createForm(UserRegistrationType::class, $user);

        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            $passHasher = $passwordHasher->getPasswordHasher($user);
            $user->setPassword($passHasher->hash($form->get('clearpassword')->getData()));

            $entityManager->persist($user);
            $entityManager->flush();

            return $this->redirectToRoute('app_login');
        }

        return $this->render('platform/user/register.html.twig', ['form'=>$form->createView()]);
    }
}

Pour recevoir la bonne fabrique dans l'argument $userFactory, j'ai également configuré Symfony dans le fichier config/services.yaml pour injecter le bon service.

services:
    _defaults:
        bind:
            $userFactory: '@App\Platform\Factory\UserFactory'

J'aurai pu mettre directement le nom de la classe de la fabrique. Mais je souhaite laisser une possibilité d'étendre la fabrique pour un client spécifique grâce à la substitution de Linskov.

Conclusion

La mise en place de cette façon de travailler demande de la rigueur et des efforts pour que tout fonctionnent. La rigueur dans le travail est nécessaire pour éviter de mélanger les fonctionnalités entre les sites.

Un petit tour des problème rencontré : J'ai été très souvent gêné par le cache lors du changement de client_id. La génération des migrations sont également plus complexe lorsqu'il faut gérer les migrations pour la plateforme et pour chaque client.

Les côtés positifs: Tout le code est dans le même projet. Le partage de code et de template est très simple et rapide. C'est le but recherché.

Malgré tout, j'ai eu un peu l'impression de refaire le RessourceBundle de Sylius pour la gestion des entités de la Platform.

Nous verront bientôt comment j'ai pu améliorer l'expérience développeur sur ce projet.

Author avatar
Jean-Baptiste Nahan

Consultant Expert Web, j'aide les entreprises ayant des difficultés avec leur projet Web (PHP, Symfony, Sylius).

@jbnahan69 | Macintoshplus | Linkedin | JB Dev Labs