Mot de passe oublié

Nous avons besoin de deux routes : une pour générer / envoyer le code et une autre pour créer le nouveau mot de passe avec vérification du code

On rajoute dans notre entité User les attributs dont nous avons besoin : passwordForgottenCode et passwordForgottenCodeExpiresAt

/**
 * @ORM\Column(type="integer", nullable=true)
 */
private $passwordForgottenCode;

/**
 * @ORM\Column(type="datetime", nullable=true)
 */
private $passwordForgottenCodeExpiresAt;

// Ainsi que leurs getters setters

public function getPasswordForgottenCode(): ?int
{
    return $this->passwordForgottenCode;
}

public function setPasswordForgottenCode(?int $passwordForgottenCode): self
{
    $this->passwordForgottenCode = $passwordForgottenCode;

    return $this;
}

public function getPasswordForgottenCodeExpiresAt(): ?\DateTimeInterface
{
    return $this->passwordForgottenCodeExpiresAt;
}

public function setPasswordForgottenCodeExpiresAt(?\DateTimeInterface $passwordForgottenCodeExpiresAt): self
{
    $this->passwordForgottenCodeExpiresAt = $passwordForgottenCodeExpiresAt;

    return $this;
}

Maintenant nous créons le service pour générer les codes et envoyer les codes. On en profite pour factoriser avec CodeService qui contiendra la méthode generateNewCode qu’on appellera dans ActivationCodeService et PasswordForgottenCodeService

<?php

// src/Security/CodeService.php

namespace App\Security;

class CodeService
{
    const VALIDATION_HOURS = 2;

    public static function generateNewCode()
    {
        return
            [
                'code' => random_int(100000, 999999),
                'expirationDate' => new \DateTime('+ ' . self::VALIDATION_HOURS . 'hour')
            ];
    }
}

On l'appelle dans ActivationCodeService en remplacant $activationCode = self::generateNewCode(); par $activationCode = CodeService::generateNewCode();

Générer un code de récupération

On créer le service qui va créer et envoyer le code pour créer un nouveau mot de passe

<?php

// src/Security/PasswordForgottenCodeService.php

namespace App\Security;

use App\Entity\User;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Twig\Environment;

class PasswordForgottenCodeService
{
    private $mailer;
    private $twig;

    public function __construct(
        MailerInterface $mailer,
        Environment $twig
    ) {
        $this->mailer = $mailer;
        $this->twig = $twig;
    }

    public function generateCodeAndSendByMail(User $user) {
        $passwordForgottenCode = CodeService::generateNewCode();
        $user->setPasswordForgottenCode($passwordForgottenCode['code']);
        $user->setPasswordForgottenCodeExpiresAt($passwordForgottenCode['expirationDate']);

        $email = (new Email())
            ->from('hello@example.com')
            ->to($user->getEmail())
            ->subject('Mot de passe oublié')
            ->html(
                $this->twig->render('emails/password-forgotten.html.twig', [
                    'passwordForgottenCode' => $passwordForgottenCode
                ])
            )
        ;

        $this->mailer->send($email);

        return $user;
    }
}

Le template templates/emails/password-forgotten.html.twig utilisé est le suivant

<h3>Mot de passe oublié</h3>

<div>
    Votre code de pour créer un nouveau mot de passe est <strong>{{ passwordForgottenCode.code }}</strong>
</div>

<div>
    Ce code est valable jusqu'au <strong>{{ passwordForgottenCode.expirationDate|date('Y-m-d H:i:s') }}</strong>
</div>

On créé la route qui nous permettra de générer et envoyer le code, pour ça l'ajoute à notre entité User

 *            "passwordForgottenRequest"={
 *                "method"="POST",
 *                "path"="/password-forgotten-request",
 *                "input"=PasswordForgottenRequestInput::class,
 *                "denormalization_context"={"groups"={"password-forgotten:write"}}
 *            },

On créé l'input qui demande notre email

<?php

namespace App\DTO;

use ApiPlatform\Core\Annotation\ApiResource;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Serializer\Annotation\Groups;

class PasswordForgottenRequestInput
{
    /**
     * @Assert\NotBlank()
     * @Assert\Email()
     * @Groups({"password-forgotten:write"})
     */
    public $email;
}

Et enfin notre DataTransformer

<?php

namespace App\DataTransformer;

use ApiPlatform\Core\DataTransformer\DataTransformerInterface;
use App\Entity\ActivationCode;
use App\Entity\User;

final class PasswordForgottenRequestDataTransformer implements DataTransformerInterface
{
    /**
     * {@inheritdoc}
     */
    public function transform($data, string $to, array $context = [])
    {
        return $data;
    }

