JWT SPA symfony exercice pratique

Source: ? (perdue, disparue)

Introduction

Chanter JWT pour l'authentification dans un SPA avec Symfony est très facile grâce à des bundles comme FOSUserBundle, LexikJWTAuthenticationBundle et JWTRefreshTokenBundle. Malheureusement, ils ne fournissent pas de sécurité contre les attaques XSS (ce qui, selon vous, n'est pas vraiment là). Dans cet article, je veux montrer comment nous pouvons améliorer une application Symfony en utilisant ces bundles pour empêcher les attaques XSS. Comme il est assez long, je vais le diviser en parties suivantes:

  • Pourquoi les applications avec JWT sont vulnérables contre XSS ?
  • Les problèmes séparés que nous devons résoudre
  • La solution à ces problèmes
  • Adaptations nécessaires pour les tests du contrôleur
  • Adaptations pour le frontend et outils comme Postman
  • Pourquoi la combinaison de JWT et XSS est-elle si pertinente ?

Bases de JWT

La promesse de JWT est simple: dans le frontend, vous effectuez une action de connexion qui retourne un JWT (JSON Web Token). Avec ce JWT, vous pouvez ensuite effectuer d'autres actions sur le serveur qui nécessitent une authentification en fournissant uniquement le JWT. Le délai d'authentification et d'expiration fait partie du JWT.

Cela présente quelques avantages

  • Vous n'êtes pas obligé d'envoyer un nom d'utilisateur et un mot de passe à chaque demande
  • Vous pouvez facilement évoluer vers plusieurs serveurs sur le backend sans avoir besoin d'un stockage de session central (mot-clé sans état)

Mais aussi un inconvénient majeur

Lorsque vous demandez et gérez le JWT via votre application Javascript, tous les autres Javascript exécutés sur la page peuvent lire le JWT et potentiellement l'envoyer à un attaquant. Cet attaquant est alors en mesure d'envoyer des requêtes au nom de l'utilisateur.

Aggraver le problème

Habituellement, le JWT n'est pas utilisé comme une goutte en remplacement d'une session avec une longue date d'expiration, mais avec une très courte, quelque chose comme 15 minutes. Bien sûr, vous ne voulez pas que l'utilisateur soit déconnecté toutes les 15 minutes. Vous améliorez donc la configuration avec un jeton d'actualisation. À travers un intercepteur, chaque fois qu'une réponse interdite est renvoyée (car le JWT est expiré), un nouveau JWT est demandé. Cela se fait via le jeton d'actualisation qui a lui-même une date d'expiration beaucoup plus longue. Selon la configuration, la date d'expiration est même prolongée chaque fois qu'un nouveau JWT est demandé via le jeton d'actualisation.

Et ce jeton d'actualisation est également lisible par Javascript et donc ouvert à la saisie par un attaquant. Pour aggraver les choses, les jetons d'actualisation ne sont pas supprimés. Déjà. Ainsi, même si un nouveau jeton d'actualisation est généré via une nouvelle connexion valide de l'utilisateur, l'attaquant dispose toujours d'un jeton d'actualisation (potentiellement illimité) avec lequel travailler.

Réponse: N'utilisez pas JWT ?

Malheureusement, c'est la réponse que j'ai trouvée le plus souvent dans les articles sur le sujet.

Mais je n'aime pas vraiment cette réponse. Voyons donc quelles autres options nous avons. Et voyons quels sont les problèmes sous-jacents. Le seul problème que nous ne pourrons pas résoudre est que le JWT et le jeton d'actualisation sont lisibles par Javascript (pour des raisons évidentes).

Les problèmes que nous devons résoudre

  • Un JWT suffit pour authentifier une requête API
  • Les JWT sont toujours valides lorsqu'un utilisateur effectue une déconnexion
  • Un jeton d'actualisation volé peut être actualisé lui-même
  • Un jeton d'actualisation est toujours valide lorsqu'un utilisateur effectue une déconnexion
  • Différents jetons d'actualisation pour le même utilisateur sont valides

La solution

Comme le JWT est lié au jeton d'actualisation, le jeton d'actualisation est notre point de défaillance unique et il est logique d'y améliorer la sécurité. Pour une sécurité maximale, nous n'autoriserons jamais deux sessions avec le même utilisateur à la fois. Ce qui signifie que si l'utilisateur est connecté sur un appareil puis se connecte sur un autre appareil, sa session sur le premier sera supprimée.

Supprimer le jeton d'actualisation après utilisation

Depuis la version 0.7.0, le bundle de jetons d'actualisation contient une nouvelle option single_use qui supprime un jeton d'actualisation après utilisation et en émet un nouveau. Cela signifie qu'une fois qu'un attaquant vole un jeton d'actualisation, il n'a que jusqu'à la prochaine utilisation de l'utilisateur pour utiliser le jeton d'actualisation. Après c'est déjà inutile. Alors activons cela dans le fichier gesdinet_jwt_refresh_token.yml.

gesdinet_jwt_refresh_token.yaml

gesdinet_jwt_refresh_token:
    single_use: true

Cela rend évidemment le paramètre ttl_update: true inutile.

Supprimer les jetons d'actualisation lors de la déconnexion

Après la déconnexion, il n'est pas nécessaire de disposer d'un jeton d'actualisation, alors assurez-vous qu'ils sont supprimés. Pour cela, nous ajoutons un gestionnaire de déconnexion personnalisé à notre pare-feu qui est généralement appelé api dans mes applications. Cela signifie que j'utilise la fonctionnalité de déconnexion par défaut de Symfony, mais en étendant la gestion.

security.yaml

security:
  firewalls:
    api:
      ...
      logout:
        path: /api/logout
        handlers: App\Service\Authentication\LogoutHandler

Dans le gestionnaire, nous injectons simplement la connexion à la base de données et supprimons les jetons. Le référentiel du bundle ne prend pas en charge la suppression de tous les jetons d'actualisation pour un utilisateur, mais vous pouvez également étendre l'implémentation RefreshTokenManager, ajouter une méthode de référentiel et l'injecter ici à la place.

