Ici, nous allons mettre en place le changement de mot de passe. Pour cela, il va falloir faire évoluer nos services, créer un nouveau formulaire, mettre en place un event lors de la soumission du formulaire, un message flash… c’est parti!
Le changement de mot de passe
Commençons par créer notre entité ChangePassword.php.
<?php namespace AppBundle\User\Password; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Validator\Constraints as Assert; /** * @Assert\Expression( * expression="this.getOldPassword() !== this.getNewPassword()", * message="Old password and new password must not be identical." * ) */ class ChangePassword { /** * * @var UserInterface */ private $user; /** * @Assert\NotBlank * @Assert\Length(min=8) */ private $oldPassword; /** * @Assert\NotBlank * @Assert\Length(min=8) */ private $newPassword; public function __construct(UserInterface $user) { $this->user = $user; } public function getUser() { return $this->user; } public function getSalt() { return $this->user->getSalt(); } public function getOldPassword() { return $this->oldPassword; } public function setOldPassword($oldPassword) { $this->oldPassword = $oldPassword; } public function getNewPassword() { return $this->newPassword; } public function setNewPassword($newPassword) { $this->newPassword = $newPassword; } }
Le formulaire
<?php namespace AppBundle\Form\Type; use AppBundle\User\Password\ChangePassword; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormError; use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvents; use Symfony\Component\OptionsResolver\OptionsResolver; class ChangePasswordType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { $builder ->add('oldPassword', 'password', ['required' => false ]) ->add('newPassword', 'repeated', [ 'required' => false, 'type' => 'password', 'first_options' => [ 'label' => 'New Password', ], 'second_options' => [ 'label' => 'Confirmation', ] ]) ; } public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'data_class' => 'AppBundle\User\Password\ChangePassword', ]); } public function getName() { return 'change_password'; } }
Le ChangePasswordController
<?php namespace AppBundle\Controller; use AppBundle\User\Password\ChangePassword; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\Component\HttpFoundation\Request; /** * @Route("/account") */ class ChangePasswordController extends Controller { /** * @Route("/change-password", name="change_password") * @Method("GET|POST") */ public function changePasswordAction(Request $request) { $data = new ChangePassword($this->getUser()); $form = $this->createForm(new ChangePasswordType(), $data); $form->handleRequest($request); if ($form->isValid()) { $user = $data->getUser(); $user->setPlainPassword($data->getNewPassword()); $factory = $this->container->get('security.encoder_factory'); $encoder = $factory->getEncoder($user); $user->encodePassword($encoder); $manager = $this->getDoctrine()->getManager(); $manager->flush(); $this->addFlash('success', 'Password has been changed successfully.'); return $this->redirectToRoute('user_dashboard'); } return $this->render('user/password.html.twig', [ 'form' => $form->createView(), ]); } }
Le template
{% extends 'base.html.twig' %} {% form_theme form "form_table_layout.html.twig" %} {% block title 'Change password' %} {% block main %} <h1>Change your password</h1> {{ form_start(form) }} {{ form_widget(form) }} <button type="submit" class="btn btn-lg btn-primary btn-block">Submit</button> {{ form_end(form) }} {% endblock %}
Rajoutons le message flash de succès au niveau du template du dashboard:
{% for flashMessage in app.session.flashbag.get('success') %} <div class="flash-notice"> {{ flashMessage }} </div> {% endfor %}
Formidable, ça marche! Mais… ce n’est ni maintenable, ni codé en SOLID, ni même propre au final, quand on regarde notre contrôleur.
Quels sont les problèmes que nous avons ici:
- Pas de vérification que l’ancien mot de passe est correct
- Nous sommes ici fortement couplé à User, comme précédemment avec le formulaire d’inscription avant que nous ne créions un service utilisant le UserInterface
- Un contrôleur énorme qui fait beaucoup trop de choses: récupérer les valeurs, setter le plainPassword, encoder le nouveau mot de passe, flusher…
On va commencer à régler les problèmes un par un pour avoir un code qui sera fortement découplé.
Déléguer des tâches au UserManager
Nous allons définir deux fonctions ici:
- updateCredentials
- isPasswordValid
Pour cela, il nous faut passer un nouveau paramètre, UserPasswordEncoderInterface, pour faire appel à sa fonction isValidPassword (qui vérifie si l’ancien mot de passe de l’utilisateur est correct)
Faisons donc d’abord évoluer notre fichier services.xml:
<service id="app.user.manager" class="%app.user.manager.class%"> <argument type="service" id="doctrine.orm.entity_manager" /> <argument type="service" id="security.encoder_factory" /> <argument type="service" id="event_dispatcher" /> <argument type="service" id="security.password_encoder" /> </service>
puis notre UserManager:
<?php namespace AppBundle\User\Manager; use Doctrine\Common\Persistence\ObjectManager; use AppBundle\AppEvents; use AppBundle\Entity\UserInterface; use AppBundle\User\Manager\UserManagerInterface; use AppBundle\Event\UserDataEvent; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface; use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; class UserManager implements UserManagerInterface { /** * @var ObjectManager */ protected $objectManager; /** * @var EncoderFactoryInterface */ protected $encoderFactory; /** * @var EventDispatcherInterface */ protected $dispatcher; /** * @var UserPasswordEncoderInterface */ protected $encoder; /** * @param ObjectManager $manager * @param EncoderFactoryInterface $encoderFactory * @param EventDispatcherInterface $dispatcher * @param UserPasswordEncoderInterface $encoder */ public function __construct( ObjectManager $manager, EncoderFactoryInterface $encoderFactory, EventDispatcherInterface $dispatcher, UserPasswordEncoderInterface $encoder ) { $this->objectManager = $manager; $this->encoderFactory = $encoderFactory; $this->dispatcher = $dispatcher; $this->encoder = $encoder; } /** * @param UserInterface $user * * @return UserInterface */ public function createUser(UserInterface $user) { $user->encodePassword($this->encoderFactory->getEncoder($user)); $this->persistAndFlushUser($user); $this->dispatcher->dispatch( AppEvents::NEW_ACCOUNT_CREATED, new UserDataEvent($user) ); } /** * @param UserInterface $user */ private function persistAndFlushUser(UserInterface $user) { $this->objectManager->persist($user); $this->objectManager->flush(); } public function updateCredentials(UserInterface $user, $newPassword) { $user->setPlainPassword($newPassword); $user->encodePassword($this->encoderFactory->getEncoder($user)); $this->objectManager->flush(); } public function isPasswordValid(UserInterface $user, $plainPassword) { return $this->encoder->isPasswordValid($user, $plainPassword); } }
Mise en place d’un event au niveau du ChangePasswordType
<?php namespace AppBundle\Form\Type; use AppBundle\User\Password\ChangePassword; use AppBundle\User\Manager\UserManagerInterface; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormError; use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvents; use Symfony\Component\OptionsResolver\OptionsResolverInterface; class ChangePasswordType extends AbstractType { /** * * @var UserManagerInterface $handler */ private $handler; /** * @param UserManagerInterface $userManager */ public function __construct(UserManagerInterface $userManager) { $this->handler = $userManager; } public function buildForm(FormBuilderInterface $builder, array $options) { $builder ->add('oldPassword', 'password', ['required' => false ]) ->add('newPassword', 'repeated', [ 'required' => false, 'type' => 'password', 'first_options' => [ 'label' => 'New Password', ], 'second_options' => [ 'label' => 'Confirmation', ] ]) ; $builder->addEventListener( FormEvents::POST_SUBMIT, function (FormEvent $event) { $data = $event->getData(); if (!$data instanceof ChangePassword) { throw new \RuntimeException('ChangePassword instance required.'); } $oldPassword = $data->getOldPassword(); $newPassword = $data->getNewPassword(); $form = $event->getForm(); if (!$oldPassword || !$newPassword || count($form->getErrors(true))) { return; } $user = $data->getUser(); if (!$this->handler->isPasswordValid($user, $oldPassword)) { $form = $event->getForm(); $form->get('oldPassword')->addError(new FormError('Previous password is not valid.')); return; } } ); } public function setDefaultOptions(OptionsResolverInterface $resolver) { $resolver->setDefaults([ 'data_class' => 'AppBundle\User\Password\ChangePassword', ]); } public function getName() { return 'change_password'; } }
Déclaration de ChangePasswordType comme un service
Ce formulaire ayant à présent une dépendance, nous devons le déclarer comme un service:
<parameter key="form.type.change_password.class">AppBundle\Form\Type\ChangePasswordType</parameter> <service id="form.type.change_password" class="%form.type.change_password.class%"> <argument type="service" id="app.user.manager" /> <tag name="form.type" alias="change_password" /> </service>
Allègement de notre contrôleur
/** * @Route("/change-password", name="change_password") * @Method("GET|POST") */ public function changePasswordAction(Request $request) { $data = new ChangePassword($this->getUser()); $form = $this->createForm('change_password', $data); $form->handleRequest($request); if ($form->isValid()) { $handler = $this->get('app.user.manager'); $handler->updateCredentials($data->getUser(), $data->getNewPassword()); $manager = $this->getDoctrine()->getManager(); $manager->flush(); $this->addFlash('success', 'Password has been changed successfully.'); return $this->redirectToRoute('user_dashboard'); } return $this->render('user/password.html.twig', [ 'form' => $form->createView(), ]); }
C’est un peu mieux, mais nous ne voulons pas dépendre du UserManager, bien entendu. Si l’on veut un jour changer le comportement, il faudra revenir sur le contrôleur. Comme pour l’enregistrement, nous allons créer un PasswordHandler qui implémentera notre FormHandlerInterface.
Vous l’avez compris, je veux arriver à ça:
/** * @Route("/change-password", name="change_password") * @Method("GET|POST") */ public function changePasswordAction(Request $request) { $data = new ChangePassword($this->getUser()); $form = $this->createForm('change_password', $data); if ($this->getChangePasswordFormHandler()->handle($form, $request)) { $this->addFlash('success', 'Password has been changed successfully.'); return $this->redirect($this->generateUrl('user_dashboard')); } return $this->render('user/password.html.twig', [ 'form' => $form->createView(), ]); } /** * @return \AppBundle\Form\Handler\FormHandlerInterface */ protected function getChangePasswordFormHandler() { return $this->container->get('app.change_password.handler'); } }
Création du ChangePasswordFormHandler
<?php namespace AppBundle\User\Password; use AppBundle\Form\Handler\FormHandlerInterface; use AppBundle\User\Manager\UserManagerInterface; use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; class ChangePasswordFormHandler implements FormHandlerInterface { /** * * @var UserManagerInterface */ private $handler; /** * @param UserManagerInterface $userManager */ public function __construct(UserManagerInterface $userManager) { $this->handler = $userManager; } /** * @param FormInterface $form * @param Request $request * @param array $options * * @return bool */ public function handle(FormInterface $form, Request $request, array $options = null) { $form->handleRequest($request); if (!$form->isValid()) { return false; } $this->handler->updateCredentials($form->getData()->getUser(), $form->getData()->getNewPassword()); return true; } }
Mise à jour du fichier des services:
<parameter key="app.change_password.handler.class">AppBundle\User\Password\ChangePasswordFormHandler</parameter> <service id="app.change_password.handler" class="%app.change_password.handler.class%"> <argument type="service" id="app.user.manager" /> </service>
Et voilà! Nous sommes parfaitement découplé et notre code est SOLID. Si l’envie nous prend d’utiliser une autre action dans un contrôleur pour y passer un nouveau handler qui implémentera FormHandlerInterface, nous n’aurons pas à revenir sur du code existant mais à étendre le code. De même, si, par injection de dépendance, nous voulons que ce nouveau handler utilise non pas userManager mais un autre manager, si celui-ci implémente le UserManagerInterface, ça passera sans aucun souci.
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
2 comments
Bonjour Jean Pierre,
Merci pour les réponses aux questions, ta procédure est ingénieuse alors je veux toujours bien comprendre.
Là je suis coincé sur ChangePasswordFormHandler avec le constructeur qui initialise UserManagerInterface $userManager.
Un peu plus loin, dans la fonction handle, nous avons la condition avec
$this->handler->updateCredentials($form->getData()->getUser(), $form->getData()->getNewPassword());
Error /////////////////////////////////////////////////////////
updateCredentials not found in UserManagerInterface, (affichée par phpstorm et exact) ce qui m’entraine dans cette issue avec l’erreur Call to a member function isPasswordValid() on null dans le formulaire de changement de mot de passe.
Je suis un peu perdu, si tu peux m’aider.
Merci Jean Pierre.
Oui, il vaut mieux se baser sur la nouvelle version: https://github.com/jpsymfony/symfony3-generic-project-symfony3-architecture/blob/master/src/AppBundle/Entity/Manager/Interfaces/UserManagerInterface.php
A l’époque, j’avais une classe UserManagerInterface incomplète. Dans la version Symfony3, elle est complète.
Par contre, ça n’est pas lié à l’erreur du formulaire (c’est plus phpstorm qui va dire que la méthode est absente). Non, ici, c’est un souci d’injection / autowiring car symfony nous dit que le UserManager n’est pas injecté dans le constructeur du formulaire (si tu as voulu faire de l’autowiring, il faut déclarer l’alias de UserManagerInterface et dire qu’il doit injecter la classe UserManager), d’où l’erreur « Call to a member fonctionisPasswordValid() on null).
Essaie de dumper le formulaire au moment où tu l’appelles pour voir mais je parierais ma chemise que le service n’est pas injecté, ce qui provoque l’erreur.