    /**
     * {@inheritdoc}
     */
    public function supportsTransformation($data, string $to, array $context = []): bool
    {
        // in the case of an input, the value given here is an array (the JSON decoded).
        if ($data instanceof User) {
            return false;
        }

        return (User::class === $to) && null !== ($context['input']['class'] ?? null) && (array_key_exists('collection_operation_name', $context) && $context['collection_operation_name'] === 'passwordForgottenRequest');
    }
}

Maintenant nous créons PasswordForgottenRequestDataPersister qui nous permet de générer et d'envoyer le code par mail avec la méthode generateCodeAndSendByMail du service PasswordForgottenCodeService

<?php

// src/DataPersister/PasswordForgottenRequestDataPersister.php

namespace App\DataPersister;

use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface;
use App\DTO\PasswordForgottenRequestInput;
use App\Repository\UserRepository;
use App\Security\PasswordForgottenCodeService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;

class PasswordForgottenRequestDataPersister implements ContextAwareDataPersisterInterface
{
    private $passwordForgottenCodeService;
    private $entityManager;
    private $userRepository;

    public function __construct(
        PasswordForgottenCodeService $passwordForgottenCodeService,
        EntityManagerInterface $entityManager,
        UserRepository $userRepository
    ) {
        $this->passwordForgottenCodeService = $passwordForgottenCodeService;
        $this->entityManager = $entityManager;
        $this->userRepository = $userRepository;
    }

    public function supports($data, array $context = []): bool
    {
        return $data instanceof PasswordForgottenRequestInput;
    }

    public function persist($data, array $context = [])
    {
        $user = $this->userRepository->findOneBy(['email' => $data->email]);
        if ($user) {
            $this->passwordForgottenCodeService->generateCodeAndSendByMail($user);
            $this->entityManager->persist($user);
            $this->entityManager->flush();
        }

        return new JsonResponse(['message' => 'If this email was found in the database, a code has been sent to you to create a new password.'], 200);
    }

    public function remove($data, array $context = [])
    {
    }
}

Sur la route POST /api/password-forgotten-request avec le payload

{
    "email": "Immanuel_Bradtke7@yahoo.com"
}