LogoutHandler

    <?php
    declare(strict_types=1);
    namespace App\Service\Authentication;
    use App\Entity\User;
    use Doctrine\DBAL\Connection;
    use Symfony\Component\HttpFoundation\Request;
    use Symfony\Component\HttpFoundation\Response;
    use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
    use Symfony\Component\Security\Http\Logout\LogoutHandlerInterface;
    use Symfony\Component\Security\Http\Logout\LogoutSuccessHandlerInterface;

    final class LogoutHandler implements LogoutHandlerInterface, LogoutSuccessHandlerInterface
    {
        /** @var Connection */
        private $databaseConnection;

        public function __construct(Connection $databaseConnection)
        {
            $this->databaseConnection = $databaseConnection;
        }
        public function logout(Request $request, Response $response, TokenInterface $token): void
        {
            $authenticatedUser = $token->getUser();
            if (null === $authenticatedUser) {
                return;
            }
            /* @var User $authenticatedUser */
            // Possible exception should not be caught, as we need to become aware that something broke here
            $this->databaseConnection->exec(sprintf('
                DELETE FROM refresh_tokens
                WHERE username = "%s"
            ', $authenticatedUser->getUsername()));
        }
    }

Ajouter un cookie à l'authentification JWT

Tant que le JWT est suffisant pour l'authentification, c'est toujours notre point de défaillance unique. Et même si notre jeton d'actualisation a maintenant moins de valeur, il est toujours suffisant pour générer un nouveau JWT. Nous avons donc besoin d'un composant supplémentaire qui n'est pas lisible via Javascript. Et c'est exactement ce qu'est un cookie http uniquement. Il ne peut être défini et lu que par le serveur et sera automatiquement envoyé par le navigateur à chaque demande.

Et la meilleure chose: nous pouvons créer le cookie au-dessus du jeton d'actualisation de l'utilisateur. Cela signifie que chaque fois que le jeton d'actualisation est supprimé, le cookie est également invalidé.

Configuration des cookies

La valeur doit être basée sur l'utilisateur, le jeton d'actualisation valide actuel et un sel aléatoire. De cette façon, il ne dépend toujours pas de la session du serveur mais des valeurs de la base de données. Nous aurions également la possibilité de forcer une déconnexion globale en modifiant le sel utilisé sur le serveur. Une petite méthode pour cela pourrait ressembler à ceci:

generateSecurityCookieHash
    private function generateSecurityCookieHash(User $user, RefreshTokenInterface $refreshToken): string
    {
        return md5(
            sprintf('%s%s%s', $user->getId(), $refreshToken->getRefreshToken(), $this->securityCookieSalt)
        );
    }

Comme nous allons avoir besoin de cela et de quelques méthodes supplémentaires, nous allons créer un service central appelé AuthenticationService.

Dans celui-ci, nous créerons le cookie en fonction du jeton d'actualisation de l'utilisateur authentifié. Le service complet ressemblera à ceci:

AuthenticationService
    <?php
    declare(strict_types=1);
    namespace App\Service\Authentication;
    use App\Entity\User;
    use Gesdinet\JWTRefreshTokenBundle\Model\RefreshTokenInterface;
    use Gesdinet\JWTRefreshTokenBundle\Model\RefreshTokenManagerInterface;
    use Symfony\Component\HttpFoundation\Cookie;

    final class AuthenticationService
    {
        public const SECURITY_COOKIE_NAME = 'security';
        /** @var RefreshTokenManagerInterface */
        private $refreshTokenManager;

        /** @var string */
        private $securityCookieSalt;

        public function __construct(
            RefreshTokenManagerInterface $refreshTokenManager,
            string $securityCookieSalt
        ) {
            $this->refreshTokenManager = $refreshTokenManager;
            $this->securityCookieSalt = $securityCookieSalt;
        }

        public function createSecurityCookie(User $user): ?Cookie
        {
            /**
             * This might happen if a user is logged in on one computer and logs out on another computer. 
             * On refresh of the session on the first computer, 
             * the refresh tokens will be removed and the user should be logged out.
             */
            $refreshToken = $this->refreshTokenManager->getLastFromUsername($user->getEmail());
            if (null === $refreshToken) {
                return null;
            }

            $cookieHash = $this->generateSecurityCookieHash($user, $refreshToken);

            return new Cookie(
                self::SECURITY_COOKIE_NAME,
                $cookieHash,
                $refreshToken->getValid(),
                null,
                null,
                true,
                true
            );
        }

        public function isSecurityHashValid(string $securityHash, User $user): bool
        {
            /**
             * This might happen if a user is logged in on one computer and logs out on another computer. 
             * On refresh of the session on the first computer,
             * the refresh tokens will be removed and the user should be logged out.
             */
            $refreshToken = $this->refreshTokenManager->getLastFromUsername($user->getEmail());
            if (null === $refreshToken) {
                return false;
            }

            return $securityHash === $this->generateSecurityCookieHash($user, $refreshToken);
        }

        private function generateSecurityCookieHash(User $user, RefreshTokenInterface $refreshToken): string
        {
            return md5(
                sprintf('%s%s%s', $user->getId(), $refreshToken->getRefreshToken(), $this->securityCookieSalt)
            );
        }
    }

Nous utilisons la même date d'expiration pour le cookie que pour le jeton d'actualisation et le marquons comme http uniquement.

Définir un cookie lors de l'authentification

Pour définir le cookie, nous ajoutons un gestionnaire de réussite de connexion et utilisons notre service d'authentification là-bas:

security.yaml
security:
  firewalls:
    login:
      form_login:
        ...
        check_path: /api/login
        success_handler: App\Service\Authentication\LoginSuccessHandler
LoginSuccessHandler
<?php
    declare(strict_types=1);
    namespace App\Service\Authentication;
    use App\Entity\User;
    use Lexik\Bundle\JWTAuthenticationBundle\Security\Http\Authentication\AuthenticationSuccessHandler as LexikAuthenticationSuccessHandler;
    use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
    use Symfony\Component\EventDispatcher\EventDispatcherInterface;
    use Symfony\Component\HttpFoundation\Cookie;
    use Symfony\Component\HttpFoundation\Request;
    use Symfony\Component\HttpFoundation\Response;
    use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;

    final class LoginSuccessHandler extends LexikAuthenticationSuccessHandler
    {
        /** @var AuthenticationService */
        private $authenticationService;

        public function __construct(
            JWTTokenManagerInterface $jwtManager,
            EventDispatcherInterface $dispatcher,
            AuthenticationService $authenticationService
        ) {
            parent::__construct($jwtManager, $dispatcher);

            $this->authenticationService = $authenticationService;
        }

        /**
         * This is called when an interactive authentication attempt succeeds.
         * This is called by authentication listeners inheriting from AbstractAuthenticationListener.
         */
        public function onAuthenticationSuccess(Request $request, TokenInterface $token): Response
        {
            /** @var User $user */
            $user = $token->getUser();
            
            $response = $this->handleAuthenticationSuccess($user);

            // Security cookie
            /** @var Cookie $securityCookie */
            $securityCookie = $this->authenticationService->createSecurityCookie($user);
            $response->headers->setCookie($securityCookie);

            return $response;
        }
    }

Nous allons maintenant obtenir un cookie de sécurité que nous vous enverrons à chaque demande et qui n'est pas accessible par Javascript. Mais ce n'est pas évalué pour le moment, alors faisons-le.

Validez le cookie à chaque demande

Vient maintenant la partie qui ne me satisfait toujours pas, car je n'ai pas trouvé de bon point d'entrée. Le garde utilisé lors de l'utilisation du bundle JWT est le JWTTokenAuthenticator. Le moyen le plus simple d'attacher le cookie serait d'écouter la réponse, mais malheureusement, il n'y a aucun moyen d'y parvenir. Et dans tous les cas où j'ai accès à l'utilisateur, nous n'avons pas accès à la réponse. Donc, le seul moyen que j'ai trouvé était de créer mon propre authentificateur, d'étendre le JWTTokenAuthenticator, d'utiliser la même méthode onAuthenticationSuccess et d'appeler la méthode parente pour avoir le même comportement qu'avant juste avec la vérification des cookies ajoutée. L'authentificateur complet sera configuré comme authentificateur dans le fichier security.yml et ressemble à ceci.

security.yaml
security:
  firewalls:
    api:
      pattern:   ^/api
      stateless: true
      ...
      guard:
        authenticators:
          - App\Service\Authentication\JWTAndSecurityCookieAuthenticator
JWTAndSecurityCookieAuthenticator
    
    <?php
    declare(strict_types=1);
    namespace App\Service\Authentication;

    use App\Entity\User;
    use Lexik\Bundle\JWTAuthenticationBundle\Security\Guard\JWTTokenAuthenticator;
    use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
    use Lexik\Bundle\JWTAuthenticationBundle\TokenExtractor\TokenExtractorInterface;
    use Symfony\Component\EventDispatcher\EventDispatcherInterface;
    use Symfony\Component\HttpFoundation\Request;
    use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
    use Symfony\Component\Security\Core\Exception\AuthenticationException;

    final class JWTAndSecurityCookieAuthenticator extends JWTTokenAuthenticator
    {
        /** @var AuthenticationService */
        private $authenticationService;

        public function __construct(
            JWTTokenManagerInterface $jwtManager,
            EventDispatcherInterface $dispatcher,
            TokenExtractorInterface $tokenExtractor,
            AuthenticationService $authenticationService
        ) {
            parent::__construct($jwtManager, $dispatcher, $tokenExtractor);

            $this->authenticationService = $authenticationService;
        }

        public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
        {
            if ('api_logout' === $request->get('_route')) {
                return null;
            }

            /** @var User $user */
            $user = $token->getUser();

            $securityCookieSecret = $request->cookies->get(AuthenticationService::SECURITY_COOKIE_NAME);

            if (null === $securityCookieSecret
                || !$this->authenticationService->isSecurityHashValid($securityCookieSecret, $user)) {
                return $this->onAuthenticationFailure($request, new AuthenticationException('Wrong security cookie'));
            }

            return null;
        }
    }


Nous ignorons le gestionnaire pour la route de déconnexion, car le jeton d'actualisation contre nous validerait le cookie, a été supprimé juste avant le déclenchement de l'authentificateur.

Faites-moi savoir si vous connaissez une meilleure option pour intégrer cette fonctionnalité. J'écrirai les responsables du bundle JWT et voir si nous pouvons trouver un écouteur d'événement que je pourrais utiliser ici.

Valider la demande de jeton d'actualisation

La demande d'actualisation d'un jeton n'est pas gérée par le pare-feu car elle ne peut pas être authentifiée car vous ne demandez un nouveau jeton que si l'ancien a expiré. Il faut donc que cela fonctionne sans authentification. Mais nous pouvons toujours valider le cookie ici aussi. Bien que la façon de le faire soit encore pire que l'authentificateur.

Nous avons un problème similaire à celui du bundle JWT en ce sens qu'il y a des événements que nous pourrions écouter, mais jamais ceux avec un accès à la demande et à la réponse en même temps. Par conséquent, la seule solution que j'ai trouvée était de copier le service de jeton d'actualisation utilisé comme point de terminaison pour l'itinéraire de jeton d'actualisation et de l'étendre. Signification au lieu de cette route qui est la valeur par défaut:

routes.yaml
api_refresh_token:
    path: '/api/token/refresh'
    defaults: { _controller: gesdinet.jwtrefreshtoken:refresh }
    methods: [POST] 
  • Nous utilisons le suivant
api_refresh_token:
    path: '/api/token/refresh'
    defaults: { _controller: App\Service\Authentication\RefreshTokenSecurityCookieService:refresh }
    methods: [POST]

La copie complète est annotée avec les espaces où je place la validation du cookie et la création du cookie après l'ajout du nouveau jeton d'actualisation.

RefreshTokenSecurityCookieService
    <?php
    declare(strict_types=1);
    namespace App\Service\Authentication;

    use App\Entity\User;
    use Gesdinet\JWTRefreshTokenBundle\Event\RefreshEvent;
    use Gesdinet\JWTRefreshTokenBundle\Model\RefreshTokenManagerInterface;
    use Gesdinet\JWTRefreshTokenBundle\Security\Authenticator\RefreshTokenAuthenticator;
    use Gesdinet\JWTRefreshTokenBundle\Security\Provider\RefreshTokenProvider;
    use Gesdinet\JWTRefreshTokenBundle\Service\RefreshToken;
    use Symfony\Component\EventDispatcher\EventDispatcherInterface;
    use Symfony\Component\HttpFoundation\Request;
    use Symfony\Component\HttpFoundation\Response;
    use Symfony\Component\Security\Core\Exception\AuthenticationException;
    use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
    use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;
    use Symfony\Contracts\EventDispatcher\EventDispatcherInterface as ContractsEventDispatcherInterface;

    final class RefreshTokenSecurityCookieService
    {
        /** @var RefreshTokenAuthenticator */
        private $authenticator;

        /** @var RefreshTokenProvider */
        private $provider;

        /** @var AuthenticationSuccessHandlerInterface */
        private $successHandler;

        /** @var AuthenticationFailureHandlerInterface */
        private $failureHandler;

        /** @var RefreshTokenManagerInterface */
        private $refreshTokenManager;

        /** @var int */
        private $ttl;

        /** @var string */
        private $providerKey;

        /** @var bool */
        private $ttlUpdate;

        /** @var EventDispatcherInterface */
        private $eventDispatcher;

        /** @var AuthenticationService */
        private $authenticationService;

        public function __construct(
            RefreshTokenAuthenticator $authenticator,
            RefreshTokenProvider $provider,
            AuthenticationSuccessHandlerInterface $successHandler,
            AuthenticationFailureHandlerInterface $failureHandler,
            RefreshTokenManagerInterface $refreshTokenManager,
            int $ttl,
            string $providerKey,
            bool $ttlUpdate,
            EventDispatcherInterface $eventDispatcher,
            AuthenticationService $authenticationService
        ) {
            $this->authenticator = $authenticator;
            $this->provider = $provider;
            $this->successHandler = $successHandler;
            $this->failureHandler = $failureHandler;
            $this->refreshTokenManager = $refreshTokenManager;
            $this->ttl = $ttl;
            $this->providerKey = $providerKey;
            $this->ttlUpdate = $ttlUpdate;
            $this->eventDispatcher = $eventDispatcher;
            $this->authenticationService = $authenticationService;
        }

        public function refresh(Request $request): Response
        {
            // - Start copy of RefreshToken service
            try {
                /** @var User $user */
                $user = $this->authenticator->getUser(
                    $this->authenticator->getCredentials($request),
                    $this->provider
                );

                $postAuthenticationToken = $this->authenticator->createAuthenticatedToken($user, $this->providerKey);
            } catch (AuthenticationException $e) {
                return $this->failureHandler->onAuthenticationFailure($request, $e);
            }

            $refreshToken = $this->refreshTokenManager->get($this->authenticator->getCredentials($request));

            if (null === $refreshToken || !$refreshToken->isValid()) {
                return $this->failureHandler->onAuthenticationFailure($request, new AuthenticationException(
                        sprintf('Refresh token "%s" is invalid.', $refreshToken)
                    )
                );
            }
            // - Stop copy of RefreshToken service

            // Check security cookie
            $securityCookieSecret = $request->cookies->get(AuthenticationService::SECURITY_COOKIE_NAME);

            if (null === $securityCookieSecret
                || !$this->authenticationService->isSecurityHashValid($securityCookieSecret, $user)) {
                return $this->failureHandler->onAuthenticationFailure($request, new AuthenticationException(
                        'Invalid security cookie.'
                    )
                );
            }

            // - Start copy of RefreshToken service

            if ($this->ttlUpdate) {
                $expirationDate = new \DateTime();
                $expirationDate->modify(sprintf('+%d seconds', $this->ttl));
                $refreshToken->setValid($expirationDate);

                $this->refreshTokenManager->save($refreshToken);
            }

            if ($this->eventDispatcher instanceof ContractsEventDispatcherInterface) {
                $this->eventDispatcher->dispatch(new RefreshEvent($refreshToken, $postAuthenticationToken), 'gesdinet.refresh_token');
            } else {
                $this->eventDispatcher->dispatch('gesdinet.refresh_token', new RefreshEvent($refreshToken, $postAuthenticationToken));
            }

            $response = $this->successHandler->onAuthenticationSuccess($request, $postAuthenticationToken);
            // - Stop copy of RefreshToken service

            // Add new security cookie to response (old one isn't valid any more with the new refresh token)
            $securityCookie = $this->authenticationService->createSecurityCookie($user);
            if (null !== $securityCookie) {
                $response->headers->setCookie($securityCookie);
            } else {
                $response->headers->removeCookie(AuthenticationService::SECURITY_COOKIE_NAME);
            }

            return $response;
        }
    }

De cette façon, lorsqu'un nouveau JWT est demandé, le jeton d'actualisation et le cookie sont validés. Un nouveau cookie est également émis ici, car chaque fois que le JWT est demandé, un nouveau jeton d'actualisation est émis et l'ancien cookie a donc été invalidé.