API PLatform - Sécurisation et extension du token de rafraîchissement

Dans le TD4, nous avons utilisé le bundle gesdinet/jwt-refresh-token-bundle afin de gérer le token de rafraîchissement permettant de rafraîchir notre JWT d’authentification périodiquement.

Cependant, bien que populaire, ce bundle possède (actuellement) un défaut majeur : les tokens sont stockés en base (nécessaire pour pouvoir les invalider au besoin) mais ne sont pas hachés (chiffrés). Cela veut donc dire que si la base fuite et est récupérée par un utilisateur malicieux, il pourra se connecter et obtenir des JWTs et de nouveaux tokens de rafraichissement de manière illimitée pour n’importe quel utilisateur actuellement connecté ! Ce n’est pas aussi grave que de stocker en clair un mot de passe (car on peut facilement invalider tous les tokens de rafraichissement en les supprimant) mais cela reste problématique.

Il est donc fortement recommandé de :

Bref, avec le bundle que nous utilisons actuellement, il n’est pas (directement) possible de chiffrer nos tokens, et il n’y a pas vraiment de bundle alternatif que nous pourrions utiliser. Cependant, nous pouvons mettre en place diverses solutions :

Dans cette note complémentaire, nous allons vous développer la première et la deuxième solution.

Nous allons aussi voir :

Première solution : coder soi-même le mécanisme de rafraîchissement

Cette solution n’est pas si compliquée, car nous allons adapter le code au besoin du projet. Cela va nous demander de mettre en place plusieurs services, mais qui seront assez légers. Aussi, nous allons continuer de mettre en application les bonnes pratiques de développement en définissant et en injectant des interfaces au lieu des services concrets, ainsi cette partie de l’application sera fortement modulable.

Cette solution vient donc remplacer complètement le bundle gesdinet/jwt-refresh-token-bundle.

Nous n’avons pas besoin de reprendre toutes les fonctionnalités proposées par le bundle gesdinet/jwt-refresh-token-bundle. Globalement, le code développé permettra de :

Nous commencerons par définir une entité RefreshToken, son interface et son repository.

Ensuite, nous allons découper le travail entre plusieurs services :

Enfin, il ne restera plus qu’à gérer les différents événements :

Définir les routes

Tout d’abord, nous allons commencer par définir les deux routes (rafraichissement et invalidation) que nous utiliserons plus tard :

Nous pouvons faire cela directement dans config/routes.yaml :

#config/routes.yaml
...
api_token_refresh:
    path: /api/token/refresh
    methods: ['POST']

api_token_invalidate:
    path: /api/token/invalidate
    methods: ['POST']

Les paramètres

Maintenant, nous allons mettre en place un certain nombre de paramètres que nous injecterons dans nos services. Pour cela, il faut éditer le fichier config/services.yaml :

#config/services.yaml
parameters:
    refresh_token.name: refresh_token
    refresh_token.refresh_path: api_token_refresh
    refresh_token.ttl: 2592000
    refresh_token.cookie.enabled: true
    refresh_token.cookie.remove_token_from_body: true
    refresh_token.cookie.path: /api/token

services:
    ...

Nous aurions pu à la place utiliser ce format (plus lisible) :

parameters:
    refresh_token:
        name: refresh_token
        refresh_path: api_token_refresh
        ttl: 2592000
        cookie:
            enabled: true
            remove_token_from_body: true
            path: /api/token

Mais alors, nous ne pourrions qu’injecter un paramètre refresh_token sous la forme d’un tableau (contenant les sous-entrées), ce qui n’est pas forcément le plus pratique en l’état. Cela aurait néanmoins l’avantage de pouvoir faire en sorte que certains paramètres soient optionnels.

Nous allons donc nous en tenir à la première version.

Intéressons-nous donc à ces paramètres :

Comme cité plus haut, le seul inconvénient de cette solution est que tous les paramètres sont obligatoires, même si on n’utilise pas les cookies, par exemple.

L’entité RefreshToken et son repository

Afin de stocker le token dans la base de données, nous allons créer une entité RefreshToken. Mais tout d’abord, nous pouvons créer une interface générale qui définit le contrat d’un token de rafraichissement (dans src/Entity) :

#src/Entity
<?php
namespace App\Entity;

use DateTime;

interface RefreshTokenInterface
{
    public function getRefreshToken(): ?string;

    public function setRefreshToken(string $refreshToken): static;

    public function getExpiresAt(): ?DateTime;

    public function setExpiresAt(DateTime $expiresAt): static;

    public function getUsername(): ?string;

    public function setUsername(string $username): static;

    public function hasExpired(): bool;
}

On définit ensuite l’entité qui implémente l’interface (et qui sera sauvegardée grâce à Doctrine) :

#src/Entity
<?php
namespace App\Entity;

use App\Repository\RefreshTokenRepository;
use DateTime;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;

#[ORM\Entity(repositoryClass: RefreshTokenRepository::class)]
#[UniqueEntity('refreshToken')] //Permet de vérifier que le token est unique (du côté application)
class RefreshToken implements RefreshTokenInterface
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    //Le "unique: true" permet de rajouter une contrainte d'unicité sur cet attribut
    #[ApiProperty(readable: false)]
    #[ORM\Column(length: 255, unique: true)]
    private ?string $refreshToken = null;

    #[ORM\Column]
    private ?DateTime $expiresAt = null;

    #[ORM\Column(length: 255)]
    private ?string $username = null;

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getRefreshToken(): ?string
    {
        return $this->refreshToken;
    }

    public function setRefreshToken(string $refreshToken): static
    {
        $this->refreshToken = $refreshToken;

        return $this;
    }

    public function getExpiresAt(): ?DateTime
    {
        return $this->expiresAt;
    }

    public function setExpiresAt(DateTime $expiresAt): static
    {
        $this->expiresAt = $expiresAt;

        return $this;
    }

    public function getUsername(): ?string
    {
        return $this->username;
    }

    public function setUsername(string $username): static
    {
        $this->username = $username;

        return $this;
    }

    /*
    - new DateTime() génère un objet correspondant à l'instant où ext ecécuté le code.
    - On peut comparer deux dates avec >, <, =, etc
    */
    public function hasExpired(): bool
    {
        return new DateTime() >= $this->expiresAt;
    }
}

