Gérer des utilisateurs sans FosUserBundle 4ème partie

Nous voici arrivés à l’avant-dernière partie qui va demander pas mal de boulot, à savoir la demande de reset de mot de passe s’il a été oublié. Quelques rajouts vont être faits à différents endroits, mais vous allez le voir, aucune modification de code existant, seulement des rajouts et extensions. Le prochain et dernier post traitera du reset en lui-même du mot de passe.

La demande de reset de mot de passe

L’entité RequestPassword

<?php

namespace AppBundle\User\Password;

use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Validator\Constraints as Assert;

class RequestPassword
{
    /**
     *
     * @var UserInterface
     */
    private $user;

    /**
     * @Assert\NotBlank
     */
    private $identifier;

    public function getIdentifier()
    {
        return $this->identifier;
    }

    public function setIdentifier($identifier)
    {
        $this->identifier = $identifier;
    }

    function getUser()
    {
        return $this->user;
    }

    function setUser(UserInterface $user)
    {
        $this->user = $user;
    }
}

Lorsque nous allons faire une demande de reset du mot de passe via un formulaire, nous pourrons y renseigner l’email ou le username, d’où l’appellation générique identifier.

Mise à jour de l’entité User

A présent que nous allons pouvoir faire une demande de mot de passe oublié, il nous faut un token et un champ vérifiant qu’une demande n’est pas déjà en cours:

/* AppBundle/Entity/User.php */
/**
     * @ORM\Column(type="string", length=255, nullable=true)
     */
    private $confirmationToken = null;

    /**
     * @var bool
     *
     * @ORM\Column(type="boolean")
     */
    private $isAlreadyRequested = false;


public function getConfirmationToken()
    {
        return $this->confirmationToken;
    }

    public function getIsAlreadyRequested()
    {
        return $this->isAlreadyRequested;
    }

    public function setConfirmationToken($confirmationToken)
    {
        $this->confirmationToken = $confirmationToken;
    }

    public function setIsAlreadyRequested($isAlreadyRequested)
    {
        $this->isAlreadyRequested = $isAlreadyRequested;
    }

 

Le Repository de User

Avant d’aller plus loin, comme nous allons utiliser un event au niveau du formulaire de demande de mot de passe oublié pour vérifier que le username ou l’email rentrés correspondent bien à un user en base, il faut que nous implémentions ces méthodes au niveau du UserRepository qui n’existe pas encore.

  1. Mettons à jour l’entité User en lui indiquant l’endroit de notre repository
  2. Mettons en place cette fonction dans le UserRepository
  3. Permettons au UserManager d’appeler cette méthode

1. L’entité User

@ORM\Entity(repositoryClass="AppBundle\Repository\UserRepository")

2. Le UserRepository

<?php

namespace AppBundle\Repository;

use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;

/**
 * UserRepository
 */
class UserRepository extends EntityRepository
{

    /**
     * @param string $alias
     * @return \Doctrine\ORM\QueryBuilder
     */
    private function getBuilder($alias = 'u')
    {
        return $this->createQueryBuilder($alias);
    }

    public function getUserByIdentifierQueryBuilder(QueryBuilder &$qb, $identifier)
    {
        $qb->andWhere(
                $qb->expr()->orX(
                    'u.username = :identifier', 'u.email = :identifier'
                )
            )
            ->setParameter('identifier', $identifier);

        return $this;
    }

    public function getUserByEmailOrUsername($identifier)
    {
        $qb = $this->getBuilder();
        $this->getUserByIdentifierQueryBuilder($qb, $identifier);

        return $qb->getQuery()->getOneOrNullResult();
    }
}

 

3. Le UserManager

Pour faire appel au UserRepository, il doit avoir cette nouvelle dépendance, que l’on va aussi mettre à jour dans le fichier services.xml