Vous devriez recevoir un email contenant le code (rendez-vous sur http://localhost:8081 si vous utilisez le webmail)

Créer un nouveau mot de passe avec le code

Maintenant il nous faut une route qui va vérifier si le code est valide, non expiré et si c’est le cas on enregistre le nouveau mot de passe

On retourne dans notre entité User pour rajouter la route suivante :

 *             "passwordForgottenNewPassword"={
 *                 "method"="POST",
 *                 "path"="/password-forgotten-new-password",
 *                 "input"=PasswordForgottenNewPasswordInput::class,
 *                 "denormalization_context"={"groups"={"password-forgotten:write"}}
 *             },

On créé l'input attendu :

<?php

// src/DTO/PasswordForgottenNewPasswordInput.php

namespace App\DTO;

use App\Validator\Constraint\IsEmailExists;
use App\Validator\Constraint\IsValidPasswordForgottenCode;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Serializer\Annotation\Groups;

/**
 * @IsValidPasswordForgottenCode()
 */
class PasswordForgottenNewPasswordInput
{
    /**
     * @Assert\NotBlank()
     * @Assert\Length(6)
     * @Assert\Type(type="integer")
     * @Groups({"password-forgotten:write"})
     */
    public $code;

    /**
     * @Assert\NotBlank()
     * @Assert\Email()
     * @Groups({"password-forgotten:write"})
     */
    public $email;

    /**
     * @Groups({"password-forgotten:write"})
     * @Assert\NotBlank()
     * @Assert\Length(
     *       min = 8,
     *       max = 32,
     *       minMessage = "Your password must be at least {{ limit }} characters long",
     *       maxMessage = "Your password cannot be longer than {{ limit }} characters"
     *  )
     * @Assert\Regex(
     *      "/^.*(?=.{8,})((?=.*[!@#$%^&*()\-_=+{};:,<.>]){1})(?=.*\d)((?=.*[a-z]){1})((?=.*[A-Z]){1}).*$/",
     *      message = "Your password needs an uppercase, a lowercase, a digit and a special character"
     *  )
     */
    public $password;
}

Notez que le validator IsValidPasswordForgottenCodeValidator est appelé en haut de la classe, celui-ci vérifié si l'email correspond à un utilisateur, et que le code est valide et non expiré

<?php

// src/Validator/Constraint/IsValidPasswordForgottenCodeValidator.php

namespace App\Validator\Constraint;

use App\DTO\PasswordForgottenNewPasswordInput;
use App\DTO\PasswordForgottenRequestInput;
use App\Repository\UserRepository;
use App\Security\PasswordForgottenCodeService;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;

class IsValidPasswordForgottenCodeValidator  extends ConstraintValidator
{
    private $userRepository;

    public function __construct(UserRepository $userRepository)
    {
        $this->userRepository = $userRepository;
    }

    public function validate($value, Constraint $constraint)
    {
        if (!$value instanceof PasswordForgottenNewPasswordInput) {
            throw new \Exception('Only PasswordForgottenRequestInput is supported');
        }

        $user = $this->userRepository->findOneBy(['email' => $value->email]);
        if (null === $user) {
            $this->context->buildViolation("Cet utilisateur n'existe pas")
                ->addViolation()
            ;
            return;
        }

        if ($user->getPasswordForgottenCodeExpiresAt() < new \DateTime()) {
            $this->context->buildViolation("Ce code est expiré")
                ->addViolation()
            ;
            return;
        }

        if ($value->code !== $user->getPasswordForgottenCode()) {
            $this->context->buildViolation("Ce code n'est pas correct")
                ->addViolation()
            ;
            return;
        }
    }
}
<?php

// src/Validator/Constraint/IsValidPasswordForgottenCode.php

namespace App\Validator\Constraint;

use Symfony\Component\Validator\Constraint;

/**
 * @Annotation
 * @Target({"CLASS"})
 */
class IsValidPasswordForgottenCode extends Constraint
{
    public function getTargets()
    {
        return self::CLASS_CONSTRAINT;
    }
}

On créé un nouveau DataTransformer PasswordForgottenNewPasswordDataTransformer

<?php

// src/DataTransformer/PasswordForgottenNewPasswordDataTransformer.php

namespace App\DataTransformer;

use ApiPlatform\Core\DataTransformer\DataTransformerInterface;
use App\Entity\User;

class PasswordForgottenNewPasswordDataTransformer implements DataTransformerInterface
{
    /**
     * {@inheritdoc}
     */
    public function transform($data, string $to, array $context = [])
    {
        return $data;
    }

    /**
     * {@inheritdoc}
     */
    public function supportsTransformation($data, string $to, array $context = []): bool
    {
        // in the case of an input, the value given here is an array (the JSON decoded).
        if ($data instanceof User) {
            return false;
        }

        return (User::class === $to) && null !== ($context['input']['class'] ?? null) && (array_key_exists('collection_operation_name', $context) && $context['collection_operation_name'] === 'passwordForgottenNewPassword');
    }
}

On créé PasswordForgottenNewPasswordDataPersister qui va s'occuper de créer le nouveau mot de passe

<?php

// src/DataPersister/PasswordForgottenNewPasswordDataPersister.php

namespace App\DataPersister;

use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface;
use App\Repository\UserRepository;
use App\Security\PasswordForgottenCodeService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;

class PasswordForgottenNewPasswordDataPersister implements ContextAwareDataPersisterInterface
{
    private $passwordForgottenCodeService;
    private $entityManager;
    private $userRepository;
    private $userPasswordEncoder;

    public function __construct(
        PasswordForgottenCodeService $passwordForgottenCodeService,
        EntityManagerInterface $entityManager,
        UserRepository $userRepository,
        UserPasswordEncoderInterface $userPasswordEncoder

    ) {
        $this->passwordForgottenCodeService = $passwordForgottenCodeService;
        $this->entityManager = $entityManager;
        $this->userRepository = $userRepository;
        $this->userPasswordEncoder = $userPasswordEncoder;
    }

    public function supports($data, array $context = []): bool
    {
        return $data instanceof PasswordForgottenNewPasswordInput;
    }

    public function persist($data, array $context = [])
    {
        $user = $this->userRepository->findOneBy(['email' => $data->email]);
        if ($user) {
            $user->setPassword(
                $this->userPasswordEncoder->encodePassword($user, $data->password)
            );
            $user->eraseCredentials();

            $user->setPasswordForgottenCode(null);
            $user->setPasswordForgottenCodeExpiresAt(null);

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

            return new JsonResponse(['message' => 'Password changed correctly'], 200);
        }

        return $data;
    }

    public function remove($data, array $context = [])
    {
    }
}

Notez que nous mettons le code et sa date d'expiration à null pour s'assurer que l'utilisation soit unique.

Maintenant, sur la route POST /api/password-forgotten-new-password, vous pouvez créer un nouveau mot de passe

{
    "code": 999320,
    "email": "Immanuel_Bradtke7@yahoo.com",
    "password": "P@sswordVeryS3cure"
}

Identifiez-vous sur POST /api/login avec le nouveau mot de passe pour vérifier que tout est fonctionnel.

prev next

Commentaires

Connectez-vous pour laisser un commentaire