Et enfin, ont créé le repository correspondant (dans src/Repository) :

<?php
#src/Repository
namespace App\Repository;

use App\Entity\RefreshToken;
use DateTime;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @extends ServiceEntityRepository<RefreshToken>
 */
class RefreshTokenRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, RefreshToken::class);
    }

    //Méthode pour supprimer les tokens invalides
    public function removeExpiredTokens() : void {
        $queryBuilder = $this->createQueryBuilder('rt')
                        ->delete()
                        ->where("rt.expiresAt <= :date")
                        ->setParameter('date', new DateTime());
        $query = $queryBuilder->getQuery();
        $query->execute();
    }
}

Le hasher

Le hasher est le service qui nous permettra de chiffrer le token (avec SHA-256, dans notre implémentation) avant de le sauvegarder dans la base de données.

Pour cela, nous allons utiliser la fonction hash_hmac de PHP qui permet de chiffrer une chaîne de caractères et de lui ajouter un poivre. Dans notre cas, le poivre sera la variable d’environnement APP_SECRET définie lors de l’initialisation d’un projet Symfony (cela pourrait être autre chose).

Notons toutefois que dans notre cas, le poivre est assez optionnel : le token généré par l’application est une chaîne aléatoire, donc, son entropie et sa robustesse sont déjà assez élevées. Si l’on ne souhaite donc pas poivrer le token, on utilisera plutôt la fonction hash.

Par soucis de qualité et pour rendre notre solution modulable, nous allons aussi définir et utiliser ce service au travers d’une interface (pour ce service et les suivants).

#src/Service/RefreshToken/Hasher
<?php
namespace App\Service\RefreshToken\Hasher;

interface RefreshTokenHasherInterface
{
    public function hashToken(string $plainTextToken) : string;
}
#src/Service/RefreshToken/Hasher
<?php

namespace App\Service\RefreshToken\Hasher;

use Symfony\Component\DependencyInjection\Attribute\Autowire;

class RefreshTokenHasher implements RefreshTokenHasherInterface
{

    public function __construct(#[Autowire(env: 'APP_SECRET')] private string $secret)
    {}

    public function hashToken(string $plainTextToken): string
    {
        return hash_hmac('sha256', $plainTextToken, $this->secret);
    }
}

Le manager

Le service suivant va nous permettre de gérer les différentes opérations de création, lecture et écriture du token.

Ses fonctionnalités sont les suivantes :

Ce service va donc utiliser divers services : notre hasher, le repository et l’entity manager de Symfony.

#src/Service/RefreshToken/Manager
<?php

namespace App\Service\RefreshToken\Manager;

use App\Entity\RefreshTokenInterface;
use Symfony\Component\Security\Core\User\UserInterface;

interface RefreshTokenManagerInterface
{
    public function createRefreshToken(string $plainTextToken, UserInterface $user, int $ttl) : RefreshTokenInterface;

    public function getRefreshToken(string $plainTextToken) : ?RefreshTokenInterface;

    public function removeRefreshToken(RefreshTokenInterface $refreshToken);

    public function removeExpiredRefreshTokens();
}
#src/Service/RefreshToken/Manager
<?php

namespace App\Service\RefreshToken\Manager;

use App\Entity\RefreshToken;
use App\Entity\RefreshTokenInterface;
use App\Repository\RefreshTokenRepository;
use App\Service\RefreshToken\Hasher\RefreshTokenHasherInterface;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Security\Core\User\UserInterface;

class RefreshTokenManager implements RefreshTokenManagerInterface
{

    public function __construct(
        private RefreshTokenHasherInterface $hasher,
        private RefreshTokenRepository $refreshTokenRepository,
        private EntityManagerInterface $entityManager
    )
    {}

    public function createRefreshToken(string $plainTextToken, UserInterface $user, int $ttl): RefreshTokenInterface
    {
        $token = new RefreshToken();
        $token->setRefreshToken($this->hasher->hashToken($plainTextToken));
        $token->setExpiresAt(new DateTime(date('Y-m-d H:i:s', time() + $ttl)));
        $token->setUsername($user->getUserIdentifier());
        $this->entityManager->persist($token);
        $this->entityManager->flush();
        return $token;
    }

    public function getRefreshToken(string $plainTextToken): ?RefreshTokenInterface
    {
        /**
         * @var RefreshToken|null $token
         */
        $token =  $this->refreshTokenRepository->findOneBy(["refreshToken" => $this->hasher->hashToken($plainTextToken)]);
        return $token;
    }

    public function removeRefreshToken(RefreshTokenInterface $refreshToken)
    {
        $this->entityManager->remove($refreshToken);
        $this->entityManager->flush();
    }

    public function removeExpiredRefreshTokens()
    {
        $this->refreshTokenRepository->removeExpiredTokens();
    }
}

Le générateur de tokens

Le générateur va simplement nous permettre de générer une chaîne aléatoire qui constituera notre token de rafraîchissement (en clair). Par la suite, ce token sera transmis à l’utilisateur puis utilisé pour créer (et chiffrer) le token en base de données.

Ce service utilisera notre manager afin de vérifier si le token généré n’existe pas déjà (et en recréer un si nécessaire).

#src/Service/RefreshToken/Generator
<?php

namespace App\Service\RefreshToken\Generator;

interface RefreshTokenGeneratorInterface
{
    public function generateUniqueToken(): string;
}
#src/Service/RefreshToken/Generator
<?php
namespace App\Service\RefreshToken\Generator;

use App\Service\RefreshToken\Manager\RefreshTokenManagerInterface;

class RefreshTokenGenerator implements RefreshTokenGeneratorInterface
{

    public function __construct(private RefreshTokenManagerInterface $refreshTokenManager)
    {}

