Gestion des utilisateurs sans FosUserBundle 2ème partie

Ici, on attaque les choses sérieuses avec des events, envoi de mails, services, validateurs et surtout, découplage maximum et réutilisabilité du code.

L’inscription

Un utilisateur doit pouvoir s’inscrire. Pour ce faire, nous allons déjà commencer par créer notre entité Registration.php

Cette entité va nous servir pour le formulaire d’inscription. La fonction getUser trouvera son utilité un peu plus tard, lorsque le formulaire sera soumis.

Créer un validateur d’email et de username unique

Nous devons vérifier à l’enregistrement s’il n’existe pas en base un autre utilisateur ayant le même email ou le même username.

Cette vérification est possible dans le contrôleur, mais les validateurs de contrainte personnalisés sont faits pour ça.

Mettre à jour le fichier de services:

 

Le RegistrationType

 

Le RegisterController

Il faut à présent soumettre ce formulaire, enregistrer l’utilisateur, lancer l’event d’envoi du mail. Il n’est pas question de mettre tout cela dans le contrôleur. Car bien évidemment, ceci serait possible:

Vous voyez à présent que la fonction getUser() de Registration nous permet de créer un User et de les setter les valeurs passées en POST sans avoir à le faire dans le contrôleur.

Nous faisons faire énormément de choses à notre fonction:

  1. Traitement des infos de la requête
  2. Encodage du mot de passe
  3. Persistance et flush du User créé avec les valeurs de la request
  4. Envoi de l’email

Il va falloir éclater toutes ces responsabilités en plusieurs fonctions. En effet, que se passerait-il si nous voulions faire d’autres choses qu’envoyer un mail (par exemple loguer l’enregistrement). De même, et si nous voulions que ce soit autre chose que l’entité User qui soit sauvée? Il faudrait revenir sur le contrôleur, et ce n’est pas une solution viable, car il faut au maximum éviter de revenir sur du code déjà écrit et testé.

Occupons-nous d’abord de créer un manager de User (qui le créera, le persistera et flushera, encodera le mot de passe, etc.) ainsi que son interface.

 

Vous remarquerez que c’est UserInterface qui est passé en argument des fonctions createUser et persistAndFlushUser, et non User, ceci toujours dans le but de respecter la norme O de SOLID. Nous pouvons ici passer n’importe quelle instance d’objet d’une classe qui implémente UserInterface.

Ici, je passe déjà le dispatcher pour lancer l’événement de l’envoi d’e-mail (nous allons créer cet événement plus tard)

Du côté du services.xml

 

Le code de mon contrôleur RegisterController devient ceci:

Mais c’est loin d’être suffisant. Notre code est toujours fortement couplé avec une entité de UserInterface (et si je veux enregistrer autre chose qu’une instance de cette classe?)… et le contrôleur peut encore être allégé. Que cela ne tienne, nous allons créer un RegisterFormHandler qui implémentera une FormHandlerInterface.

 Le FormHandleInterface

Elle ne comporte qu’une seule méthode, handle, qui prend en paramètre FormInterface, une request et un tableau d’options qui peut être vide.

 Le RegisterFormHandler

Plutôt simple, celui-ci va implémenter FormHandlerInterface, traiter la requête et appeler une fonction du UserManager pour créer le user. Sa fonction handle nous renvoie un booléen pour nous dire si la requête était bien valide.

Mise à jour du services.xml

Mise à jour du RegisterController

Et là, notre registerAction s’allège encore :

Les events

Il nous reste une dernière chose à faire: créer un mail pour informer l’utilisateur que son compte a bien été créé et qu’il peut à présent se connecter sur la plateforme.

Inutile de vous dire que nous n’allons pas faire cela dans le contrôleur. C’est le userManager qui va se charger de dispatcher l’événement et nous allons dire à Symfony que lorsque cette événement est dispatché, il faut que tous ses listeners qui sont branchés dessus exécutent une action en particulier (c’est le principe du Design Pattern Observer). Pour cela, Symfony propose deux systèmes: les listeners et les subscribers. Ceci fera l’objet d’un autre article.

