Mini série: réinitialisation du mot de passe (partie 4/5)

publié le 03/04/2018

symfony 4 réinitialisation mot de passe

Création d'un espace membre 4ème épisode. L'enjeu de cet article est d'implémenter la réinitialisation du mot de passe. En effet, qui parmi nous n'a pas déjà oublié son password? Un espace membre sans une telle fonctionnalité ne serait pas complet. On s'y consacre donc aujourd'hui.


Partie 4: la réinialisation du mot de passe

Préambule

Pour commencer, j'aimerais insister sur le fait que cet article fait partie d'une série sur la création d'un espace membre. Qui dit 4ème épisode implique que du code ait déjà été produit. Si vous envisagez d'intégrer les classes qui suivront dans votre projet existant, il y aura peut être des adaptations à faire.

Récapitulons l'objet de cet article et ses implications. Il est prévu d'intégrer la fonctionnalité de réinitilisation du mot de passe. Il sera donc nécessaire que le membre souhaitant réinitialiser son mot de passe reçoive un mail contenant un token. Ce token devra avoir une durée limitée, et n'être utilisable qu'une fois.


1) installation et configuration de swiftmailer

Swiftmailer est une librairie bien pratique pour envoyer des mails et elle s'intègre en outre très facilement à Symfony. Elle est d'ailleurs maintenue par Fabien Potencier, le créateur de Symfony.

composer require mailer

Il n'y a qu'un paramètre à configurer pour profiter de cette librairie. C'est le MAILER_URL présent dans le fichier .env. Ce sera peut être pour vous le principal obstacle pour arriver au bout de ce tuto !! C'est quelquefois pénible de trouver le bon protocole, ou l'hôte correct, etc... Si vous avez une adresse gmail, pour tester c'est assez simple. Remplacez ci-dessous votre identifiant et mot de passe.

# .env
MAILER_URL=gmail://votre_identifiant_gmail:votre_mot_de_passe@localhost?encryption=tls&auth_mode=oauth

Bien que pratique, j'ai toujours trouvé les méthodes de cette libraire difficiles à mémoriser. J'ai donc l'habitude d'utiliser un service pour utiliser cette librairie et rassembler toute cette logique en 1 seule classe.

<?php

namespace App\Services;

use Symfony\Component\Templating\EngineInterface;
use Twig\Environment;

/**
 * Class Mailer
 */
class Mailer
{
    private $engine;
    private $mailer;

    public function __construct(\Swift_Mailer $mailer, Environment $engine)
    {
        $this->engine = $engine;
        $this->mailer = $mailer;
    }

    public function sendMessage($from, $to, $subject, $body, $attachement = null)
    {
        $mail = (new \Swift_Message($subject))
            ->setFrom($from)
            ->setTo($to)
            ->setSubject($subject)
            ->setBody($body)
            ->setReplyTo($from)
            ->setContentType('text/html');

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

    public function createBodyMail($view, array $parameters)
    {
        return $this->engine->render($view, $parameters);
    }
}

Grâce à l'autowiring des services, nul besoin d'enregistrer le service manuellement car les dépendances sont automatiquement injectées.


2) Modification de l'entité User

 <?php
// src/Entity/User.php
namespace App\Entity;

//...
class User implements UserInterface, \Serializable
{
    // ...
    /**
     * @ORM\Column(type="datetime", nullable=true)
     * @var \DateTime
     */
    private $passwordRequestedAt;

    /**
    * @var string
    *
    * @ORM\Column(type="string", length=255, nullable=true)
    */
    private $token;

    /*
     * Get passwordRequestedAt
     */
    public function getPasswordRequestedAt()
    {
        return $this->passwordRequestedAt;
    }

    /*
     * Set passwordRequestedAt
     */
    public function setPasswordRequestedAt($passwordRequestedAt)
    {
        $this->passwordRequestedAt = $passwordRequestedAt;
        return $this;
    }

    /*
     * Get token
     */
    public function getToken()
    {
        return $this->token;
    }

    /*
     * Set token
     */
    public function setToken($token)
    {
        $this->token = $token;
        return $this;
    }
}

Sans surprise nous ajoutons à notre entité User 2 propriétés:

  • 1 token pour vérifier l'accès du membre à l'espace de réinitialisation du mot de passe
  • un champ datetime (passwordRequestedAt) pour contrôler la validité du token

Pensez ensuite à mettre à jour votre base de données en faisant:

 bin/console doctrine:migrations:diff && bin/console doctrine:migrations:migrate

3) La demande de réinitialisation du mot de passe

<?php

namespace App\Controller;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;
use App\Entity\User;
use App\Services\Mailer;
use Symfony\Component\Security\Csrf\TokenGenerator\TokenGeneratorInterface;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Form\Extension\Core\Type\EmailType;

/**
 * @Route("/renouvellement-mot-de-passe")
 */
