Valider l'email de l'utilisateur

Nous allons générer un code à six chiffres que nous enverrons par mail avec une date d'expiration. Il faudra également pouvoir renvoyer un nouveau code si celui-ci est expiré.

Dans notre entité User nous rajoutons ces deux attributs ainsi que ces getters et setters :

    /**
     * @ORM\Column(type="integer")
     */
    private $activationCode;

    /**
     * @ORM\Column(type="datetime")
     */
    private $activationCodeExpiresAt;

    /**
     * @Groups({"user:read"})
     * @ORM\Column(type="boolean")
     */
    private $isEnabled;

    public function __construct()
    {
        $this->isEnabled = false;
    }

// ...

    public function getActivationCode(): ?int
    {
        return $this->activationCode;
    }

    public function setActivationCode(int $activationCode): self
    {
        $this->activationCode = $activationCode;

        return $this;
    }

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

    public function setActivationCodeExpiresAt(\DateTimeInterface $activationCodeExpiresAt): self
    {
        $this->activationCodeExpiresAt = $activationCodeExpiresAt;

        return $this;
    }

    public function getIsEnabled(): ?bool
    {
        return $this->isEnabled;
    }

    public function setIsEnabled(bool $isEnabled): self
    {
        $this->isEnabled = $isEnabled;

        return $this;
    }

Nous mettons à jour notre base de donnée avec php bin/console doctrine:shema:update --force. Faites attention à bien avoir mis isEnabled à false dans la méthode __construct

Afin de pouvoir envoyer des emails avec notre application nous allons utiliser le bundle mailer.

S'il n'est pas sur votre application vous pouvez l'ajouter : composer require symfony/mailer

Si vous utilisez docker vous pouvez créer un fichier docker-compose.override.yml dans lequel vous pouvez ajouter le container maildev qui nous permettra de capturer les mails sortant

version: '3.8'

services:
  maildev:
    image: maildev/maildev
    command: bin/maildev --web 80 --smtp 25 --hide-extensions STARTTLS
    ports:
      - "8081:80"

Après avoir relancer vos containers si vous vous rendez sur l'adresse http://127.0.0.1:8081 vous devriez avoir le webmail

screenshot maildev

Dans le fichier .env pour configurer symfony/mailer il faut indiquer MAILER_DSN=smtp://maildev:25 (si vous utilisez le container et votre service doit s'appeler "maildev") Sinon si vous n'utilisez pas docker renseignez : MAILER_DSN=smtp://localhost

Générer le code et sa date d'expiration