Créer un évent

Il s’agit simplement d’une classe concernant l’entité dont nous voulons récupérer des infos (le sujet, celui qui est observé par les listeners, en a besoin). Je vais simplement l’appeler UserDataEvent (mais vous pouvez lui donner le nom de l’événement, comme NewAccountUserEvent par exemple, mais je vais rester générique ici)

Etablir la liste des events de l’application

Ceci se faire dans un fichier et est, en ce qui me concerne, une bonne pratique pour connaître tous les events de votre application.

Dispatcher l’événement

La fonction createUser du UserManager peut à présent dispatcher l’événement:

Créer le listener d’envoi de mail

Il va prendre en argument switft_mailer, le templating de twig, un template et une adresse mail:

Vous pourriez vous demander comment la méthode onNewAccountCreated peut récupérer les informations de l’event? C’est simplement lors du dispatch: nous avons passé un second argument qui était ce fameux UserDataEvent.

Création du mail

Nous définissons ici deux blocks qui sont renseignés dans la fonction onNewAccountCreated:

 Enregistrement du listener au niveau du fichier services.xml

A présent que nous dispatchons l’événement à la création d’un utilisateur et que nous avons créé l’événement en question, il est temps de raccorder l’écouteur à celui qui l’informe par le fichier des services:

Ici, nous indiquons à Symfony  que nous avons un listener (avec le tag  kernel.event_listener), qui se déclenche à l’événement app.new_account_created, et que nous voulons exécuter la méthode onNewAccountCreated lorsqu’il est dispatché.

Ici, mail_from est un paramètre que j’ai rajouté dans parameters.yml.

Le template

 

Le code github se trouve ici: https://github.com/jpsymfony/authentication-demo
Sa version mise à jour (il faudra chercher un peu pour retrouver les classes car l’arborescence a changé) en Symfony 3.2 est ici: https://github.com/jpsymfony/symfony3-generic-project-symfony3-architecture

Rédigé par