    public function generateUniqueToken(): string
    {
        $plainTextToken = null;
        $exists = true;
        while ($exists) {
            $plainTextToken = bin2hex(random_bytes(64));
            $exists = null !== $this->refreshTokenManager->getRefreshToken($plainTextToken);
        }
        return $plainTextToken;
    }
}

L’extracteur de token (depuis une requête)

L’extracteur de token est un service qui va nous permettre de récupérer le token de rafraichissement (en clair) transmis par l’utilisateur lors d’une requête.

Dans notre implémentation, nous allons chercher ce token à divers endroits : dans les données de la requête HTTP (query string ou corps), dans le payload JSON ou bien dans un cookie. Nous allons injecter le nom du token de rafraichissement (définit dans les paramètres de l’application) et rechercher le token dans la requête en utilisant ce nom.

#src/Service/RefreshToken/Extractor
<?php

namespace App\Service\RefreshToken\Extractor;

use Symfony\Component\HttpFoundation\Request;

interface RefreshTokenExtractorInterface
{
    public function extractRefreshTokenFromRequest(Request $request) : ?string;
}
#src/Service/RefreshToken/Extractor
<?php

namespace App\Service\RefreshToken\Extractor;

use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;

class RefreshTokenExtractor implements RefreshTokenExtractorInterface
{

    public function __construct(
        #[Autowire(param: 'refresh_token.name')] private string $refreshTokenName,
    )
    {
    }

    public function extractRefreshTokenFromRequest(Request $request): ?string
    {
        if($request->request->has($this->refreshTokenName)) {
            return $request->request->get($this->refreshTokenName);
        }
        $data = json_decode($request->getContent(), true);
        if(isset($data[$this->refreshTokenName])) {
            return $data[$this->refreshTokenName];
        }
        if($request->cookies->has($this->refreshTokenName)) {
            return $request->cookies->get($this->refreshTokenName);
        }
        return null;
    }
}

Gestion des cookies

La prochaine étape est de pouvoir créer et demander la suppression d’un cookie contenant le token de rafraîchissement (si l’option de stocker le token dans un cookie est activée) dans la réponse transmisse au client (donc, lors de l’authentification ou bien lors du rafraîchissement du token où un nouveau token de rafraîchissement est généré puis transmis). Ce cookie sera généré avec l’option secure, httpOnly et la politique sameSite définie sur lax.

Notre implémentation utilisera deux paramètres définis dans notre configuration : le nom du token de rafraîchissement et aussi le path configuré pour le cookie.

#src/Service/RefreshToken/Cookie
<?php

namespace App\Service\RefreshToken\Cookie;

use DateTime;
use Symfony\Component\HttpFoundation\Response;

interface RefreshTokenCookieServiceInterface
{
    public function writeRefreshTokenCookieInResponse(string $plainTextToken, DateTime $expiresAt, Response $response) : void;

    public function clearRefreshTokenCookieInResponse(Response $response) : void;

}
#src/Service/RefreshToken/Cookie
<?php

namespace App\Service\RefreshToken\Cookie;

use DateTime;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Response;

class RefreshTokenCookieService implements RefreshTokenCookieServiceInterface
{
    public function __construct(
        #[Autowire(param: 'refresh_token.name')] private string $refreshTokenName,
        #[Autowire(param: 'refresh_token.cookie.path')] private string $path
    )
    {}

    public function writeRefreshTokenCookieInResponse(string $plainTextToken, DateTime $expiresAt, Response $response): void
    {
        $cookie = new Cookie(
            name: $this->refreshTokenName,
            value: $plainTextToken,
            expire: $expiresAt,
            path: $this->path,
            secure: true,
            httpOnly: true,
            sameSite: 'lax'
        );
        $response->headers->setCookie($cookie);
    }

    public function clearRefreshTokenCookieInResponse(Response $response): void
    {
        $response->headers->clearCookie(
            name: $this->refreshTokenName,
            path: $this->path,
            secure: true,
            httpOnly: true,
            sameSite: 'lax'
        );
    }
}

Délivrer le token lors de l’authentification

Maintenant que tous nos services utilitaires sont en place, nous allons pouvoir faire en sorte de générer et transmettre le token de rafraîchissement lors de l’authentification de l’utilisateur.

Pour cela, nous allons créer un event listener qui écoutera l’événement AuthenticationSuccessEvent (émis par le bundle LexikJWTAuthenticationBundle) et ajouter notre logique :

Cet event listener va donc utiliser les divers services et paramètres définis dans notre solution.

<?php
#src/EventListener
namespace App\EventListener;

use App\Service\RefreshToken\Cookie\RefreshTokenCookieServiceInterface;
use App\Service\RefreshToken\Generator\RefreshTokenGeneratorInterface;
use App\Service\RefreshToken\Manager\RefreshTokenManagerInterface;
use Lexik\Bundle\JWTAuthenticationBundle\Event\AuthenticationSuccessEvent;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

class AuthenticationSuccessEventListener
{
    public function __construct(
        private RefreshTokenGeneratorInterface                                  $refreshTokenGenerator,
        private RefreshTokenManagerInterface                                    $refreshTokenManager,
        private RefreshTokenCookieServiceInterface                              $refreshTokenCookieService,
        #[Autowire(param: 'refresh_token.name')] private string                 $refreshTokenName,
        #[Autowire(param: 'refresh_token.ttl')] private int                     $ttl,
        #[Autowire(param: 'refresh_token.cookie.enabled')] private bool         $cookieEnabled,
        #[Autowire(param: 'refresh_token.cookie.remove_token_from_body')] private bool $removeTokenFromBody,
    )
    {
    }

