Apprendre Express.js : Sécuriser une API REST avec un JWT
Ce chapitre est dédié à l'élaboration d'un système de sécurisation des endpoints d'une API REST Express avec un JSON Web Token (JWT).
Afin d’illustrer la mécanique d’authentification via JWT, nous élaborerons une API minimaliste retournant des prénoms aléatoires en fonction d’une nationalité et d’un genre souhaitée.
Initialisation du projet
Création du package.json
Initialiser le fichier 📄 package.json
avec la commande suivante :
npm init
Installation des dépendances
Dans le cadre de ce tutoriel, notre API sera développée sur l’environnement d’exécution Node et nous utiliserons le framework JavaScript express
, ainsi que les paquets jsonwebtoken
, dotenv
et cookie-parser
.
express
: Le célèbre framework minimaliste et modulaire pour JavaScript.nodemon
: Une bibliothèque pour redémarrer automatiquement l’application Node.js à chaque fois que des changements sont détectés dans les fichiers du projet. Cela facilite le processus de développement en ne nécessitant pas de redémarrer manuellement le serveur à chaque modification du code.jsonwebtoken
: Une bibliothèque pour la création et la vérification de JSON Web Tokens (JWT). Les JWT sont utilisés pour l’authentification et l’autorisation dans les applications web.dotenv
: Une bibliothèque pour charger dans l’application des variables d’environnement définies dans un fichier📄.env
. Cela permet de stocker des configurations sensibles ou spécifiques à l’environnement, comme des clés d’API, sans les exposer directement dans le code source. Nous l’exploiterons pour stocker la clé secrète de notre JWT.cookie-parser
: Une bibliothèque qui sert à analyser les cookies HTTP entrants, les rendant disponibles sous forme d’objet.
Pour installer ces paquets, utiliser la commande suivante dans le terminal, à partir du répertoire 📂 api-prenoms
:
npm install express nodemon dotenv jsonwebtoken cookie-parser
Création du serveur
Ajouter dedans le code ci-dessous :
const express = require("express");
const app = express();
app.get("/", (req, res) => {
res.send({ message: "Hello" });
});
const hostname = "127.0.0.1";
const port = process.env.PORT || 3000;
app.listen(port, hostname, () => {
console.log(`Serveur démarré sur http://${hostname}:${port}`);
});
Lancement du serveur
Pour faciliter le lancement de notre serveur, configurons un script npm run dev
au sein de notre 📄 package.json
.
{
// ...
"main": "server.js",
"scripts": {
"dev": "nodemon server"
},
// ...
}
Profitons-en au passage pour redéfinir la clé main
en server.js
et non index.js
.
Le serveur de notre application se contentera, pour les requêtes vers http://127.0.0.1:3000/
, de retourner l’objet JSON suivant :
{
"message": "Hello"
}
Endpoint 1 (GET /api/firstnames
)
Base de données
Commençons par créer une base de données rudimentaire au sein d’un module JS exportant des prénoms d’utilisateurs, classés par nationalité et par genre.
module.exports = {
fr: {
male: ["Pierre", "Paul", "Jack"],
female: ["Sophie", "Laura", "Camille"]
},
en: {
male: ["John", "Mike", "Peter"],
female: ["Jane", "Elisabeth", "Suzanne"]
},
es: {
male: ["Alejandro", "Pedro", "Alfredo"],
female: ["Maria", "Monica", "Silvia"]
}
}
Retour d’un prénom aléatoire
Il est temps de construire sur l’objet router
d’express une route dont le rôle sera de retourner un prénom aléatoire, en considérant les paramètres d’URL country
et gender
reçus.
Cette route sera, à terme, montée sur le chemin /api/firstnames
.
Ainsi, le chemin /api/firstnames?country=en&gender=female
retournerait un prénom parmi : « Jane », « Elisabeth » ou « Suzanne ».
const express = require('express');
const router = express.Router();
const firstnames = require('../db/firstnames');
router.get("/", (req, res) => {
// Extraction des paramètres d'URL country et gender
const { country, gender } = req.query;
if (!country || !gender) res.status(400).json({ message: "Erreur dans la requête." });
// Récupération de l'ensemble des prénoms pour les paramètres spécifiés
const candidateFirstnames = firstnames[country][gender];
if (!candidateFirstnames) res.status(400).json({ message: "Erreur dans la requête." });
// Extraction d'un prénom aléatoire depuis la liste récupérée
const firstname = candidateFirstnames[Math.floor(Math.random() * candidateFirstnames.length)];
res.json({ firstname });
});
module.exports = router;
Importons notre route précédente au sein d’un fichier 📄 index.js
dont le seul rôle est de centraliser l’ensemble des routes de l’application pour alléger leur import par notre serveur.
const express = require("express");
const router = express.Router();
const firstnameRoutes = require("./firstname");
router.use("/firstnames", firstnameRoutes);
module.exports = router;
Mettons enfin à jour notre serveur pour y importer les routes agrégées dans 📄 routes/index.js
. Profitons-en au passage pour supprimer notre route de test affichant « Hello ».
const express = require("express");
const app = express();
app.get("/", (req, res) => {
res.send({ message: "Hello" });
});
const routes = require('./routes');
app.use("/api", routes);
const hostname = "127.0.0.1";
const port = process.env.PORT || 3000;
app.listen(port, hostname, () => {
console.log(`Serveur démarré sur http://${hostname}:${port}`);
});
Désormais, le endpoint de l’API /api/firstnames?country=en&gender=female
retourne bien un prénom anglais féminin. Mais ce endpoint est public… et je ne souhaite le rendre accessible qu’aux utilisateurs authentifiés sur mon application ! C’est ici qu’entre en jeu l’authentification par JWT.
Endpoint 2 (POST /api/login
)
Déclaration d’un secret pour notre JWT
Pour être en mesure de générer un JWT, il faut avant toute chose définir un secret, qui sera utilisé pour encoder et décoder le JWT.
Il est temps de créer un fichier 📄 .env
et d’y définir notre JWT_SECRET
au sein d’une variable d’environnement.
JWT_SECRET=3939a257017821afc405406c53cd22741720d24871e43ff24792a47045fdc083
Rendons les variables d’environnement accessibles de partout en éditant notre serveur :
require('dotenv').config();
const express = require("express");
const app = express();
// ...
Création du JWT
Il est temps de construire sur l’objet router
d’express une route dont le rôle sera d’authentifier un utilisateur ayant saisi un couple username/password.
Cette route sera, à terme, montée sur le chemin /api/login
.
const express = require('express');
const router = express.Router();
const jwt = require("jsonwebtoken");
router.post("/login", (req, res) => {
// Récupération des paramètres POST (username et password)
const { username, password } = req.body;
if (password === "toto") {
// Encodage du JWT via la variable d'environnement JWT_SECRET
const jwtToken = jwt.sign({ username }, process.env.JWT_SECRET, {
expiresIn: "1h",
});
// Stockage du JWT dans un cookie HttpOnly
res.cookie("jwtToken", jwtToken, { httpOnly: true, secure: true });
res.json(jwtToken);
} else {
res.status(401).json({ message: "Authentification échouée." });
}
});
module.exports = router;
Pour récupérer les paramètres POST
envoyés en JSON dans le corps de la requête, nous devons mettre à jour notre serveur en y exploitant le middleware express.json()
.
// ...
app.use(express.json());
app.use("/api", routes);
// ...
Ici, le token est stocké dans un cookie HttpOnly
qui ne sera transmis que via le protocole HTTPS (secure: true
), afin de limiter les failles XSS. Cette méthode s’avère plus prudente que via le local storage ou session storage.
Mettons à jour 📄 routes/index.js
pour y incorporer la route de connexion.
const express = require("express");
const router = express.Router();
const authRoutes = require("./auth");
const firstnameRoutes = require("./firstname");
router.use("/", authRoutes);
router.use("/firstnames", firstnameRoutes);
module.exports = router;
Endpoint 3 (GET /api/logout
)
La déconnexion se résume à créer une route dans 📄routes/auth.js
dont le rôle va être de supprimer le cookie jwtToken
lorsqu’elle reçoit une requête HTTP GET
.
// ...
router.get("/logout", (req, res) => {
res.clearCookie("jwtToken");
res.redirect('/');
});
module.exports = router;
Middleware : sécurisation des endpoints
Il est temps d’exploiter le JWT généré en accédant à POST /api/login
afin de sécuriser le endpoint GET /api/firstnames
.
Création du middleware d’autorisation
Le rôle de notre middleware sera relativement simple.
- Il se chargera dans un premier temps de chercher si un cookie
HttpOnly
nomméjwtToken
existe bien. - De vérifier le cas échéant, la validité du JWT.
const jwt = require("jsonwebtoken");
function auth(req, res, next) {
const jwtToken = req.cookies["jwtToken"];
if (!jwtToken) {
return res.status(401).json({ message: "Non autorisé" });
}
jwt.verify(jwtToken, process.env.JWT_SECRET, (err, decoded) => {
if (err) {
return res.status(401).json({ message: "Non autorisé" });
}
next();
});
}
module.exports = auth;
La fonction next()
permet de passer la main au middleware suivant dans la pile d’exécution (c’est cette fonction qui nous permet d’aller jusqu’aux routes).
Afin de pouvoir analyser les cookies présents dans la requête HTTP, on utilise le middleware cookie-parser
. Je vous invite en conséquence à mettre à jour votre serveur :
// ...
const cookieParser = require("cookie-parser");
const routes = require('./routes');
app.use(cookieParser());
app.use(express.json());
// ...
Montage sur les routes à sécuriser
Importons notre middleware au sein du fichier de routage principal afin de sécuriser le endpoint GET /api/firstnames
:
// ...
const auth = require("../middlewares/auth");
const authRoutes = require("./auth");
const firstnameRoutes = require("./firstname");
router.use("/firstnames", auth, firstnameRoutes);
// ...
Quelques précisions
Ce chapitre met en lumière les mécaniques de création/vérification d’un JWT. Par souci de simplification, la sécurisation des attaques CSRF n’y est pas détaillée. Je vous invite en revanche à ne pas négliger ce point en configurant une bibliothèque de sécurisation CSRF adaptée.
Aussi, si vous souhaitez interagir avec votre API par l’intermédiaire d’une vraie interface frontend et non pas via un outil de test d’API tel que Postman ou Insomnia, je vous invite à regarder du côté de la librairie cors. CORS
(Cross-Origin Resource Sharing) est un mécanisme de sécurité qui accepte ou bloque les requêtes HTTP depuis différents domaines. Ce package ajoute les en-têtes CORS
nécessaires aux réponses HTTP, facilitant ainsi l’accès à une API depuis différents domaines.