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

Icône de calendrier
Intermédiaire
8 chapitres

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 :

copié !
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 :

copié !
npm install express nodemon dotenv jsonwebtoken cookie-parser

Création du serveur

Ajouter dedans le code ci-dessous :

server.js
copié !
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.

package.json
copié !
{
	// ...
	"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.

db/firstnames.js
copié !
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 ».

routes/firstname.js
copié !
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.

routes/index.js
copié !
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 ».

server.js
copié !
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.

.env
copié !
JWT_SECRET=3939a257017821afc405406c53cd22741720d24871e43ff24792a47045fdc083

Rendons les variables d’environnement accessibles de partout en éditant notre serveur :

server.js
copié !
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.

routes/auth.js
copié !
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().

server.js
copié !
// ...

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.

routes/index.js
copié !
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.

routes/auth.js
copié !
// ...

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.

  1. Il se chargera dans un premier temps de chercher si un cookie HttpOnly nommé jwtToken existe bien.
  2. De vérifier le cas échéant, la validité du JWT.
middlewares/auth.js
copié !
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 :

server.js
copié !
// ...

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 :

routes/index.js
copié !
// ...

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.