    #[AsEventListener('lexik_jwt_authentication.on_authentication_success')]
    public function onAuthenticationSuccessResponse(AuthenticationSuccessEvent $event) : void
    {
        $uniqueToken = $this->refreshTokenGenerator->generateUniqueToken();
        $refreshToken = $this->refreshTokenManager->createRefreshToken($uniqueToken, $event->getUser(), $this->ttl);
        if(!($this->cookieEnabled && $this->removeTokenFromBody)) {
            $data = $event->getData();
            $data[$this->refreshTokenName] = $uniqueToken;
            $event->setData($data);
        }
        if($this->cookieEnabled) {
            $response = $event->getResponse();
            $this->refreshTokenCookieService->writeRefreshTokenCookieInResponse($uniqueToken, $refreshToken->getExpiresAt(), $response);
        }
    }
}

Rafraîchir le JWT grâce au token

Nous allons maintenant gérer la route /api/token/refresh permettant de rafraîchir le JWT d’authentification (et de générer un nouveau token de rafraichissement).

Pour cela, nous allons avoir besoin de créer un Authenticator. Ce type de service permet d’authentifier un utilisateur (à travers une ou plusieurs routes) et de charger ses données globalement (pour le temps de la requête) afin qu’elles soient accessibles dans les diverses actions.

L’action de rafraichissement est une action d’authentification où le token de rafraîchissement est utilisé pour authentifier l’utilisateur. En comparaison, le bundle LexikJWTAuthenticationBundle utilise son propre authenticator pour gérer l’authentification via le JWT lors de chaque requête sécurisée.

Ici, notre objectif est simple : si le token de rafraichissement donné par l’utilisateur est valide, on appellera la méthode onAuthenticationSuccess du gestionnaire d’authentification du bundle LexikJWTAuthenticationBundle qui déclenchera l’événement AuthenticationSuccessEvent permettant ainsi de générer à la fois un nouveau JWT d’authentification ainsi qu’un nouveau token de rafraichissement (grâce à ce que nous avons codé dans la section précédente).

On devra aussi gérer les divers cas d’erreurs à travers cette classe et renvoyer une réponse d’erreur (au format JSON) adéquate.

Ce service va utiliser divers autres services et paramètres :

#src/Security/RefreshTokenAuthenticator
<?php
namespace App\Security;

use App\Service\RefreshToken\Extractor\RefreshTokenExtractorInterface;
use App\Service\RefreshToken\Manager\RefreshTokenManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Component\Security\Http\HttpUtils;

class RefreshTokenAuthenticator extends AbstractAuthenticator
{

    public function __construct(
        private RefreshTokenManagerInterface $manager,
        private RefreshTokenExtractorInterface $extractor,

        #[Autowire(service: 'lexik_jwt_authentication.handler.authentication_success')]
        private readonly AuthenticationSuccessHandlerInterface $lexikAuthenticationSuccessHandler,
        private readonly HttpUtils $httpUtils,

        #[Autowire(param: 'refresh_token.refresh_path')]
        private string $refreshPath
    )
    {
    }

    //Si la méthode renvoie false, l'authenticator ne sera pas utilisé pour traiter la route demandée par la requête courante.
    public function supports(Request $request): ?bool
    {
        return $this->httpUtils->checkRequestPath($request, $this->refreshPath);
    }

    public function authenticate(Request $request): Passport
    {
        $plainTextToken = $this->extractor->extractRefreshTokenFromRequest($request);
        if(!$plainTextToken) {
            throw new AuthenticationException("Refresh token not found.");
        }
        $refreshToken = $this->manager->getRefreshToken($plainTextToken);
        if(!$refreshToken) {
            throw new AuthenticationException("Invalid refresh token.");
        }
        if($refreshToken->hasExpired()) {
            $this->manager->removeRefreshToken($refreshToken);
            throw new AuthenticationException("The refresh token has expired.");
        }
        $identifier = $refreshToken->getUsername();
        $this->manager->removeRefreshToken($refreshToken);

        //On délivre un "passeport" authentifiant l'utilisateur pour la requête en cours.
        return new SelfValidatingPassport(new UserBadge($identifier));
    }

    //Méthode automatiquement déclenchée si authenticate termine et délivre le passeport.
    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        //On délenche appelle la méthode onAuthenticationSuccess du bundle Lexik, permettant de gérer les JWT qui permettra de déclencher l'événement AuthenticationSuccessEvent permettant ainsi de générer un nouveau JWT et un nouveau token de rafraîchissement.
        return $this->lexikAuthenticationSuccessHandler->onAuthenticationSuccess($request, $token);
    }

    //Méthode automatiquement déclenchée si authenticate échoue (si elle lève une AuthenticationException).
    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        $data = [
            "error" => true,
            'message' => $exception->getMessage()
        ];

        return new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
    }
}

Ensuite, il faut déclarer qu’on souhaite utiliser cet Authenticator au niveau du fichier de configuration security.yaml :

#config/packages/security.yaml
security:
    firewalls:
        ...
        main:
            ...
            custom_authenticators:
                - App\Security\RefreshTokenAuthenticator

Invalider le token

Enfin, il ne reste plus qu’à gérer l’invalidation du token. Pour cela, nous allons :

Notre implémentation utilisera donc :

On commence donc par éditer encore une fois security.yaml :

#config/packages/security.yaml
security:
    firewalls:
        ...
        main:
            ...
            logout:
                path: api_token_invalidate
                delete_cookies: ['BEARER'] #Afin de supprimer le cookie contenant le JWT d'uathentification

Note importante : on ne peut pas juste préciser le nom du cookie contenant le token de rafraichissement car il est définit avec un path. Il faut indiquer le path en question lors de la suppression (ce que fait notre service de gestion des cookies).

On code ensuite l’event listener captant et traitant l’événement de déconnexion :

#src/EventListener
<?php

namespace App\EventListener;

use App\Service\RefreshToken\Cookie\RefreshTokenCookieServiceInterface;
use App\Service\RefreshToken\Extractor\RefreshTokenExtractorInterface;
use App\Service\RefreshToken\Manager\RefreshTokenManagerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Http\Event\LogoutEvent;

class LogoutEventListener
{