<?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 AppBundle\Repository\UserRepository;
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;

    /**
     *
     * @var UserRepository
     */
    protected $userRepository;

    /**
     * @param ObjectManager                 $manager
     * @param EncoderFactoryInterface       $encoderFactory
     * @param EventDispatcherInterface      $dispatcher
     * @param UserPasswordEncoderInterface  $encoder
     * @param UserRepository                $userRepository
     */
    public function __construct(
    ObjectManager $manager, EncoderFactoryInterface $encoderFactory, EventDispatcherInterface $dispatcher,
    UserPasswordEncoderInterface $encoder, UserRepository $userRepository
    )
    {
        $this->objectManager = $manager;
        $this->encoderFactory = $encoderFactory;
        $this->dispatcher = $dispatcher;
        $this->encoder = $encoder;
        $this->userRepository = $userRepository;
    }

    /**
     * @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);
    }

    public function getUserByIdentifier($identifier)
    {
        return $this->userRepository->getUserByEmailOrUsername($identifier);
    }
}

 

Le fichier services.xml

<parameter key="app.repository_user.class">AppBundle\Repository\UserRepository</parameter>

<service id="app.repository.user"
                 class="%app.repository_user.class%"
                 factory-service="doctrine"
                 factory-method="getRepository">
    <argument>AppBundle\Entity\User</argument>
</service>

<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" />
    <argument type="service" id="app.repository.user" />
</service>

Le formType RequestPasswordType

<?php

namespace AppBundle\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use AppBundle\User\Manager\UserManagerInterface;
use AppBundle\User\Password\RequestPassword;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;

class RequestPasswordType 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('identifier');

        $builder->addEventListener(
            FormEvents::POST_SUBMIT,
            function (FormEvent $event) {
            $data = $event->getData();

            if (!$data instanceof RequestPassword) {
                throw new \RuntimeException('RequestPassword instance required.');
            }
            $identifier = $data->getIdentifier();
            $form = $event->getForm();

            if (!$identifier || count($form->getErrors(true))) {
                return;
            }

            $user = $this->handler->getUserByIdentifier($identifier);

            if (null == $user) {
                $form->get('identifier')->addError(new FormError('User not present in our database'));
                return;
            } else {
                $data->setUser($user);

                if ($user->getIsAlreadyRequested() && null != $user->getConfirmationToken()) {
                    $form->get('identifier')->addError(new FormError('You already requested for a new password'));
                    return;
                }
            }
        }
        );
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => 'AppBundle\User\Password\RequestPassword',
        ]);
    }

    public function getName()
    {
        return 'request_password_form';
    }
}

Au moins, grâce à cela, nous saurons si nous passons le $form->isValid() afin de pouvoir lancer la suite des évenements, c’est à dire envoyer le mail via le UserManager.

Ce formulaire a une dépendance, déclarons-le comme un service:

<parameter key="form.type.request_password.class">AppBundle\Form\Type\RequestPasswordType</parameter>

<service id="form.type.request_password"
                 class="%form.type.request_password.class%">
    <argument type="service" id="app.user.manager" />
    <tag name="form.type" alias="request_password_form" />
</service>

 

Le RequestPasswordFormHandler

Inutile de perdre du temps, vous avez l’habitude à présent, j’utilise systématiquement un handler pour traiter request & soumission de formulaire, et aussi pour déclencher une action au niveau d’une dépendance. Ici, comme d’habitude, nous allons passer comme dépendance le UserManager. Voici donc le handler et la mise à jour du fichier services.xml:

<?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 RequestPasswordFormHandler 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->sendRequestPassword($form->getData()->getUser());

        return true;
    }
}

 

Et le fichier services.xml:

<parameter key="app.request_password.handler.class">AppBundle\User\Password\RequestPasswordFormHandler</parameter>

<service id="app.request_password.handler"
                 class="%app.request_password.handler.class%">
    <argument type="service" id="app.user.manager" />
</service>

Le RequestPasswordController

Avant d’implémenter la fonction sendRequestPassword du UserManager, mettons tout de suite en place le contrôleur:

<?php

namespace AppBundle\Controller;

use AppBundle\User\Password\RequestPassword;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;

/**
 * @Route("/")
 */
class RequestPasswordController extends Controller
{
    /**
     * @Route("/request-password", name="request_password")
     * @Method("GET|POST")
     */
    public function requestPasswordAction(Request $request)
    {
        $form = $this->createForm('request_password_form', new RequestPassword());

        if ($this->getRequestPasswordFormHandler()->handle($form, $request)) {
            $this->addFlash('success', 'A mail has been sent to your mailbox to reset your password.');
            return $this->redirect($this->generateUrl('homepage'));
        }

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

    /**
     * @return \AppBundle\Form\Handler\FormHandlerInterface
     */
    protected function getRequestPasswordFormHandler()
    {
        return $this->container->get('app.request_password.handler');
    }
}

Rajout de la fonction sendRequestPassword dans le UserManager

Nous allons rajouter cette fonction, mais attention, le UserManager… sert à gérer les utilisateurs, à setter des valeurs, mettre à jour la base, faire une recherche dans un repository. Il n’a pas vocation à envoyer un mail. Non, pour cela, nous allons gaiement créer un autre listener qui se chargera de tout faire:

public function sendRequestPassword($user)
    {
        $this->dispatcher->dispatch(
            AppEvents::NEW_PASSWORD_REQUESTED, new UserDataEvent($user)
        );
    }

 

Le listener

Il faut effectuer plusieurs actions ici:

  1. Déclarer la clef de ce listener dans notre fichier AppEvents
  2. Créer le listener
  3. Le déclarer comme un service

 

1. Déclarer la clef du listener

<?php

namespace AppBundle;

final class AppEvents
{
    const NEW_ACCOUNT_CREATED = 'app.new_account_created';
    const NEW_PASSWORD_REQUESTED = 'app.new_password_requested';
}

 

2. Créer le listener

Beaucoup de dépendances pour ce listener qui va devoir créer le mail, définir une route avec un token, générer ce token, mettre à jour la base de données… Bien sûr, ce sont ses diverses dépendances qui vont faire le boulot.

<?php

namespace AppBundle\EventListener;

use AppBundle\Event\UserDataEvent;
use Twig_Environment;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Csrf\TokenGenerator\TokenGeneratorInterface;
use AppBundle\User\Manager\UserManagerInterface;

class SendRequestPasswordMailListener
{
    /**
     * @var \Swift_Mailer
     */
    protected $mailer;

    /**
     * @var \Twig_Environment
     */
    protected $templating;

    /**
     *
     * @var RouterInterface $router
     */
    protected $router;

    /**
     *
     * @var TokenGeneratorInterface $tokenGenerator
     */
    protected $tokenGenerator;

    /**
     * @var UserManagerInterface $userManager
     */
    protected $userManager;

    /**
     * @var array
     */
    protected $template;

    /**
     * @var string $from
     */
    protected $from;

    /**
     * @param \Swift_Mailer $mailer
     * @param Twig_Environment $templating
     * @param $template
     * @param $from
     */
    public function __construct(\Swift_Mailer $mailer, Twig_Environment $templating, RouterInterface $router,
                                TokenGeneratorInterface $tokenGenerator, UserManagerInterface $userManager, $template,
                                $from)
    {
        $this->mailer = $mailer;
        $this->templating = $templating;
        $this->router = $router;
        $this->tokenGenerator = $tokenGenerator;
        $this->userManager = $userManager;
        $this->template = $template;
        $this->from = $from;
    }

    /**
     * @param UserDataEvent $event
     */
    public function onRequestedPassword(UserDataEvent $event)
    {
        $user = $event->getUser();
        $token = $this->tokenGenerator->generateToken();
        $this->userManager->updateConfirmationTokenUser($user, $token);

        $message = \Swift_Message::newInstance()
            ->setCharset('UTF-8')
            ->setSubject($this->templating->loadTemplate($this->template)->renderBlock('subject', []))
            ->setFrom($this->from)
            ->setTo($user->getEmail())
            ->setBody($this->templating->loadTemplate($this->template)->renderBlock('body',
                [
                'username' => $user->getUsername(),
                'request_link' => $this->router->generate('reset_password',
                    ['token' => $token], true)
            ])
        );

        $this->mailer->send($message);
    }
}

Ici, comme je sais déjà que j’ai bien un user en base dont le username ou l’email existent, je n’ai pas besoin de faire de vérification et je demande au UserManager de mettre à jour ce user (nous allons mettre en place la fonction juste après).

Je génère un token avec un service de Symfony qui est le security.csrf.token_generator et qui fait appel à une fonction bien particulière, UriSafeTokenGenerator.

Je fais aussi appel au routeur pour généner ma route (qui n’existe pas encore).

3. Déclarer le listener comme service

<parameter key="app.send_request_password_mail_listener.class">AppBundle\EventListener\SendRequestPasswordMailListener</parameter>

<service id="app.send_request_password_mail_listener"
                 class="%app.send_request_password_mail_listener.class%">
            <argument type="service" id="mailer"/>
            <argument type="service" id="twig" />
            <argument type="service" id="router" />
            <argument type="service" id="security.csrf.token_generator" />
            <argument type="service" id="app.user.manager" />
            <argument>AppBundle:Mail:request_password_mail.html.twig</argument>
            <argument>%mail_from%</argument>
            <tag name="kernel.event_listener" event="app.new_password_requested" method="onRequestedPassword" />
</service>

Mise à jour du UserManager

public function updateConfirmationTokenUser(UserInterface $user, $token) {
        $user->setConfirmationToken($token);
        $user->setIsAlreadyRequested(true);
        $this->objectManager->flush();
}

Mise en place du mail

{# src/AppBundle/Resources/views/Mail/request_password_mail.html.twig #}
{% block subject %}Password Request{% endblock %}

{% block body %}
Hello {{ username }},

You have requested a brand new password. Click to this <a href="{{ request_link }}">link</a> to reset your password.
{% endblock %}

Et enfin le template de demande de reset mot de passe

{# app/Resources/views/user/request-password.html.twig #}
{% extends 'base.html.twig' %}

{% form_theme form "form_table_layout.html.twig" %}

{% block title 'Request password' %}

{% block main %}
    <h1>Request password</h1>
    {{ form_start(form) }}
        {{ form_widget(form) }}
        <button type="submit" class="btn btn-lg btn-primary btn-block">Send request</button>
    {{ form_end(form) }}
{% endblock %}

 

N’oubliez pas de mettre de lien au niveau du formulaire du login:

<a href="{{ path('request_password') }}">forgot password?</a>

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

Laisser un commentaire

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

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.