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.
Commentaires
Connectez-vous pour laisser un commentaire