    public function __construct(
        private RefreshTokenExtractorInterface $refreshTokenExtractor,
        private RefreshTokenManagerInterface $refreshTokenManager,
        private RefreshTokenCookieServiceInterface $refreshTokenCookieService,
    )
    {
    }

    #[AsEventListener]
    public function onLogout(LogoutEvent $event) : void {
        $response = new JsonResponse();
        $this->refreshTokenCookieService->clearRefreshTokenCookieInResponse($response);
        $plainTextToken = $this->refreshTokenExtractor->extractRefreshTokenFromRequest($event->getRequest());
        if(!$plainTextToken) {
            $response->setData(["message" => "Refresh token not found"]);
            $response->setStatusCode(Response::HTTP_BAD_REQUEST);
            $event->setResponse($response);
            return;
        }

        $refreshToken = $this->refreshTokenManager->getRefreshToken($plainTextToken);
        if(!$refreshToken) {
            $response->setData(["message" => "Refresh token not found"]);
            $response->setStatusCode(Response::HTTP_NOT_FOUND);
            $event->setResponse($response);
            return;
        }
        $this->refreshTokenManager->removeRefreshToken($refreshToken);
        if($refreshToken->hasExpired()) {
            $response->setData(["message" => "The refresh token has already expired"]);
            $response->setStatusCode(Response::HTTP_OK);
            $event->setResponse($response);
            return;
        }
        $response->setData(["message" => "Tokens have been invalidated"]);
        $response->setStatusCode(Response::HTTP_OK);
        $event->setResponse($response);
    }
}

À ce stade, notre système est complètement prêt et fonctionnel ! Il n’y a plus qu’à mettre à jour la base de données. Le token stocké en base est maintenant chiffré et donc, un attaquant ne pourra pas “hacker” les clients connectés en cas de fuite de la base de données.

Ajouter des informations au token

Actuellement, notre token (stocké en base) contient des informations limitées. Dans certains cas, il peut être judicieux d’ajouter des informations complémentaires :

Notre implémentation nous permet facilement d’ajouter ces informations :

Par exemple :

#src/Entity
<?php
namespace App\Entity;

use App\Repository\RefreshTokenRepository;
use DateTime;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;

#[ORM\Entity(repositoryClass: RefreshTokenRepository::class)]
#[UniqueEntity('refreshToken')]
class RefreshToken implements RefreshTokenInterface
{
    ...

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $ip = null;

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $userAgent = null;

    #[ORM\Column]
    private ?DateTime $createdAt = null;

    public function getIp(): ?string
    {
        return $this->ip;
    }

    public function setIp(?string $ip): static
    {
        $this->ip = $ip;

        return $this;
    }

    public function getUserAgent(): ?string
    {
        return $this->userAgent;
    }

    public function setUserAgent(?string $userAgent): static
    {
        $this->userAgent = $userAgent;

        return $this;
    }

    public function getCreatedAt(): ?DateTime
    {
        return $this->createdAt;
    }

    public function setCreatedAt(DateTime $createdAt): static
    {
        $this->createdAt = $createdAt;

        return $this;
    }
}
<?php

namespace App\Service\RefreshToken\Manager;

use App\Entity\RefreshToken;
use App\Entity\RefreshTokenInterface;
use App\Repository\RefreshTokenRepository;
use App\Service\RefreshToken\Hasher\RefreshTokenHasherInterface;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\User\UserInterface;

class RefreshTokenManager implements RefreshTokenManagerInterface
{

    public function __construct(
        private RefreshTokenHasherInterface $hasher,
        private RefreshTokenRepository $refreshTokenRepository,
        private EntityManagerInterface $entityManager,
        private RequestStack $requestStack
    )
    {}

    public function createRefreshToken(string $plainTextToken, UserInterface $user, int $ttl): RefreshTokenInterface
    {
        $token = new RefreshToken();
        $token->setRefreshToken($this->hasher->hashToken($plainTextToken));
        $token->setExpiresAt(new DateTime(date('Y-m-d H:i:s', time() + $ttl)));
        $token->setUsername($user->getUserIdentifier());

        //Écriture des nouvelles propriétés
        $request = $this->requestStack->getCurrentRequest();
        $token->setIp($request->getClientIp());
        $token->setUserAgent($request->headers->get('User-Agent'));
        $token->setCreatedAt(new DateTime());

        $this->entityManager->persist($token);
        $this->entityManager->flush();
        return $token;
    }
}

Il faudra penser à bien mettre à jour la base de données en conséquence.

Ajouter des commandes pour supprimer les tokens invalides et révoquer des tokens

Comme dans le bundle original, on peut ajouter des commandes afin de supprimer les tokens invalides ou bien pour révoquer certains tokens. Cette implémentation est encore une fois très largement inspirée de celle du bundle gesdinet/jwt-refresh-token-bundle.

#src/Command
<?php
namespace App\Command;

use App\Service\RefreshToken\Manager\RefreshTokenManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(
    name: 'refresh-token:revoke',
    description: 'Révoque un token de rafraîchissement',
)]
class RefreshTokenRevoke extends Command
{
    public function __construct(private RefreshTokenManagerInterface $refreshTokenManager) {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this
            ->addArgument('token', InputArgument::REQUIRED, 'Le token de rafraîchissement à révoquer');
        ;
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);
        $plainTextRefreshToken = $input->getArgument('token');
        $refreshToken = $this->refreshTokenManager->getRefreshToken();
        if($refreshToken === null) {
            $io->error("Ce token n'existe pas.");
            return Command::FAILURE;
        }
        $this->refreshTokenManager->removeRefreshToken($refreshToken);
        $io->success("Le token a bien été révoqué !");
        return Command::SUCCESS;
    }
}
#src/Command
<?php
namespace App\Command;

use App\Service\RefreshToken\Manager\RefreshTokenManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(
    name: 'refresh-token:clear',
    description: 'Supprime les tokens de rafraîchissement expirés',
)]
class RefreshTokenClear extends Command
{
    public function __construct(private RefreshTokenManagerInterface $refreshTokenManager) {
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);
        $this->refreshTokenManager->removeExpiredRefreshTokens();
        $io->success("Les tokens expirés ont bien été supprimés !");
        return Command::SUCCESS;
    }
}

Deuxième solution : décorer jwt-refresh-token-bundle

Dans cette deuxième approche, nous utilisons toujours le bundle de base : gesdinet/jwt-refresh-token-bundle. Cependant, nous allons étendre son comportement dynamiquement grâce au design pattern décorateur (dont la mise en place et l’utilisation est facilité par Symfony) afin de chiffrer le token de rafraîchissement.

Cette solution sera développée dans la vue d’un stockage des données via un ORM (doctrine) mais il serait facilement possible de l’adapter et la rendre plus générique afin qu’elle fonctionne également pour MongoDB (SGBD NoSQL) car le bundle que nous utilisons permet aussi de stocker le token sur cette base.

Ajout d’un nouveau champ au token

Tout d’abord, nous allons ajouter une nouvelle propriété à la classe RefreshToken générée par le bundle :

#src/Entity
<?php
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Gesdinet\JWTRefreshTokenBundle\Entity\RefreshToken as BaseRefreshToken;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;

#[ORM\Entity]
#[ORM\Table(name: 'refresh_tokens')]
#[UniqueEntity('hashedRefreshToken')] //Permet de vérifier que le token est unique (du côté application)
class RefreshToken extends BaseRefreshToken
{
    //Le "unique: true" permet de rajouter une contrainte d'unicité sur cet attribut
    #[ORM\Column(length: 255, unique: true)]
    #[ApiProperty(readable: false)]
    private ?string $hashedRefreshToken = null;

    public function getHashedRefreshToken(): ?string
    {
        return $this->hashedRefreshToken;
    }

    public function setHashedRefreshToken(string $hashedRefreshToken): static
    {
        $this->hashedRefreshToken = $hashedRefreshToken;

        return $this;
    }
}

Cette propriété représentera notre token chiffré dans la base.

Réécriture des fichiers de configuration de doctrine

Nous allons maintenant proposer une version mise à jour du fichier RefreshToken.orm.xml qui permet de définir les propriétés de base du token (de la classe qu’étend notre entité RefreshToken). Notre objectif est de ne plus sauvegarder le token en clair (non haché) généré par le bundle. Attention, il sera toujours disponible dans l’entité, mais pas sauvegardé : nous nous en sevrions pour écrire dans le champ du token haché lors de la sauvegarde.

On commence par placer ces deux fichiers dans un dossier config/doctrine/jwt-refresh-token-bundle :

Fichier RefreshToken.orm.xml :

<?xml version="1.0" encoding="UTF-8"?>

<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
                  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                  xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping
                    http://doctrine-project.org/schemas/orm/doctrine-mapping.xsd">

    <mapped-superclass name="Gesdinet\JWTRefreshTokenBundle\Entity\RefreshToken" table="refresh_tokens" repository-class="Gesdinet\JWTRefreshTokenBundle\Entity\RefreshTokenRepository">
        <id name="id" type="integer">
            <generator/>
        </id>
        <field name="username" length="255" column="username"/>
        <field name="valid" type="datetime"/>
    </mapped-superclass>
</doctrine-mapping>

Ensuite, on indique à doctrine de charger ce fichier au lieu de celui de base définit par le bundle :

doctrine:
    orm:
        ...
        mappings:
            ...
            GesdinetJWTRefreshTokenBundle:
                type: xml
                dir: '%kernel.project_dir%/config/doctrine/jwt-refresh-token-bundle'
                prefix: 'Gesdinet\JWTRefreshTokenBundle\Entity'
                alias: GesdinetJWTRefreshTokenBundle

Ajout du service hasher

Comme dans la solution précédente, on ajoute un service hasher qui nous permettra de chfifrer le token (avec SHA-256, dans notre implémentation) avant de le sauvegarder dans la base de données.

#src/Service/RefreshToken/Hasher
<?php
namespace App\Service\RefreshToken\Hasher;

interface RefreshTokenHasherInterface
{
    public function hashToken(string $plainTextToken) : string;
}
#src/Service/RefreshToken/Hasher
<?php

namespace App\Service\RefreshToken\Hasher;

use Symfony\Component\DependencyInjection\Attribute\Autowire;

class RefreshTokenHasher implements RefreshTokenHasherInterface
{

    public function __construct(#[Autowire(env: 'APP_SECRET')] private string $secret)
    {}

    public function hashToken(string $plainTextToken): string
    {
        return hash_hmac('sha256', $plainTextToken, $this->secret);
    }
}

Décoration du manager

Le service RefreshTokenManagerInterface du bundle permet de réaliser les différentes opérations CRUD liées au token.

Comme nous avons supprimé le token non haché de l’entité RefreshToken, il faut changer le comportement de cette classe afin de hacher le token en clair avant d’effectuer la requête de lecture en base. Cependant, le reste des opérations peut rester tel quel. Cette situation est donc adaptée à la mise en place du pattern décorateur en étendant l’opération de lecture du token pour ajouter le système de chiffrement.

Symfony facilite la décoration de services grâce à l’attribut %[AsDecorator('nom_service')] posé sur la classe décoratrice. Il faut ensuite que cette classe implémente la même interface que le service qu’elle décore. Enfin, on récupère l’instance du service décorée via le constructeur grâce à l’attribut #[AutowireDecorated], ce qui nous permettra de déléguer certaines opérations au service de base.

Nous allons donc créer un service RefreshTokenManagerDecorator décorant le service gesdinet.jwtrefreshtoken.refresh_token_manager du bundle. Il n’y a pas d’autre déclaration à faire en dehors de la classe : lorsqu’une classe aura besoin d’utiliser le service gesdinet.jwtrefreshtoken.refresh_token_manager, nore version sera injectée à la place.

#src/Service/RefreshToken/Manager
<?php
namespace App\Service\RefreshToken\Manager;

use App\Repository\RefreshTokenRepository;
use App\Service\RefreshToken\Hasher\RefreshTokenHasherInterface;
use Gesdinet\JWTRefreshTokenBundle\Model\RefreshTokenInterface;
use Gesdinet\JWTRefreshTokenBundle\Model\RefreshTokenManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;

#[AsDecorator(decorates: 'gesdinet.jwtrefreshtoken.refresh_token_manager')]
class RefreshTokenManagerDecorator implements RefreshTokenManagerInterface
{
    public function __construct(
        //On obtient le service de base que l'on décore
        #[AutowireDecorated] private RefreshTokenManagerInterface $inner,
        private RefreshTokenRepository $refreshTokenRepository,
        private RefreshTokenHasherInterface $hasher,
    )
    {}