class ResettingController extends Controller
{
    /**
     * @Route("/requete", name="request_resetting")
     */
    public function request(Request $request, Mailer $mailer, TokenGeneratorInterface $tokenGenerator)
    {
        // création d'un formulaire "à la volée", afin que l'internaute puisse renseigner son mail
        $form = $this->createFormBuilder()
            ->add('email', EmailType::class, [
                'constraints' => [
                    new Email(),
                    new NotBlank()
                ]
            ])
            ->getForm();
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {

            $em = $this->getDoctrine()->getManager();

            // voir l'épisode 2 de cette série pour retrouver la méthode loadUserByUsername:
            $user = $em->getRepository(User::class)->loadUserByUsername($form->getData()['email']);

            // aucun email associé à ce compte.
            if (!$user) {
                $request->getSession()->getFlashBag()->add('warning', "Cet email n'existe pas.");
                return $this->redirectToRoute("request_resetting");
            } 

            // création du token
            $user->setToken($tokenGenerator->generateToken());
            // enregistrement de la date de création du token
            $user->setPasswordRequestedAt(new \Datetime());
            $em->flush();

            // on utilise le service Mailer créé précédemment
            $bodyMail = $mailer->createBodyMail('resetting/mail.html.twig', [
                'user' => $user
            ]);
            $mailer->sendMessage('from@email.com', $user->getEmail(), 'renouvellement du mot de passe', $bodyMail);
            $request->getSession()->getFlashBag()->add('success', "Un mail va vous être envoyé afin que vous puissiez renouveller votre mot de passe. Le lien que vous recevrez sera valide 24h.");

            return $this->redirectToRoute("connexion");
        }

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

}

Pour rester concis, je ne vais pas mettre l'intégralité du template et du code HTML. Mais vous pouvez retrouver tout ça sur mon dépôt Github.

{# resetting/request.html.twig #}

<h1>Mot de passe oublié ?</h1>
<p>Demandez à réinitialiser votre mot de passe en renseignant votre mail.</p>

{{form_start(form)}}
    {{form_row(form.email)}}
    <button  type="submit">Envoyer</button>
{{form_end(form)}}

Si vous avez déjà tenté de construire des mails responsives, vous avez pu vous rendre compte que c'est vraiment la galère pour produire un template qui soit compatible pour tous les clients mails. Personnellement, j'utilise habituellement Cerberus, un projet open source qui propose des templates responsives. Ils sont assez longs à reproduire ici donc je ne vais pas les utiliser dans cet article. Mais je vous conseille fortement d'aller faire un tour sur ce projet car le résultat visuel vaut le coup.

{# resetting/mail.html.twig #}
<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <title>Renouvellement du mot de passe</title>
</head>
<body>
    <p>
        Bonjour, <br>
        Cliquez sur ce lien pour changer votre mot de passe.
        {# pour que le lien fonctionne, il est indispensable que le lien soit absolu. #}
        <a href="{{absolute_url(path('resetting', {'id': user.id, 'token': user.token}))}}">Changer le mot de passe</a>
    </p>
</body>
</html>

4) Réinitialisation du mot de passe

Dès lors que le mail est envoyé, l'internaute a la possibilité de renouveler son mot de passe en utilisant notre lien auquel nous avons associé un token. Nous allons maintenant créer le formulaire pour que l'internaute puisse renouveler son mot de passe.

<?php

namespace App\Form;

use App\Entity\Resetting;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use App\Entity\User;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;

class ResettingType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('plainPassword', RepeatedType::class, array(
                'type' => PasswordType::class,
                'first_options' => array('label' => 'Nouveau mot de passe'),
                'second_options' => array('label' => 'Confirmer le mot de passe'),
                'invalid_message' => 'Les 2 mots de passe ne sont pas identiques.',
            ))

        ;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            // uncomment if you want to bind to a class
            'data_class' => User::class,
        ]);
    }
}

Modifions notre controlleur initial et ajoutons la route "resetting".

<?php
namespace App\Controller;
// ...

use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use App\Form\ResettingType;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;

// ...
class ResettingController extends Controller
{
            
    // si supérieur à 10min, retourne false
    // sinon retourne false
    private function isRequestInTime(\Datetime $passwordRequestedAt = null)
    {
        if ($passwordRequestedAt === null)
        {
            return false;        
        }
        
        $now = new \DateTime();
        $interval = $now->getTimestamp() - $passwordRequestedAt->getTimestamp();

        $daySeconds = 60 * 10;
        $response = $interval > $daySeconds ? false : $reponse = true;
        return $response;
    }

    /**
     * @Route("/{id}/{token}", name="resetting")
     */
    public function resetting(User $user, $token, Request $request, UserPasswordEncoderInterface $passwordEncoder)
    {
        // interdit l'accès à la page si:
        // le token associé au membre est null
        // le token enregistré en base et le token présent dans l'url ne sont pas égaux
        // le token date de plus de 10 minutes
        if ($user->getToken() === null || $token !== $user->getToken() || !$this->isRequestInTime($user->getPasswordRequestedAt()))
        {
            throw new AccessDeniedHttpException();
        }

        $form = $this->createForm(ResettingType::class, $user);
        $form->handleRequest($request);

        if($form->isSubmitted() && $form->isValid())
        {
            $password = $passwordEncoder->encodePassword($user, $user->getPlainPassword());
            $user->setPassword($password);

            // réinitialisation du token à null pour qu'il ne soit plus réutilisable
            $user->setToken(null);
            $user->setPasswordRequestedAt(null);

            $em = $this->getDoctrine()->getManager();
            $em->persist($user);
            $em->flush();

            $request->getSession()->getFlashBag()->add('success', "Votre mot de passe a été renouvelé.");

            return $this->redirectToRoute('connexion');

        }

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

Enfin, le template minimal pour le changement du mot de passe

{# resetting/index.html.twig #}
<h1>Renouvellement du mot de passe</h1>
{{form_start(form)}}
	{{form_row(form.plainPassword.first)}}
	{{form_row(form.plainPassword.second)}}
	<button class=""  type="submit">Envoyer</button>
{{form_end(form)}}

Comme d'habitude, j'insiste sur le fait que l'erreur est humaine et que j'ai pu malencontreusement en glisser quelques-unes, et ce d'autant plus que l'article est assez long. Si vous en repérez 1, d'avance merci de m'envoyer un message pour que je pallie au problème.