Modifier son email

Dans notre entité User nous allons rajouter un attribut newEmail ainsi que son code et sa date d'expiration.

    /**
     * @ORM\Column(type="string", length=180, nullable=true)
     * @Groups("user:item:put")
     * @Assert\Email(groups="update-email")
     * @Assert\Length(max="180", groups="update-email")
     * @AppAssert\IsEmailExists(groups="update-email")
     */
    private $newEmail;

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

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

    public function getNewEmail(): ?string
    {
        return $this->newEmail;
    }

    public function setNewEmail(?string $newEmail): self
    {
        $this->newEmail = $newEmail;

        return $this;
    }

    public function getNewEmailCode(): ?int
    {
        return $this->newEmailCode;
    }

    public function setNewEmailCode(?int $newEmailCode): self
    {
        $this->newEmailCode = $newEmailCode;

        return $this;
    }

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

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

        return $this;
    }

On adapte la logique de notre groupe de validation

public static function validationGroups(self $user)
{
    if ($user->getCurrentPassword() !== null || $user->getPlainPassword() !== null) {
        return ["update-password"];
    }

    if ($user->getNewEmail() !== null) {
        return ["update-email"];
    }

    return [""];
}

On créé un validateur IsEmailExists pour newEmail qui va vérifier si le nouvel email n'est pas déjà enregistré sur notre base de donnée.

<?php

// src/Validator/Constraint/IsEmailExists.php

namespace App\Validator\Constraint;

use Symfony\Component\Validator\Constraint;

/**
 * @Annotation
 */
class IsEmailExists extends Constraint
{
    public $message = 'This email is already registered in the database';
}
<?php

// src/Validator/Constraint/IsEmailExistsValidator.php

namespace App\Validator\Constraint;

use App\Repository\UserRepository;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;

class IsEmailExistsValidator extends ConstraintValidator
{
    private $userRepository;

    public function __construct(UserRepository $userRepository) {

        $this->userRepository = $userRepository;
    }

    public function validate($value, Constraint $constraint)
    {
        if (!$constraint instanceof IsEmailExists) {
            throw new UnexpectedTypeException($constraint, IsEmailExists::class);
        }

        if (null === $value || '' === $value) {
            return;
        }

        $isEmailExists = $this->userRepository->findOneBy(['email' => $value]);

        if ($isEmailExists) {
            $this->context->buildViolation($constraint->message)
                ->addViolation()
            ;
        }
    }
}

On créé un nouveau service qui va nous permettre de créer le code et sa date d'expiration.

<?php

// src/Security/NewEmailCodeService.php

namespace App\Security;

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

class NewEmailCodeService
{
    private $mailer;
    private $twig;

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

    public function generateCodeAndSendByMail(User $user) {
        $newEmailCode = CodeService::generateNewCode();
        $user->setNewEmailCode($newEmailCode['code']);
        $user->setNewEmailCodeExpiresAt($newEmailCode['expirationDate']);

        $email = (new Email())
            ->from('hello@example.com')
            ->to($user->getEmail())
            ->subject('Activez votre mail')
            ->html(
                $this->twig->render('emails/new-email.html.twig', [
                    'newEmailCode' => $newEmailCode,
                    'newEmail' => $user->getNewEmail()
                ])
            )
        ;

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

        return $user;
    }
}

Le template correspondant templates/emails/new-email.html.twig

<h3>Changement d'adresse email</h3>

<div>
    Votre code de pour valider votre nouvelle adresse <strong>{{ newEmail }}</strong> est <strong>{{ newEmailCode.code }}</strong>
</div>

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

Nous allons détecter si un changement est fait sur l'attribut newEmail dans notre UserDataPersister et si c'est le cas on envoie un mail avec le code.

Pour ça nous allons utiliser l'entity manager, qui contient la méthode getUnitOfWork. Dans cette méthode on va utiliser getOriginalEntityData pour récupérer les données actuelle de l'utilisateur. Ensuite on va comparer avec ce qu'on envoie dans le payload. Il y a également la condition $data->getId() !== null qui vérifie si notre utilisateur est déjà existant.

