« JWT SPA symfony exercice pratique » : différence entre les versions

De Marmits Wiki
Aucun résumé des modifications
 
(26 versions intermédiaires par le même utilisateur non affichées)
Ligne 1 : Ligne 1 :
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:
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:


Ligne 17 : Ligne 21 :
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.
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 : ==
== Cela présente quelques avantages ==


*Vous n'êtes pas obligé d'envoyer un nom d'utilisateur et un mot de passe à chaque demande
*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)
*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 : ==
== 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.
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 ===
== 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.
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.
Ligne 32 : Ligne 36 :
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.
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 ? ===
== 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.
Malheureusement, c'est la réponse que j'ai trouvée le plus souvent dans les articles sur le sujet.
Ligne 38 : Ligne 42 :
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).
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 : ===
== Les problèmes que nous devons résoudre ==


*Un JWT suffit pour authentifier une requête API
*Un JWT suffit pour authentifier une requête API
Ligne 50 : Ligne 54 :
*Différents jetons d'actualisation pour le même utilisateur sont valides
*Différents jetons d'actualisation pour le même utilisateur sont valides


=== La solution : ===
== 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é.
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.
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.


==== 1) Supprimer le jeton d'actualisation après utilisation ====
=== 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.
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
==== gesdinet_jwt_refresh_token.yaml ====
<pre>
<syntaxhighlight lang="yaml" line>
    gesdinet_jwt_refresh_token:
gesdinet_jwt_refresh_token:
        single_use: true
    single_use: true
</pre>
</syntaxhighlight>


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


==== 2) Supprimer les jetons d'actualisation lors de la déconnexion ====
=== 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.
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.
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.


<pre>
==== security.yaml ====
    security:
<syntaxhighlight lang="yaml" line>
      firewalls:
security:
        api:
  firewalls:
          ...
    api:
          logout:
      ...
            path: /api/logout
      logout:
            handlers: [App\Service\Authentication\LogoutHandler]
        path: /api/logout
</pre>
        handlers: App\Service\Authentication\LogoutHandler
</syntaxhighlight>


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.
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
==== LogoutHandler ====
<pre>
<syntaxhighlight lang="php" line>
     <?php
     <?php
     declare(strict_types=1);
     declare(strict_types=1);
Ligne 120 : Ligne 125 :
         }
         }
     }
     }
</pre>
</syntaxhighlight>


==== 3) Ajouter un cookie à l'authentification JWT ====
=== 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.
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.
Ligne 128 : Ligne 133 :
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é.
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 :
==== 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:
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
===== generateSecurityCookieHash =====
<pre>
<syntaxhighlight lang="php" line>
     private function generateSecurityCookieHash(User $user, RefreshTokenInterface $refreshToken): string
     private function generateSecurityCookieHash(User $user, RefreshTokenInterface $refreshToken): string
     {
     {
Ligne 140 : Ligne 145 :
         );
         );
     }
     }
</pre>
</syntaxhighlight>


Comme nous allons avoir besoin de cela et de quelques méthodes supplémentaires, nous allons créer un service central appelé AuthenticationService.
Comme nous allons avoir besoin de cela et de quelques méthodes supplémentaires, nous allons créer un service central appelé AuthenticationService.
Ligne 146 : Ligne 151 :
Dans celui-ci, nous créerons le cookie en fonction du jeton d'actualisation de l'utilisateur authentifié. Le service complet ressemblera à ceci:
Dans celui-ci, nous créerons le cookie en fonction du jeton d'actualisation de l'utilisateur authentifié. Le service complet ressemblera à ceci:


AuthenticationService
===== AuthenticationService =====
<pre>
<syntaxhighlight lang="php" line>
     <?php
     <?php
     declare(strict_types=1);
     declare(strict_types=1);
Ligne 220 : Ligne 225 :
         }
         }
     }
     }
</pre>
</syntaxhighlight>


Nous utilisons la même date d'expiration pour le cookie que pour le jeton d'actualisation et le marquons comme http uniquement.
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  
==== 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:
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.yaml =====
<pre>
<syntaxhighlight lang="yaml" line>
    security:
security:
      firewalls:
  firewalls:
        login:
    login:
          form_login:
      form_login:
            ...
        ...
            check_path: /api/login
        check_path: /api/login
            success_handler: App\Service\Authentication\LoginSuccessHandler
        success_handler: App\Service\Authentication\LoginSuccessHandler
</pre>
</syntaxhighlight>


LoginSuccessHandler
===== LoginSuccessHandler =====
<pre>
<syntaxhighlight lang="php" line>
    <?php
<?php
     declare(strict_types=1);
     declare(strict_types=1);
     namespace App\Service\Authentication;
     namespace App\Service\Authentication;
Ligne 287 : Ligne 292 :
         }
         }
     }
     }
</pre>
</syntaxhighlight>


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.
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 :
==== 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.
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.yaml =====
<pre>
<syntaxhighlight lang="yaml" line>
    security:
security:
      firewalls:
  firewalls:
        api:
    api:
          pattern:  ^/api
      pattern:  ^/api
          stateless: true
      stateless: true
          ...
      ...
          guard:
      guard:
            authenticators:
        authenticators:
              - App\Service\Authentication\JWTAndSecurityCookieAuthenticator
          - App\Service\Authentication\JWTAndSecurityCookieAuthenticator
</pre>
</syntaxhighlight>


JWTAndSecurityCookieAuthenticator
===== JWTAndSecurityCookieAuthenticator =====
<pre>  
<syntaxhighlight lang="php" line>
    <?php
<?php
     declare(strict_types=1);
     declare(strict_types=1);
     namespace App\Service\Authentication;
     namespace App\Service\Authentication;
Ligne 358 : Ligne 363 :
         }
         }
     }
     }
</pre>
</syntaxhighlight>




Ligne 365 : Ligne 370 :
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.
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
=== 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.
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:
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:


api_refresh_token.yaml
===== routes.yaml =====
<pre>
<syntaxhighlight lang="yaml" line>
    api_refresh_token:
api_refresh_token:
        path: '/api/token/refresh'
    path: '/api/token/refresh'
        defaults: { _controller: gesdinet.jwtrefreshtoken:refresh }
    defaults: { _controller: gesdinet.jwtrefreshtoken:refresh }
        methods: [POST]
    methods: [POST]  
</pre>
</syntaxhighlight>


*Nous utilisons le suivant :
*Nous utilisons le suivant


api_refresh_token.yaml
<syntaxhighlight lang="yaml" line>
</pre>
api_refresh_token:
    api_refresh_token:
    path: '/api/token/refresh'
        path: '/api/token/refresh'
    defaults: { _controller: App\Service\Authentication\RefreshTokenSecurityCookieService:refresh }
        defaults: { _controller: App\Service\Authentication\RefreshTokenSecurityCookieService:refresh }
    methods: [POST]
        methods: [POST]
</syntaxhighlight>
</pre>


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.
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
===== RefreshTokenSecurityCookieService =====
<pre>
<syntaxhighlight lang="php" line>
    <?php
<?php
     declare(strict_types=1);
     declare(strict_types=1);
     namespace App\Service\Authentication;
     namespace App\Service\Authentication;
Ligne 532 : Ligne 536 :
         }
         }
     }
     }
</pre>
</syntaxhighlight>


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é.
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é.
[[Category:Symfony]] [[Category:JWT]]

Dernière version du 27 août 2024 à 09:03

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é.