    //On modifie le comportement de "get"
    public function get($plainTextToken)
    {
        return $this->refreshTokenRepository->findOneBy(['hashedRefreshToken' => $this->hasher->hashToken($plainTextToken)]);
    }

    //Pour les autres opérations, on délègue simplement au service de base décoré
    public function save(RefreshTokenInterface $refreshToken, $andFlush = true) {$this->inner->save($refreshToken, $andFlush);}
    public function create(){return $this->inner->create();}
    public function getLastFromUsername($username) {return $this->inner->getLastFromUsername($username);}
    public function delete(RefreshTokenInterface $refreshToken) {$this->inner->delete($refreshToken);}
    public function revokeAllInvalid($datetime = null) {return $this->inner->revokeAllInvalid($datetime);}
    public function getClass() {return $this->inner->getClass();}
}

Décoration du generator

Enfin, la dernière étape consiste à décorer le service RefreshTokenGeneratorInterface (identifié par gesdinet.jwtrefreshtoken.refresh_token_generator) qui instancie le token. Nous allons laisser le service de base instancier l’entité token, lire le token en clair et remplir la propriété hashedRefreshToken avec le token chiffré.

#src/Service/RefreshToken/Generator
<?php

namespace App\Service\RefreshToken\Generator;

use App\Entity\RefreshToken;
use App\Service\RefreshToken\Hasher\RefreshTokenHasherInterface;
use Gesdinet\JWTRefreshTokenBundle\Generator\RefreshTokenGeneratorInterface;
use Gesdinet\JWTRefreshTokenBundle\Model\RefreshTokenInterface;
use Gesdinet\JWTRefreshTokenBundle\Model\RefreshTokenManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\User\UserInterface;

#[AsDecorator(decorates: 'gesdinet.jwtrefreshtoken.refresh_token_generator')]
class RefreshTokenGeneratorDecorator implements RefreshTokenGeneratorInterface
{
    public function __construct(
        #[AutowireDecorated] private RefreshTokenGeneratorInterface $inner,
        private RefreshTokenHasherInterface $hasher,
        //Notre service décoré précédemment sera injecté !
        private RefreshTokenManagerInterface $refreshTokenManager
    )
    {}
    public function createForUserWithTtl(UserInterface $user, int $ttl): RefreshTokenInterface
    {
        /**
         * @var RefreshToken $token
         */
        $token = $this->inner->createForUserWithTtl($user, $ttl);
        $token->setHashedRefreshToken($this->hasher->hashToken($token->getRefreshToken()));
        return $token;
    }
}

À ce stade, tout est fonctionnel ! Il ne reste plus qu’à mettre à jour la base de données, et tout devrait fonctionner.

Ajouter d’autres informations au token

Comme pour la précédente solution, il est possible d’ajouter des informations au token en modifiant RefreshToken puis RefreshTokenGeneratorDecorator.

Par exemple :

#src/Entity
<?php
namespace App\Entity;

use DateTime;
use Doctrine\ORM\Mapping as ORM;
use Gesdinet\JWTRefreshTokenBundle\Entity\RefreshToken as BaseRefreshToken;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;

#[ORM\Entity]
#[ORM\Table(name: 'refresh_tokens')]
#[UniqueEntity('hashedRefreshToken')]
class RefreshToken extends BaseRefreshToken
{
    ...

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $ip = null;

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $userAgent = null;

    #[ORM\Column]
    private ?DateTime $createdAt = null;

    public function getIp(): ?string
    {
        return $this->ip;
    }

    public function setIp(?string $ip): static
    {
        $this->ip = $ip;

        return $this;
    }

    public function getUserAgent(): ?string
    {
        return $this->userAgent;
    }

    public function setUserAgent(?string $userAgent): static
    {
        $this->userAgent = $userAgent;

        return $this;
    }

    public function getCreatedAt(): ?DateTime
    {
        return $this->createdAt;
    }

    public function setCreatedAt(DateTime $createdAt): static
    {
        $this->createdAt = $createdAt;

        return $this;
    }
}
<?php

namespace App\Service;

use App\Entity\RefreshToken;
use App\Service\RefreshToken\Hasher\RefreshTokenHasherInterface;
use DateTime;
use Gesdinet\JWTRefreshTokenBundle\Generator\RefreshTokenGeneratorInterface;
use Gesdinet\JWTRefreshTokenBundle\Model\RefreshTokenInterface;
use Gesdinet\JWTRefreshTokenBundle\Model\RefreshTokenManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\User\UserInterface;

#[AsDecorator(decorates: 'gesdinet.jwtrefreshtoken.refresh_token_generator')]
class RefreshTokenGeneratorDecorator implements RefreshTokenGeneratorInterface
{
    public function __construct(
        #[AutowireDecorated] private RefreshTokenGeneratorInterface $inner,
        private RefreshTokenHasherInterface $hasher,
        private RequestStack $requestStack,
        private RefreshTokenManagerInterface $customRefreshTokenManager
    )
    {}
    public function createForUserWithTtl(UserInterface $user, int $ttl): RefreshTokenInterface
    {
        /**
         * @var RefreshToken $token
         */
        $token = $this->inner->createForUserWithTtl($user, $ttl);
        $token->setHashedRefreshToken($this->hasher->hashToken($token->getRefreshToken()));
        $token->setCreatedAt(new DateTime());
        $request = $this->requestStack->getCurrentRequest();
        $token->setIp($request->getClientIp());
        $token->setUserAgent($request->headers->get('User-Agent'));
        return $token;
    }
}

Il faudra penser à bien mettre à jour la base de données en conséquence.

Gestion des tokens par l’utilisateur

Quelle que soit la solution retenue, il est possible de permettre à un utilisateur :

Cela permet notamment d’ajouter des fonctionnalités du type “se déconnecter de tel appareil”. Il faudra impérativement sécuriser ces routes. On pourra utiliser un state provider pour récupérer les tokens d’un utilisateur.

#src/State
<?php
namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Repository\RefreshTokenRepository;
use App\Repository\UtilisateurRepository;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

class RefreshTokenUserProvider implements ProviderInterface
{