<?php

namespace App\DataPersister;

use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface;
use App\Entity\User;
use App\Security\ActivationCodeService;
use App\Security\NewEmailCodeService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
use Twig\Environment;

final class UserDataPersister implements ContextAwareDataPersisterInterface
{
    private $decorated;
    private $userPasswordEncoder;

    private $mailer;

    private $twig;

    private $activationCodeService;

    private $newEmailCodeService;

    private $entityManager;

    public function __construct(
        ContextAwareDataPersisterInterface $decorated,
        UserPasswordEncoderInterface $userPasswordEncoder,
        MailerInterface $mailer,
        Environment $twig,
        ActivationCodeService $activationCodeService,
        NewEmailCodeService $newEmailCodeService,
        EntityManagerInterface $entityManager
    ) {
        $this->decorated = $decorated;
        $this->userPasswordEncoder = $userPasswordEncoder;
        $this->mailer = $mailer;
        $this->twig = $twig;
        $this->activationCodeService = $activationCodeService;
        $this->newEmailCodeService = $newEmailCodeService;
        $this->entityManager = $entityManager;
    }

    public function supports($data, array $context = []): bool
    {
        return $this->decorated->supports($data, $context)
            && $data instanceof User;
    }

    /**
     * @param User $data
     * @param array $context
     * @return object|void
     * @throws \Symfony\Component\Mailer\Exception\TransportExceptionInterface
     */
    public function persist($data, array $context = [])
    {
        if ($data->getId() === null) {
            if ($data->getPlainPassword()) {
                $data->setPassword(
                    $this->userPasswordEncoder->encodePassword($data, $data->getPlainPassword())
                );
                $data->eraseCredentials();
            }

            $this->activationCodeService->generateCodeAndSendByMail($data);
        }

        $unitOfWork = $this->entityManager->getUnitOfWork();
        $originalData = $unitOfWork->getOriginalEntityData($data);

        if ($data->getId() !== null) {
            if ($data->getNewEmail() && $originalData['newEmail'] !== $data->getNewEmail()) {
                $this->newEmailCodeService->generateCodeAndSendByMail($data);
            }
        }

        return $this->decorated->persist($data, $context);
    }

    public function remove($data, array $context = [])
    {
        return $this->decorated->remove($data, $context);
    }
}

Maintenant à chaque fois qu'on modifie newEmail avec la route PUT /users{id} et le payload qui contient newEmail:

{
    "newEmail": "Elta2.Weissnat@yahoo.com"
}

Alors un email avec un nouveau code est envoyé.

Route pour valider le nouvel email

Nous allons dans notre entité User rajouter une nouvelle route

 *              "newEmailValidation"={
 *                  "method"="POST",
 *                  "path"="/new-email-validation",
 *                  "input"=NewEmailCodeInput::class,
 *                  "denormalization_context"={"groups"={"new-email:write"}},
 *                  "security"="is_granted('ROLE_USER')"
 *              },

Nous avons besoin de créer NewEmailCodeInput qui va vérifier si notre code est correct et non expiré et si l'email existe bien.

<?php

// src/DTO/NewEmailCodeInput.php

namespace App\DTO;

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

/**
 * @IsValidNewEmailCode()
 */
class NewEmailCodeInput
{
    /**
     * @Assert\NotBlank()
     * @Assert\Length(6)
     * @Assert\Type(type="integer")
     * @Groups({"new-email:write"})
     */
    public $code;

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

}

Nous créons un validator IsValidNewEmailCode qui va vérifier si les données rentrées sont correctes.

<?php

// src/Validator/Constraint

namespace App\Validator\Constraint;

use Symfony\Component\Validator\Constraint;

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

// src/Validator/Constraint/IsValidNewEmailCodeValidator.php

namespace App\Validator\Constraint;

use App\DTO\NewEmailCodeInput;
use App\Repository\UserRepository;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;

class IsValidNewEmailCodeValidator  extends ConstraintValidator
{
    private $userRepository;

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

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

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

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

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

On créé notre DataTransformer

<?php

namespace App\DataTransformer;

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

class NewEmailDataTransformer implements DataTransformerInterface
{
    public function transform($object, string $to, array $context = [])
    {
        return $object;
    }

    /**
     * {@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'] === 'newEmailValidation');
    }
}

Si tout est valide on rentre dans notre DataPersister que nous devons créer NewEmailCodeDataPersister

<?php

namespace App\DataPersister;

use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface;
use App\DTO\NewEmailCodeInput;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;

final class NewEmailCodeDataPersister implements ContextAwareDataPersisterInterface
{
    private $userRepository;
    private $entityManager;

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

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

    /**
     * @param NewEmailCodeInput $data
     * @param array $context
     * @return object|void
     * @throws \Symfony\Component\Mailer\Exception\TransportExceptionInterface
     */
    public function persist($data, array $context = [])
    {
        $user = $this->userRepository->findOneBy(['email' => $data->email]);

        $user->setEmail($user->getNewEmail());
        $user->setNewEmailCode(null);
        $user->setNewEmailCodeExpiresAt(null);
        $user->setNewEmail(null);

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

        return $user;
    }

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

Dans ce DataPersister nous récupérons l'utilisateur, et nous déplaçons newEmail dans email. Nous supprimons également les données dans newEmail, newEmailCode et newEmailCodeExpiresAt afin que l'usage soit unique.

Pour tester la validation du nouveau mail vous pouvez aller sur la route POST /api/new-email-validation avec comme payload

{
    "email": "Elta.Weissnat@yahoo.com",
    "code": 444462
}

Route pour générer un nouveau code s'il est expiré

Dans notre entité User nous créons cette route

 *               "resendNewEmailValidationCode"={
 *                   "method"="POST",
 *                   "path"="/resend-new-email-validation-code",
 *                   "input"=ResendNewEmailCodeInput::class,
 *                   "security"="is_granted('ROLE_USER')"
 *               },

Nous avons besoin d'un DTO input pour pouvoir mettre notre logique dans un DataPersister

Nous créons donc le le DTO suivant qui n'est qu'une classe vide

<?php

namespace App\DTO;

class ResendNewEmailCodeInput
{
}

Nous créons le DataTransformer pour ce DTO input

<?php

namespace App\DataTransformer;

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

class ResendNewEmailDataTransformer implements DataTransformerInterface
{
    public function transform($object, string $to, array $context = [])
    {
        return $object;
    }

    /**
     * {@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'] === 'resendNewEmailValidationCode');
    }
}

Et enfin le DataPersister qui permet de renvoyer le code

<?php

namespace App\DataPersister;

use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface;
use App\DTO\ResendNewEmailCodeInput;
use App\Entity\User;
use App\Repository\UserRepository;
use App\Security\NewEmailCodeService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Security\Core\Security;

final class ResendNewEmailValidationCodeDataPersister implements ContextAwareDataPersisterInterface
{
    private $userRepository;
    private $entityManager;

    private $newEmailCodeService;

    private $security;

    public function __construct(
        UserRepository $userRepository,
        EntityManagerInterface $entityManager,
        NewEmailCodeService $newEmailCodeService,
        Security $security
    ) {
        $this->userRepository = $userRepository;
        $this->entityManager = $entityManager;
        $this->newEmailCodeService = $newEmailCodeService;
        $this->security = $security;
    }

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

    /**
     * @param ResendNewEmailCodeInput $data
     * @param array $context
     * @return object|void
     * @throws \Symfony\Component\Mailer\Exception\TransportExceptionInterface
     */
    public function persist($data, array $context = [])
    {
        /** @var User $user */
        $user = $this->security->getUser();

        if ($user->getNewEmail()) {
            $this->newEmailCodeService->generateCodeAndSendByMail($user);
            $this->entityManager->persist($user);
            $this->entityManager->flush();
        }

        return new JsonResponse(['message' => 'A new code has just been sent to you']);
    }

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

Maintenant la route POST /api/resend-new-email-validation-code avec le payload {} génere un nouveau code et l'envoie par email.

prev next

Commentaires

Connectez-vous pour laisser un commentaire