Nous allons créer une classe avec une méthode statique (pour nous éviter d'instancier la classe)

Créez la classe et le dossier comme suit src/SecurityActivationCodeService.php Voici la méthode que je vous propose

<?php

namespace App\Security;

class ActivationCodeService
{
    const VALIDATION_HOURS = 2;

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

pour l'utiliser nous n'aurons plus qu'à faire ActivationCodeService::generateNewCode() et nous aurons en retour un array

array:2 [
  "code" => 744763
  "expirationDate" => DateTime @1692535362 {#1749
    date: 2023-08-20 12:42:42.708807 UTC (+00:00)
  }
]

Il faut enregistrer ses informations et les envoyer par mail.

Dans notre UserDataPersister dans notre méthode persist nous rajoutons le persist du code ainsi que l'envoie du mail. Ainsi, dès qu'un utilisateur sera créé, il recevra un code généré par mail.

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

public function persist($data, array $context = [])
{
    if ($data->getId() === null) {
        if ($data->getPlainPassword()) {
            $data->setPassword(
                $this->userPasswordEncoder->encodePassword($data, $data->getPlainPassword())
            );
            $data->eraseCredentials();
        }

        $activationCode = ActivationCodeService::generateNewCode();
        $data->setActivationCode($activationCode['code']);
        $data->setActivationCodeExpiresAt($activationCode['expirationDate']);

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

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

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

Il faut également créer le template templates/email/registration.html.twig

<h3>Bienvenue</h3>

<div>
    Votre code d'activation est <strong>{{ activationCode.code }}</strong>
</div>

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

Maintenant quand un nouvel utilisateur s'inscrit vous pouvez voir le mail qu'il reçoit sur http://127.0.0.1:8081

registration email code activation

Valider le code

Maintenant nous avons besoin d'une route qui va nous permettre de vérifier si ce code est valide et non expiré.

Nous allons créer un DTO ActivationCodeInput dans lequel nous mettrons les attributs code et email. Ensuite on créé ActivationCodeDataPersister pour récupérer l'objet ActivationCodeDataTransformer afin de pouvoir faire notre logique et vérifier si le code est valide et non expiré.

input and data-transformer
<?php

// src/DTO/ActivationCodeInput.php

namespace App\DTO;

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

class ActivationCodeInput
{
    /**
     * @Assert\NotBlank()
     * @Groups({"user:write"})
     */
    public $code;

    /**
     * @Assert\Email()
     * @Assert\Length(max="180")
     * @Groups({"user:write"})
     */
    public $email;
}

On créé la route dans l'entité User et on indique à input notre DTO

/**
 * @ORM\Entity(repositoryClass=UserRepository::class)
 * @ApiResource(
 *      normalizationContext={"groups"={"user:read"}},
 *      denormalizationContext={"groups"={"user:write"}},
 *       collectionOperations={
 *           "get",
 *           "post",
 *           "activationCode"={
 *               "method"="POST",
 *               "path"="activation-code",
 *               "input": ActivationCodeInput::class,
 *               "denormalization_context"={"groups"={"activation:write"}}
 *           }
 *       }
 * )
 */
class User implements UserInterface

Notre DataTransformer nous permet de récupérer notre DTO. Donc $data dans la méthode transform est notre objet ActivationCodeInput

<?php

// src/DataTransformer/ActivationCodeDataTransformer.php

namespace App\DataTransformer;

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

final class ActivationCodeDataTransformer 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 it's a user we transformed the data already
        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'] === 'activationCode');
    }
}

Notre entité User

route on user entity

Notre DTO input ActivationCodeInput

DTO ActivationCodeInput

Notre DataTransformer ActivationCodeDataTransformer

ActivationCodeDataTransformer

Vérifier si le code est valide et s'il n'est pas expiré

Nous allons créer une classe, et un validateur qui vérifiera le code et sa date. Ce validateur pourra nous indiquer si :

  • l'utilisateur existe (en cherchant l'utilisateur avec l'email)
  • vérifier que le code est valide
  • vérifier que la date est valide
<?php

// src/DTO/ActivationCodeValidator.php

namespace App\DTO;

use App\Validator\Constraint\IsValidActivationCode;

/**
 * @IsValidActivationCode
*/
class ActivationCodeValidator
{
    public $code;
    public $email;
}

Nous allons créer le validateur IsValidActivationCode

<?php

// src/Validator/Constraint/IsValidActivationCode.php

namespace App\Validator\Constraint;

use Symfony\Component\Validator\Constraint;

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

// src/Validator/Constraint/IsValidActivationCodeValidator.php

namespace App\Validator\Constraint;

use App\DTO\ActivationCodeValidator;
use App\Entity\CheeseListing;
use App\Repository\UserRepository;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;

class IsValidActivationCodeValidator extends ConstraintValidator
{
    private $userRepository;

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

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

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

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

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

Notre IsValidActivationCodeValidator nous permet de vérifier si l'email existe en base, si le code est expiré et s'il est valide. Il y a un return dans la condition car nous n'avons pas besoin de continuer les vérifications quand il rencontre une violation.

Nous créons dans notre ActivationCodeDataPersister et nous faisons la vérifications des données sur notre nouvelle classe ActivationCodeValidator, si il n'y a pas d'erreur, on peut continuer et on valide notre User avec setIsEnabled(true)

<?php

// src/DataPersister/ActivationCodeDataPersister.php

namespace App\DataPersister;

use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface;
use App\DTO\ActivationCodeInput;
use App\DTO\ActivationCodeValidator;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Validator\Validator\ValidatorInterface;

class ActivationCodeDataPersister implements ContextAwareDataPersisterInterface
{
    private $validator;
    private $userRepository;
    private $entityManager;

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

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

    public function persist($data, array $context = [])
    {
        $activationCodeValidator = new ActivationCodeValidator();
        $activationCodeValidator->code = $data->code;
        $activationCodeValidator->email = $data->email;

        $errors = $this->validator->validate($activationCodeValidator);
        if ($errors->count() > 0) {
            return $errors;
        }

        // Once we know everything is valid, we can activate our User
        $user =  $this->userRepository->findOneBy(['email' => $data->email]);
        $user->setIsEnabled(true);

        // We expire the code by setting the date to now
        $user->setActivationCodeExpiresAt(new \DateTime());

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

        return new JsonResponse(['message' => 'Your address is enabled']);
    }

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

Notre condition dans supportsTransformation vérifie maintenant si on est sur la route activationCode pour être sûr de rentrer dans ce Transformer uniquement avec cette route

prev next

Commentaires

Connectez-vous pour laisser un commentaire