    public function __construct(
        private UtilisateurRepository $utilisateurRepository,
        private RefreshTokenRepository $refreshTokenRepository)
    {
    }

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
    {
        $utilisateur = $this->utilisateurRepository->find($uriVariables['idUtilisateur']);
        return $this->refreshTokenRepository->findBy(['username' => $utilisateur->getUserIdentifier()]);
    }
}

Ensuite, on ajoute les routes sur l’entité RefreshToken.

Par exemple, avec la première solution :

#src/Entity
namespace App\Entity;

use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use App\Repository\RefreshTokenRepository;
use App\State\RefreshTokenUserProvider;
use DateTime;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;

#[ORM\Entity(repositoryClass: RefreshTokenRepository::class)]
#[UniqueEntity('refreshToken')]
#[ApiResource(
    operations: [
        new GetCollection(
            uriTemplate: '/utilisateurs/{idUtilisateur}/refresh_tokens',
            uriVariables: [
                "idUtilisateur" => new Link(
                    fromProperty: 'id',
                    fromClass: Utilisateur::class,
                    //On vérifie que l'utilisateur dont on récupère els tokens est bien celui connecté
                    security: "is_granted('ROLE_USER') and utilisateur === user",
                    securityObjectName: 'utilisateur',
                )
            ],
            //On utilise un provider car le token ne contient pas de référence directe vers un objet utilisateur (on pourrait changer cela cependant!)
            provider: RefreshTokenUserProvider::class
        ),
        new Delete(
            uriTemplate: '/refresh_tokens/{id}',
            security: "is_granted('ROLE_USER') and object.getUsername() === user.getUserIdentifier()"
        )
    ]
)]
class RefreshToken implements RefreshTokenInterface
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    //On ne doit pas sérialiser le refresh token lorsqu'il est renvoyé au client
    #[ApiProperty(readable: false)]
    #[ORM\Column(length: 255, unique: true)]
    private ?string $refreshToken = null;

    #[ORM\Column]
    private ?DateTime $expiresAt = null;

    #[ORM\Column(length: 255)]
    private ?string $username = null;

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $ip = null;

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $userAgent = null;

    #[ORM\Column]
    private ?DateTime $createdAt = null;

    ...
}

Ou bien, pour la deuxième solution :

#src/Entity
<?php

namespace App\Entity;

use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use App\State\RefreshTokenUserProvider;
use DateTime;
use Doctrine\ORM\Mapping as ORM;
use Gesdinet\JWTRefreshTokenBundle\Entity\RefreshToken as BaseRefreshToken;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;


#[ORM\Entity]
#[ORM\Table(name: 'refresh_tokens')]
#[UniqueEntity('hashedRefreshToken')]
#[ApiResource(
    operations: [
        new GetCollection(
            uriTemplate: '/utilisateurs/{idUtilisateur}/refresh_tokens',
            uriVariables: [
                "idUtilisateur" => new Link(
                    fromProperty: 'id',
                    fromClass: Utilisateur::class,
                    security: "is_granted('ROLE_USER') and utilisateur === user",
                    securityObjectName: 'utilisateur',
                )
            ],
            provider: RefreshTokenUserProvider::class
        ),
        new Delete(
            uriTemplate: '/refresh_tokens/{id}',
            security: "is_granted('ROLE_USER') and object.getUsername() === user.getUserIdentifier()"
        )
    ]
)]
class RefreshToken extends BaseRefreshToken
{
    #[ORM\Column(length: 128, unique: true)]
    #[ApiProperty(readable: false)]
    private ?string $hashedRefreshToken = null;

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $ip = null;

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $userAgent = null;

    #[ORM\Column(type: 'date')]
    private ?DateTime $createdAt = null;

    ...

}

Limiter le nombre de requêtes

Vous aurez peut-être remarqué une faille dans notre application : un utilisateur peut créer autant de tokens qu’il veut, de manière illimitée. Mais de manière plus générale, cela est aussi vrai pour d’autres opérations : l’utilisateur peut créer autant de comptes utilisateur qu’il veut et dans l’api de The Feed, créer autant de publications qu’il veut, etc.

Un utilisateur malveillant pourrait surcharger la base de données en un rien de temps !

Pour parer cela, il serait possible de faire des vérifications du nombre de ressources créées par utilisateur du côté de l’application, mais il est plutôt conseillé d’utiliser un rate limiter. Dans une application web, cet outil va permettre de limiter le nombre de fois où une opération spécifique est utilisée (par exemple, POST sur la route /api/utilisateurs/auth…). Il est possible d’utiliser un rate limiter lié au type d’application (par exemple, Symfony fournit un bundle pour cela). il est aussi conseillé de configurer et d’utiliser le rate limiter du serveur (apache, nginx, caddy, etc) pour parer les attaques DoS.

Bref, dans une application professionnelle, il est très fortement recommandé (pour ne pas dire obligatoire) d’installer et de configurer correctement ce genre d’outils.

Vous pouvez trouver plus d’explication sur le rate limiter de Symfony au nvieau de cet article. Vous pouvez aussi jeter un œil sur la documentation officielle du rate limiter d’apache ainsi que le module security qui est plus complet.