20 comments

  1. Bonjour et merci pour ces articles de qualité !

    Je suis encore en phase d’apprentissage avec le framework Symfony (mais je pense qu’avec des outils aussi poussé on ne sort jamais de cette dite phase :P).

    Je ne comprends pas bien le concept du tout déléguer en Handler pour la partie Registration. Je connais bien les bénéfices du « diviser pour mieux régner » en POO, mais là j’avoue que je trouve cela un peu poussif.

    Notamment lorsque vous dites « Notre code est toujours fortement couplé avec une entité de User (et si je veux enregistrer autre chose qu’une instance de cette classe?)… et le contrôleur peut encore être allégé. »

    Finalement le Handler Register Form que vous faites juste après est lui aussi  » fortement couplé avec une entité de User ». C’est là que cela devient flou pour moi. Peut-être pourriez-vous me donner un cas d’utilisation en exemple ?

    En tous cas, félicitation pour vos articles très élaborés !

    1. Bonjour Jonathan,

      J’ai observé cette pratique de créer un handlerForm dans différents projets, et la première fois, cela m’a aussi interpelé.

      Mais au final, les avantages sont multiples:
      – je ne fais pas de traitement dans le contrôleur mais dans le handlerForm (en faisant appel au userManager, mais je pourrais passer d’autres dépendances qui joueraient un rôle ici)
      – j’injecte dans le service du formHandler le UserManager, et je découple donc le contrôleur du UserManager (et donc du User). Je m’explique: ce formHandler service a un UserManager injecté dans son constructeur, et ce UserManager traite une entité qui implémente l’interface UserInterface.

      Donc, quelque part, le UserManager est déjà découplé de l’entité User car il traite toute entité qui implémente l’interface UserInterface (je viens de changer cette petite donnée dans mon article, c’était une coquille)
      Mais imaginons qu’un jour, je décide de changer ce service est de lui injecter un autre UserManager qui prendra en compte un autre type d’entité User.

      Dans le cas où je n’aurai pas créé de FormHandler, je vais être obligé de revenir dans tous mes contrôleurs et de mettre quelque chose du style:
      $this->container->get(‘app.advanced_user.manager’)->createUser($form->getData()->getUser());

      Et ça m’ennuie, je ne suis pas censé revenir sur du code existant et le modifier. Mais voilà, mon UserManager traite des entités implémentant le UserInterface, et j’utilise peut-être ce code ailleurs, je ne veux pas non plus le modifier. Et mon but ici, est que ce nouveau manager puisse créer un nouveau type de User qui implémentera l’interface UserInterface, mais peut-être également une autre interface, pour lui rajouter des fonctions.

      Il me suffit que:
      – créer un UserAdvancedManager qui implémentera la UserManagerInterface
      – modifier mon service RegisterFormHandler en injectant non plus le UserManager mais le UserAdvancedManager

      Du coup, je ne modifie ni mes contrôleurs, ni mon service RegisterFormHandler (je parle au niveau de l’algorithme). Juste pour l’enregistrement dans cette partie du code, mon service RegisterFormHandler aura un UserAdvancedManager injecté qui, lui-même, créera un autre type de User (AdvancedUser, par exemple)

      Le principe d’encapsuler du code et d’injecter des services qui implémentent une interface permet de découpler le code.

      En laissant le traitement dans le contrôleur, je suis bloqué: je dois revenir à de nombreux endroits, modifier mes tests unitaires, fonctionnels, etc.

      Là, je modifie simplement l’injection de mon service (si ce service n’était utilisé qu’à un endroit bien sûr, sinon il faut créer un autre service RegisterFormHandler, mais c’est un autre débat), et par injection, le nouveau UserManager va pouvoir agir sur un autre type de User.

      1. Merci pour votre réponse très détaillée !

        J’ai été au bout de vos 5 articles, et après avoir répété plusieurs fois ce principe découplage (inscription, changement de mot de passe, mot de passe perdu,…), cela me semble déjà plus naturel.

        Je vois désormais clairement l’intérêt de tels manipulation. En effet les contrôleur ne nécessite plus de changement après cela. Il suffit de modifier ou de créer un nouveau service avec une entité implémentant l’interface UserInterface.

        Merci beaucoup pour votre aide et bonne continuation !

      2. Je me permets de revenir sur le sujet après 2 mois d’avancement sur mon projet.

        Vos articles ont été un réel déblocage pour moi. Je ne conçois plus mon code sans délégation via des services, toutes mes entités utilisent un Manager qui leurs est propre, les validations de formulaire passent toutes via un Handler interagissant avec les Managers, les contrôleurs sont très allégés, les formulaires utilisent les Event lorsque cela est nécessaire, …

        Bref, la qualité de mon code a fait un bond en avant ! Bien heureusement, j’avais pas mal de notions en PHP brut et POO, mais avec de la persévérance pour bien comprendre vos articles, je perçois le framework d’une toute autre manière !

        Tous cela pour vous remercier d’avoir partagé votre savoir aussi proprement, rare sont les tutos/articles aussi bien développé et aboutis.

        1. Bonjour Jonathan,

          J’en suis très heureux! Je suis actuellement en mission avec des dev seniors et j’ai vu qu’ils ont également la même pratique: handler de formulaire, qui a un manager d’entité injecté, qui lui-même a un repository de cette entité qui lui est injecté. Ca permet de bien découpler le code et d’éviter de nombreux if elseif.

          Oui, les events, c’est vraiment quelque chose de super, je m’en suis encore servi tout à l’heure pour un tuto que je prépare sur l’upload de fichier facultatif (car une fois que l’on a imbriqué un formulaire d’upload dans un autre formulaire, Symfony veut obligatoirement qu’un upload soit fait, et je me suis un peu arraché les cheveux pour trouver comment faire, mais la solution s’est en partie trouvée dans l’event POST_SUBMIT)

          Pour le moment, le temps me manque, mais je viens de mettre en place un repo (https://github.com/jpsymfony/symfony2-generic-project) dans lequel j’ai mis l’intégralité de mes connaissances symfony2 actuelles.

          J’en ai profité pour épurer le CoreBundle pour qu’il puisse être repris tel quel dans n’importe quel projet. Il ne contient que des classes génériques.

          J’ai tenté une expérience intéressante: gérer le crud avec le design pattern strategy, et ma foi, j’aime bien le principe, je pense que je vais le garder à présent.

          Dès que je vais avoir plus de temps, j’écrirai d’autres articles et je prendrai ce repo comme exemple en permanence car il contient vraiment tout ce que je pourrai expliquer sur ce site dans les prochains mois.

          Merci encore pour votre message et longue vie de développeur!

  2. Bonjour,

    J’aurai une question sur le fait de créer une classe Registration , quelle est l’avantage de passer par cette classe , pourquoi ne pas directement utiliser le formulaire de l’utilisateur ?

    1. Bonjour,

      Cela permet de créer un formulaire (le RegistrationType) et d’appeler le validateur de Symfony dessus. A moins que je ne comprenne mal votre question concernant le formulaire de l’utilisateur.

      En règle générale, dès que je créé un formType, j’y associe une classe (même si cela n’est pas obligatoire) afin de pouvoir rajouter des règles de validation directement au niveau de la classe plutôt que de valider par des formEvents.

      1. En fait ce que je ne comprenais pas c’est pourquoi recréer une classe Registration et son formulaire pour créer un utilisateur plutôt que de passer directement par le UserType, mais je suppose que c’est pour faire transiter les données et ne pas taper directement sur la classe User ?
        Aussi, je tenais à avoir votre avis, je m’aide de votre tuto afin de faire ma gestion d’utilisateur mais pour une API Rest.
        Du coup, à la création d’un utilisateur par une application tiers quel est l’information à renvoyer ? Est ce que je dois comme pour la création de ressources en générale renvoyer le nouvel objet avec un id, sans bien sur le mdp même haché?

        1. Ca y est, je comprends! En fait, la raison ici est simple, c’est parce que je passe par un formulaire graphique. Il est vrai que j’aurais pu faire une page html, récupérer les infos de la requête et persister pour créer l’utilisateur, mais je me serais privé de la validation des champs (simple avec des validateurs du style Length ou complexe avec mes contraintes de validation personnalisées comme UniqueAttribute). J’aurais dû aussi réécrire toute la logique de comparaison des deux passwords pour vérifier leur égalité.

          Cela dit, même si les données étaient envoyées en json par API, je serais passé par un formulaire pour l’hydrater et valider la donnée.

          En revanche, s’il n’y a aucun formEvent dans le formulaire (comme c’est le cas dans registration) et aucune spécificité (comme deux mots de passe à taper), en effet, je pense qu’il est même préférable de passer par le validateur de Symfony pour valider l’objet puis le persister/flusher.

          Pour votre question, pour moi, il n’est pas « grave » de passer le mot de passe en clair si et seulement si vous avez un paramètre de sécurité (un token oauth, un token jwt, un basic auth) passé dans le header de votre request avec la clef Authorization. Sans cette clef, impossible de faire la requête.

          Ensuite, si vraiment on veut sécuriser les informations qui transitent, oui, on peut envoyer le mot de passe mais avec une clef de hash (et l’application côté back se chargera de la décrypter en utilisant la clef de hash connue afin de persister le mot de passe en bdd). Et l’API renverra en retour toutes les informations du user, sans son mot de passe bien sûr puisqu’il a été défini lors de la création.

          1. Trse bien merci, je me permets de vous poser une autre question qui me pertube, désolé pour les nombres de questions posés mais je trouve votre méthodologie vraiment excellente et j’aimerai arriver à comprendre le mécanisme.
            Vous injectez toujours UserInterface au lieu de l’entité user ce que je comprends bien, mais par la suite vous faites des appels sur ce UserInterface tels que getEmail, ou getPlainPassword, qui pourtant ne sont pas des méthodes implémentés aurai je loupé une étape ?
            Aussi si je veux effectuer le crud sur une entité, si je comprends bien je dois d’abord rajouter mes 4 methodes telles quel createUser, getUser, UpdateUser et deleteUser au UserManagerInterface, comme ça je l’implémente dans mon UserManager.
            Mais du coup comment serait le FormHandler qui implemente le formHandlerInterface puisque dans ce tuto le handle nous sert que pour la création?

          2. En fait, c’est un manque de rigueur de l’époque où j’ai fait ce tuto. En effet, même si les méthodes getMail par exemple sont appelées dans les templates et pas directement dans le code, il faut les implémenter dans l’interface UserInterface. Je viens de le rajouter dans la version en Symfony3 (cf bas de l’article pour le lien vers la nouvelle version)

            Oui, en effet, pour effectuer un crud, il faut rajouter les 4 méthodes createUser, UpdateUser etc au UserManagerInterface (ce qui permettra, si vous en injectez un autre qui l’implémente un jour de bénéficier des mêmes méthodes) et appeler ces méthodes dans le formHandler qui aurait alors plusieurs méthodes.

            Mais en passant surtout par le formHandler, j’avoue que j’ai voulu m’amuser à faire un crud avec le design pattern strategie, juste pour m’amuser et parce que l’architecte avec lequel je travaillais m’avait proposé d’avoir une seule méthode de contrôleur pour la création ET la mise à jour.

            Pour l’entité acteur, par exemple, cela a donné ça:
            Le contrôleur avec les méthodes newEdit et delete: https://github.com/jpsymfony/symfony3-generic-project-symfony3-architecture/blob/master/src/AppBundle/Controller/ActorController.php

            L’actorFormHandler qui a comme injection deux classes de stratégies: celle de la création et celle de la mise à jour:
            https://github.com/jpsymfony/symfony3-generic-project-symfony3-architecture/blob/master/src/AppBundle/Form/Handler/Actor/ActorFormHandler.php
            https://github.com/jpsymfony/symfony3-generic-project-symfony3-architecture/blob/master/src/AppBundle/Form/Handler/Actor/NewActorFormHandlerStrategy.php
            https://github.com/jpsymfony/symfony3-generic-project-symfony3-architecture/blob/master/src/AppBundle/Form/Handler/Actor/UpdateActorFormHandlerStrategy.php

            Bon, c’est clair que pour un simple crud, c’est un peu compliqué, mais on voulait tenter l’expérience d’utiliser ce design pattern pour le crud pour déplacer toute la logique des contrôleurs dans les deux classes stratégies qui peuvent ensuite faire des traitements distincts très clairs sans avoir de if/else.

            Pour la classe User, dans ce tuto, c’est vrai que je suis passé par des formHandlers différents pour la création, le changement de mot de passe, la demande de reset de mot de passe… En fait, si je m’en réfère à ma mission actuelle, nous avons un formHandler qui a une méthode handle pour faire des GET, une méthode create pour le POST, une méthode replace pour le PUT, update pour le PATCH et une méthode delete pour le DELETE.
            Les méthodes create, update et replace appellent la méthode processForm d’un AbstractFormHandler qui va créer le formulaire et le soumettre.
            La méthode delete, quant à elle, appelle la méthode delete de l’abstractFormHandler. Ainsi, nous avons mutualisé la majorité du code dans la classe abstraite.

  3. Bonjour Jean Pierre
    Au niveau de notre entité User, on a dejà ce use:
    use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
    Je voudrais savoir pourquoi un class UniqueAttributeValidator à partir du moment que nous sommes rassurés du fait que les entités entrées vérifies la contrainte d’unicité.
    Merci de m’éclairer et en toute sincérité ce blog est très instructif.
    Bonne continuation

    1. Bonjour!

      En fait, il est vrai qu’il existe la contrainte de classe UniqueEntity, mais on ne peut définir qu’une valeur unique (et si on choisit d’en définir plusieurs, elles sont prises comme un couple d’unicité et non séparément comme je le désirais pour afficher le message d’erreur sous chaque champs plutôt que sous le premier champ). En gros, cela veut dire qu’ici, si j’avais une contrainte de classe, ce serait le couple username/email qui devrait être unique.

      Si j’ai fait ce validateur, c’était principalement pour le message d’erreur mais aussi parce que ma contrainte porte sur la classe User et non sur la classe Registration (qui n’existe pas en base de données). Avec une contrainte de classe, l’erreur aurait portée sur la classe Registration sur le premier champs et doctrine m’aurait dit qu’il n’y a pas de table registration dans laquelle aller chercher. Par contre, si j’avais une entité User qui existe en base, oui, c’est UniqueEntity qu’il aurait fallu utiliser.

  4. Bonjour Jean Pierre je viens de lire juste au dessus, tu pourras supprimer les posts pour ne pas revenir sur les même questions.
    Toutes les excuses et encore Merci.

  5. Bonsoir Jean Pierre,

    Finalement, je vais avoir besoin de ton aide sur ce point.
    Si possible de m’éclairer sur une erreur.
    Nous utilisons un formulaire d’enregistrement Registration qui n’est pas basé sur le formulaire de l’ entité User.
    Registration qui n’a pas de Id, et User qui en a un.
    Lorsque j’essaie de m’enregistrer, il me semble qu’il y a problème avec le Id de Registration.
    //////
    SQLSTATE[42703]: Undefined column: 7 ERREUR: la colonne t0.id n’existe pas
    LINE 1: SELECT t0.id AS id_1, t0.username AS username_2, t0.password…
    ////////
    Avec une exception du genre
    vendor\doctrine\dbal\lib\Doctrine\DBAL\Driver\AbstractPostgreSQLDriver.php (line 80)
    case ‘42703’: return new Exception\InvalidFieldNameException($message, $exception);

    J’utilise le moteur de base de donnée PostgreSQL.

    Merci pour ces tutos de grande qualité.

    1. Mmm… est-ce que vous réutilisez une classe Registration avec un formulaire comme moi (donc couplé à l’entité Registration)?

      Car normalement, avec le $form->handleRequest(), on peut récupérer le user pour le sauver. C’est étrange que les colonnes n’existent pas… avez-vous fait un doctrine:schema:update –force?

  6. Décidément c’est Ok

    « user » is a reserved keyword in postgreSQL, you should change your table name to something else, or add « ` » (backtick) symbol
    Encore Merci

  7. Bonsoir Jean Pierre,

    Effectivement j’aborde la même manière de structurer l’application, parce que cela permet d’avoir une maitrise de l’évolution de l’application.
    J’ai changé les répertoires à mon niveau mais là je suis stoppé net ici:
    ======>>>>> src/Entity/User/RegistrationFormHandler
    public function handle(FormInterface $form, Request $request, array $options = null)
    {
    $form->handleRequest($request);
    if (!$form->isValid()) {
    return false;
    }
    $this->handler->createUser($form->getData()->getUser());
    return true;
    }
    ////////////Errors ///////
    Cannot check if an unsubmitted form is valid. Call Form::isSubmitted() before Form::isValid().

    ======>>>>>Lorsque je mets

    if ($form->isSubmitted() && $form->isValid()) {
    return false;
    }
    $this->handler->createUser($form->getData()->getUser());
    return true;
    /////////Errors////
    An exception occurred while executing ‘INSERT INTO user_all (id, username, password, email, roles, is_active) VALUES (?, ?, ?, ?, ?, ?)’ with params [34, null, null, null, « [] », 1]:

    J’avoue que je cherche…si tu as une idée…grand merci pour le partage de la stratégie de structuration de s applications.

    Bonne continuation

    1. Désolé pour le temps de réponse, j’étais pris par le cnam. Je pense qu’il manque un point d’exclamation:

      if ($form->isSubmitted() && !$form->isValid()) au lieu de if ($form->isSubmitted() && $form->isValid()).

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *