Sécuriser une API REST (3/3) : gestion du JWT coté client

Sécuriser une API REST (3/3) : gestion du JWT coté client

Après avoir vu la théorie sur la sécurisation d’une API REST et l’implémentation en Node.js, nous allons clore cette série d’articles avec la gestion du JSON Web Token (JWT) coté client et voir les erreurs bien trop souvent commises.

Note : Avant de poursuivre, je tiens à préciser que les exemples de cet article sont fonctionnels, mais sont simplifiés au maximum pour que vous compreniez les concepts clés. Comme pour les autres articles certains raccourcis sont pris (middleware simplifié, gestion des erreurs, accès à la base de données, etc.) pour améliorer la compréhension, je ne me concentre pas sur l’architecture de l’application ce n’est pas le but de l’article. Comme toujours, libre à vous d’adapter en fonction de vos projets.

Connexion à l’API

Lors de la connexion à une API REST, on récupère souvent le JWT et le « refresh token » dans la réponse HTTP, c’est d’ailleurs ce que nous avons fait dans le précédent article :

Vient alors la question du stockage de ceux-ci côté client.

Erreur n°1 : Stockage des tokens dans le « localStorage »

Lorsque l’on souhaite stocker les tokens coté client, on pense naturellement à l’utilisation du localStorage. Le code pour se connecter à notre API et récupérer les tokens pourrait ressembler à ceci :

Il suffit ensuite de stocker les tokens dans le localStorage :

Il est possible de voir le contenu du localStorage dans la console de votre navigateur. Pour Chrome, il suffit de se rendre dans l’onglet « Application » puis « Local Storage ».

Affichage du "localStorage" dans la console du navigateur
Affichage du « localStorage » dans la console du navigateur

Lorsque le client souhaite effectuer une requête sur une route sécurisée de notre API, celui-ci devra transmettre le JWT via l’en-tête HTTP Authorization:

Malheureusement, cette façon de stocker les tokens ouvre la porte à une faille bien connue : la faille XSS.

La faille XSS

XSS ou cross-site scripting est un type de faille permettant d’injecter du contenu dans une page web par exemple du HTML ou du JavaScript. 

Il existe plusieurs types d’attaques XSS, d’ailleurs un article dédié à la sécurité web devrait sortir prochainement dans lequel nous verrons en détail les différents types d’attaques XSS et comment s’en protéger.

Pour notre exemple, nous allons simplement voir l’attaque la plus répandue qui est l’attaque XSS stockée. Ce type d’attaque consiste simplement à un pirate d’envoyer du contenu malicieux à notre API (code JavaScript ou HTML par exemple) qui va le stocker en base de données et le restituer tel quel à notre application web qui elle va l’exécuter.

Le code malicieux peut être simplement :

Affichage un dialogue d'alerte via une faille XSS
Affichage un dialogue d’alerte via une faille XSS

Bon c’est pas jolie, jolie, mais rien de bien méchant, mais cela peut être ce type de code :

Le navigateur exécute ce code et effectue une requête GET sur le serveur du pirate avec les tokens en paramètre de l’URL sans que l’utilisateur s’en aperçoive. Il suffit ensuite au pirate de logger de son côté la requête pour récupérer les tokens :

Bon cette fois-ci c’est beaucoup plus grave ! Ceci n’est qu’un exemple parmi tant qu’autres permettant de voler les tokens depuis le localStorage. Il existe bien entendu des solutions pour prévenir ce genre d’attaques comme nettoyer les données reçues pour les attaques XSS stockées par exemple, mais on en parlera dans un prochain article. Malgré tout stocker les tokens dans le localStorage est une très mauvaise pratique.

Erreur n°2 : Stockage des tokens dans les cookies

Comme nous l’avons vu stocker les tokens dans le localStorage ouvre la porte aux attaques XSS. Une autre solution consiste à stocker ceux-ci dans des cookies. 

Dans le précédent article, notre API envoyait directement au client les tokens dans la réponse HTTP comme nous l’avons vu en introduction. Notre code ressemblait à ceci :

Modifions celui-ci pour envoyer cette fois-ci les tokens dans des cookies :

Dans le cas d’une requête qui concerne le même domaine, le navigateur enregistre automatiquement les cookies. Par contre lorsque l’on effectue des requêtes entre domaines différents, ce qui est fréquent dans le cas d’une API REST, il devient obligatoire d’ajouter la propriété credentials aux options de la méthode fetch et de lui assigner la valeur include:

Si vous utilisez la classe XMLHttpRequest ou la librairie axios à la place de fetch, il suffit de définir l’option withCredentials à true.

Comme pour le localStorage, vous pouvez directement voir les cookies depuis la console de votre navigateur. Pour Chrome, il suffit de se rendre dans l’onglet « Application » puis « Cookies ».

Affichage des cookies dans la console du navigateur
Affichage des cookies dans la console du navigateur

Il faut maintenant, dès lors que l’on effectue une requête sur une route sécurisée, envoyer les cookies à notre API. Comme pour l’enregistrement des cookies, si la requête concerne le même domaine, ceux-ci sont envoyés automatiquement. Dans le cas contraire, il faut ajouter la propriété credentials aux options de la méthode fetch et de lui assigner la valeur include (ou withCredentials à true pour la classe XMLHttpRequest ou la librairie axios) :

Côté serveur, il faut par contre modifier le middleware d’authentification puisque celui-ci récupérait le JWT depuis l’en-tête HTTP Authorization, il faut maintenant le récupérer depuis les cookies:

Bon normalement là tout est bon, on doit maintenant être protégé contre les attaques XSS !

Eh bien… Pas du tout ! En fait il est tout à fait possible de récupérer les cookies en JavaScript :

On peut donc utiliser la même faille XSS que pour localStorage :

Bon vous vous en doutez il existe une solution pour protéger nos cookies d’une attaque XSS.

Il existe deux options permettant de sécuriser nos cookies :

  • HttpOnly : Cette option permet d’interdire l’utilisation du cookie côté client, il est donc impossible de récupérer celui-ci via l’instruction vue précédemment, on est donc protégé des failles XSS;
  • secure : Cette option permet d’envoyer le cookie uniquement dans via le protocole HTTPS.

Modifions donc notre code coté serveur:

Cette fois-ci c’est tout bon… Enfin pas vraiment. On est bien protégé des attaques XSS mais on est vulnérable à un autre type d’attaque : les attaques CSRF.

La faille CSRF

Cross Site Request Forgery ou CSRF est un type de faille qui consiste simplement à faire exécuter à une victime une requête HTTP à son insu.

On va prendre un exemple très simple pour mieux comprendre  :

  • Un utilisateur est authentifié sur notre API. Le cookie contenant le JWT est alors stocké dans son navigateur;
  • Une personne mal intentionnée incite l’utilisateur à visiter la page d’un site contenant du code malveillant exécutant une requête vers notre API;
  • La requête est envoyée sur notre API avec le cookie contenant le JWT de l’utilisateur qui est ajouté automatiquement par le navigateur;
  • Notre API effectue l’opération correspondant à la requête à l’insu de l’utilisateur.
Exemple d'attaque CSRF
Exemple d’attaque CSRF

Il y a plusieurs manières de procéder à une attaque CSRF,  par exemple cela peut être un formulaire qui est envoyé une fois la page chargée :

Ce code effectue une requête POST sur notre API et ajoute un article dont le contenu est « You have been hacked ! ». Imaginez donc ce qu’il est possible de faire avec ce type d’attaque.

Concernant le refresh token, le stockage dans un cookie ne pose pas de problème. Celui-ci est uniquement utilisé pour générer un nouveau JWT. Donc même dans le cas d’une attaque CSRF, l’attaquant n’a pas moyen de récupérer le nouveau JWT ou d’effectuer d’autres actions.

Ce qu’il nous faut c’est donc un moyen de protéger le JWT contre les attaques XSS et CSRF.

FUUUUSION… HA !

Nous avons vu que le stockage des tokens dans le localStorage nous exposé aux attaques XSS et que le stockage de ceux-ci dans des cookies nous exposé cette fois-ci aux attaques CSRF.

Du coup, quelle solution pour nous prémunir de ces attaques ? Et bien nous allons fusionner les deux solutions. En effet, le localStorage n’est pas sensible aux attaques CSRF et inversement les cookies ne sont pas sensibles aux attaques XSS (via la propriété HttpOnly).

L’idée est de diviser nos informations d’authentification en deux parties et de stocker celles-ci à la fois dans le localStorage et dans un cookie. On envoie ensuite ses deux informations au serveur, l’une via le cookie et l’autre via un en-tête HTTP.

Concrètement, voici comment cela se déroule :

  • Lors de l’authentification, notre API va générer un token unique que l’on appelle token CSRF et qui sera stocké dans le payload du JWT;
  • Notre API envoie le JWT dans un cookie et le token CSRF dans le corps de la réponse HTTP;
  • Le navigateur va s’occuper de stocker le cookie et nous allons stocker le token CSRF dans le localStorage;
  • Lors d’une requête sur notre API, celle-ci doit comporter à la fois le cookie contenant le JWT et un en-tête HTTP x-xsrf-token contenant le token CSRF;
  • Notre API décode le JWT et vérifie sa validité;
  • Notre API vérifie que le token CSRF contenu dans l’en-tête HTTP correspond à celui du payload du JWT, si c’est le cas l’utilisateur est authentifié.
Authentification à l'aide du JWT et du token CSRF
Authentification à l’aide du JWT et du token CSRF

Voyons voir maintenant ce que cela donne au niveau du code.

Coté serveur, commençons par modifier notre route de connexion  :

Coté client, la fonction de connexion reste la même, il nous faut juste stocker le token CSRF dans le localStorage

Il nous faut maintenant gérer l’authentification via le middleware coté serveur :

Pour terminer, modifions notre code coté client pour effectuer une requête sur notre API :

Pour finir…

Pour clôturer cette série d’articles, nous avons vu quelques bonnes pratiques concernant la sécurisation d’une API REST, leurs implémentations en Node.js et que le simple stockage des tokens coté client pouvait exposer nos applications à plusieurs failles de sécurité.

Nous n’avons pas encore terminé côté sécurité, puisque nous verrons dans un prochain article comment nous prémunir entre autres des failles XSS et CSRF de manière plus générale et quelles sont les autres bonnes pratiques en termes de sécurité lorsque nous développons une application web.


Annonces partenaire

Je suis lead developer dans une boîte spécialisée dans l'univers du streaming/gaming, et en parallèle, je m'éclate en tant que freelance. Passionné par l'écosystème JavaScript, je suis un inconditionnel de Node.js depuis 2011. J'adore échanger sur les nouvelles tendances et partager mon expérience avec les autres développeurs. Si vous avez envie de papoter, n'hésitez pas à me retrouver sur Twitter, m'envoyer un petit email ou même laisser un commentaire.

22 commentaires

  1. Merci pour cette série d’articles très instructifs.

    J’ai cependant un doute sur la robustesse de la solution utilisant « CSRF token + Cookie HttpOnly » face aux attaques XSS. Si un attaquant parvient à injecter du JS, il n’a certes pas directement accès au JWT mais a accès au token CSRF. Comme les cookies sont automatiquement passés, il lui suffit alors de récupérer le csrf-token et de forger une requête HTTP avec fetch(). Ainsi, le token CSRF **et** le JWT sont passés et valides.

    Qu’en dites-vous ?

    Cordialement,

    1. Je m’attendais à cette question bien vue 😉 ! Il est effectivement possible de procéder à une attaque XSS et d’injecter du code effectuant une requête HTTP via fetch. C’est pourquoi il faut à tout prix se prémunir des failles XSS, comme je l’ai dit je compte faire un article qui s’intéresse plus en détail aux différents soucis de sécurité dont notamment les failles XSS (OWASP Top Ten). Mais même en étant protégé contre les failles XSS, on n’est pas à l’abri d’utiliser une librairie vérolée. Bon après vu que la méthode présentée dans l’article empêche de voler le JWT, la librairie vérolée ne pourra effectuer que des requêtes sur notre API en utilisant le token CSRF + le JWT, autrement dit elle doit être conçue uniquement pour effectuer une attaque sur notre API, donc peu de chance que ça arrive.

    1. Tu peux te le faire voler via une attaque XSS et le hacker peut l’utiliser pour récupérer un nouveau JWT. Tu peux par contre toujours demander l’ancien JWT (contenu dans un cookie) en plus du refresh token pour récupérer un nouveau JWT

  2. Merci pour ta réponse aussi rapide , enfaîte je développe une appli pour une certification de développeur web et j’utilise ta technique pour la partie « faille de sécurité » et du coup j’ai des probleme avec le refresh token , quand je signe a nouveau le acces token , je dois recupéré le xsrfToken via la requête le local storage ?

    1. Pour le refresh token tu n’as pas besoin d’envoyer le xsrfToken. Quand tu veux récupérer un nouveau refresh token tu as juste à envoyer, coté front, une requête POST sur ta route (en envoyant bien le cookie avec l’option withCredentials à true) et récupérer le refresh token depuis le cookie coté back. Tu vérifies que celui-ci est valide et tu génères les nouveaux tokens : access et refresh dans des cookies, et xsrf dans le corps de la réponse HTTP que tu stockeras dans le localStorage coté front.

  3. Du coup je ne vois pas l’utilité du refresh TOKEN, ormi etre stocké dans la BDD …
    tu vas t’en servir pour faire quoi

    1. L’access token peut avoir une durée de vie de quelques heures voir de quelques minutes, tu t’imagines bien que tu ne vas pas demander à chaque fois à l’utilisateur de se reconnecter. Par contre, une fois l’access token expiré ton application va envoyer le refresh token à l’API pour demander un nouveau access token (et un nouveau refresh token). Tu vas me dire pourquoi ne pas utiliser un access token avec une durée de vie plus élevée ? Tout simplement car celui-ci est envoyé à chaque appel API et il n’est pas impossible que celui-ci soit intercepté (peu importe la façon) et soit utilisé à des fins malveillantes. Comme nous sommes dans une API stateless, il n’y a pas de session côté serveur et l’access token n’est donc pas stocké, de ce fait il est impossible pour le serveur de blacklister cet access token « corrompu » c’est pourquoi on définit généralement des access tokens avec une durée de vie faible pour pouvoir « renouveler » ceux-ci régulièrement via le refresh token. Le refresh token n’est pas à l’abri d’être volé mais le front est censé le stocker dans un endroit sécurisé et l’envoyer uniquement pour demander un nouveau access token. De plus, si on a un doute sur un refresh token on peut très bien le blacklister côté serveur car celui-ci est stocké en base de données.

      1. Hello,
        je tombe sur cet article longtemps après sa parution mais on sait jamais.
        Si tu stockes le refresh token dans un cookie il va être envoyé automatiquement par le navigateur.
        Dans ce cas on perd l’intérêt du refresh token qui ne devrait être envoyée uniquement au moment de générer un nouvel access token (afin d’éviter au maximum que le refresh token se fasse intercepter).

        1. Hello,

          C’est vrai que l’article date un peu mais je suis toujours là pour répondre 😀 ! Je ne l’ai pas forcément bien explicité dans l’article, mais le refresh token se trouve dans un cookie qui ne sera envoyé que sur la route « /token » (via la propriété path du cookie). Donc, pas d’inquiétude, le refresh token est bien envoyé uniquement lors de la demande d’un nouvel access token. 😉

  4. Bonjour,
    Merci pour cette série d’articles très intéressante !
    Je comprends bien le principe d’access et refresh token. Mais vu le role primordial et stratégique du refresh token (il permet de régénérer un access token) je ne comprends pas qu’il soit stocker dans les cookies alors que l’article précise qu’ils peuvent être sujets à une attaque.
    Si un hacker récupère le refresh token via une attaque CRSF on peut imaginer qu’il pourra le récupérer tout le temps. Et donc accéder tout le temps a un access token. Et toute la sécurité de l’API tombe à l’eau. Non ? Je manque quelque chose ?

  5. Très belle série d’articles.
    Cependant je ne comprends pas pourquoi le refreshToken est stocké uniquement dans les cookies. Vous écrivez que dans ce même article que les cookies sont sujets aux attaques du fait de la faille CSRF. Ainsi notre refreshToken est vulnérable. Et ce refreshToken est critique dans la sécurisation de notre API. S’il est volé on peut générer un nouvel accessToken et récupérer un nouveau refreshToken. Donc accéder à l’API comme bon nous semble.
    Je manque quelque chose ?

    1. Merci !
      Attention l’attaque CSRF ne permet pas de voler les tokens mais de tromper l’utilisateur en lui faisant effectuer des actions à son insu. Le refresh token se trouve dans cookie qui ne peut être envoyé que sur la route « /token » (via la propriété path du cookie, j’aurais dû être plus précis dans l’article) qui est la route pour récupérer un nouveau access token (et refresh token). Donc même si l’on est victime d’une attaque CSRF avec le refesh token, la seule action possible est de régénérer un access token et un refresh token, ce qui en soit n’est pas très grave car je le rappelle tout se passe dans le navigateur de l’utilisateur et donc l’attaquant n’a aucun moyen de récupérer les tokens ou d’effectuer d’autres actions.

      J’espère que cela a répondu à ta question 😉

  6. Hello, étant en fin de formation DWWM, j’avais essayé de mettre en place ce type de sécurité sur une app avec une api sous symfo et un front sous React. Mais en prenant un peu de recul je me rend compte que tout ça est inutile, non ?

    Je m’explique, les failles xss sont gérés par symfony, impossible pour un utilisateur d’insérer un script en BDD, du coup on pourrait très bien se contenter de stocker le JWT ( généré à la connexion de l’utilisateur) dans le localStorage ?

  7. Salut, merci beaucoup pour ton tuto,
    Par contre j’ai un petit problème je n’arrive pas à voir le cookie dans le navigateur, pourtant credentials: ‘include’ est bien activé, ça peut être du à quoi d’autre ? :s

    1. Salut,

      J’ai le même problème. Mon API est en HTTPS.
      Quand j’utilise l’inspecteur, je le vois bien passer lors du login dans « set-cookie’ mais je ne le retrouve pas dans l’onglet cookie ensuite. Je ne peux donc pas m’en servir plus tard.

  8. Salut mon app sauvegarde en localStorage, j’ai souhaité essayer quelque chose moitié cookie moitié localStorage mais étrange quand mon app vuejs3 se connecte via axios, impossible d’avoir un cookie,

    sur la meme route de l’api j’accede et je crée un cookie mais avec axios toujours undefined

    console.log(req.cookies) // avec axios toujours undefined & en accedant via chrome j’obtiens à la prochaine requete le timestamp

    Sais tu pourquoi ?
    res.cookie(« test », new Date().getTime(), { httpOnly: true, secure: true })

  9. Bonjour,

    Je ne peux pas supprimer mon commentaire précedent en revanche j’ai résolu le probleme des cookies et donc j’ai modifié mon authentification pour que cela fonctionne comme ce que tu as proposé comme explication.

    Cela dit, dans mon entreprise j’ai voulu analyser le risque que tu as cité, je t’expose la conception par écrit et j’aimerais avoir ton avis si il y a un risque et lequel ?

    Il n’y a pas de cookies du tout !
    C’est une app VueJS3 avec Pinia et bien sure Axios, inutile d’en citer d’avantage
    Lors de l’authentification, un seul token est sauvegardé en localStorage c’est le refreshToken, l’authToken est conservé dans le store de Pinia.
    Si on rafraichit ou bien on ferme et réouvre le navigateur, l’authToken est bien entendu inexistant puisqu’il etait dans le store, la procédure est la suivante
    Au montage du composant, le refreshToken qui est valide demande à l’API un AuthToken qui sera à nouveau conservé dans le store de Pinia, si nécessaire le refreshToken se met à jour ce qui est le cas

    Vois tu une potentiel vulnérabilité ?

    Merci

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.