Nous avons vu dans le précédent article, les recommandations concernant la sécurisation des échanges entre un client et une API REST. Nous allons donc aujourd’hui nous attaquer à l’implémentation de celles-ci en Node.js.
Avant de commencer…
Je tiens à m’excuser pour ces mois (années…) d’absences, mais comme je l’ai dit le blog n’est pas mort et je compte bien revenir pour cette année 2020, j’ai pas mal d’articles en cours de rédaction et je vais tenter d’être plus régulier.
Dans cet article, je vais vous montrer comment sécuriser une API REST utilisant le framework Express. Je tiens à préciser que le but de cet article n’est pas de vous expliquer comment mettre en place un projet Express de A à Z, mais plutôt de se concentrer sur l’aspect sécurité. Par conséquent, nous nous concentrerons uniquement sur la mise en place du serveur HTTPS et de la sécurisation de nos routes en utilisant le standard JWT.
Bref, commençons !
Mise en place de HTTPS
Comme dit dans le précédent article, la première étape de la sécurisation d’une API REST est l’utilisation du protocole HTTPS. Pour cela, il nous faut obtenir un certificat SSL.
Plusieurs solutions s’offrent à nous :
- Obtenir notre certificat SSL auprès d’une autorité de certification (pensez à Let’s Encrypt qui est gratuit);
- Générer un certificat auto signé.
Pour cet article, nous allons générer un certificat auto signé.
Pour une mise en production, utiliser toujours un certificat obtenu auprès d’une autorité de certification.
Générer un certificat auto signé
Afin de générer notre certificat auto signé, nous avons besoin de OpenSLL et de lancer cette ligne de commande :
1 | openssl req -nodes -new -x509 -keyout server.key -out server.cert |
Nous obtenons deux fichiers :
server.key
qui contient la clé privée;server.cert
qui contient le certificat.
Création du serveur HTTPS
Il nous faut maintenant créer notre serveur HTTPS :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | const fs = require('fs'); const path = require('path'); const https = require('https'); const express = require('express'); /* On créer notre application Express */ const app = express(); /* On récupère notre clé privée et notre certificat (ici ils se trouvent dans le dossier certificate) */ const key = fs.readFileSync(path.join(__dirname, 'certificate', 'server.key')); const cert = fs.readFileSync(path.join(__dirname, 'certificate', 'server.cert')); const options = { key, cert }; /* Puis on créer notre serveur HTTPS */ https.createServer(options, app).listen(8080, () => { console.log('App is running ! Go to https://localhost:8080'); }); |
Voilà c’était simple non ? Notre serveur utilise maintenant le protocole HTTPS.
Il existe également une autre solution possible qui consiste à déporter la responsabilité de la communication HTTPS entre le client et le serveur à ce qu’on appelle un « reverse proxy« .
Un reverse proxy ? C’est quoi ?
Voyons la définition de notre cher Wikipédia :
Un proxy inverse (reverse proxy) est un type de serveur, habituellement placé en frontal de serveurs web. Contrairement au serveur proxy qui permet à un utilisateur d’accéder au réseau Internet, le proxy inverse permet à un utilisateur d’Internet d’accéder à des serveurs internes.
https://fr.wikipedia.org/wiki/Proxy_inverse
Bon, pour une fois c’est assez clair, le reverse proxy permet simplement de faire l’intermédiaire entre vos serveurs internes et un client.
Principe d’un reverse proxy
Il existe plusieurs solutions de reverse proxy sur le marché comme HAProxy ou encore Traefik , mais la plus connue d’entre elles est nginx. Voyons rapidement comment la mettre en place.
Mise en place de nginx
Tout d’abord, commençons par l’installation de nginx:
1 | sudo apt-get install nginx |
Une fois installé, il nous faut créer un fichier contenant notre « server block » qui est l’équivalent des virtual hosts d’Apache :
1 | sudo vim /etc/nginx/sites-available/www.example.com.conf |
Voici le contenu du fichier :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | upstream mon_api{ server localhost:8080; } server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name example.com www.example.com; ssl_certificate path/server.cert; ssl_certificate_key path/server.key; location / { proxy_pass http://mon_api; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } } |
Pensez à modifier l’adresse et le port de votre API, votre nom de domaine, ainsi que le chemin d’accès de votre certificat et de votre clé privée.
Activons ensuite notre « server block » :
1 | sudo ln -s /etc/nginx/sites-available/www.example.com.conf /etc/nginx/sites-enabled/www.example.com.conf |
Pour terminer, redémarrons notre serveur nginx :
1 | sudo systemctl reload nginx |
Votre API est maintenant accessible en HTTPS via votre nom de domaine.
Authentification via JWT
Pour commencer, il nous faut une route permettant aux utilisateurs de se connecter et récupérer un JWT. Nous aurons besoin de la librairie jsonwebtoken
pour créer nos JWT :
1 | npm install --save jsonwebtoken |
Créons ensuite 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 | const jwt = require('jsonwebtoken'); const { User, RefreshToken } = require('./models'); const config = require('./config'); app.post('/login', async (req, res, next) => { try { /* 1. On récupère le nom d'utilisateur et le mot de passe dans la requête */ const { username, password } = req.body; /* 2. 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' }); } /* 3. 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' }); } /* 4. On authentifie l'utilisateur */ const user = await User.authenticate(username, password); /* 5. 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' }); } /* 6. On créer le JWT */ const accessToken = jwt.sign( { firstName: user.firstName, lastName: user.lastName }, config.accessToken.secret, { algorithm: config.accessToken.algorithm, audience: config.accessToken.audience, expiresIn: config.accessToken.expiresIn / 1000, issuer: config.accessToken.issuer, subject: user.id.toString() } ); /* 7. 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 }); /* 7. On envoie au client le JWT et le refresh token */ return res.json({ accessToken, tokenType: config.accessToken.type, accessTokenExpiresIn: config.accessToken.expiresIn, refreshToken, refreshTokenExpiresIn: config.refreshToken.expiresIn }); } catch (err) { return res.status(500).json({ message: 'Internal server error' }); } }); |
L’étape suivante est de sécuriser nos routes pour permettre uniquement aux utilisateurs authentifiés d’y accéder. Imaginons que nous ayons la route suivante :
1 2 3 4 5 | app.get('/anything', (req, res) => { let data; /* récupération de données ... */ res.json(data); }); |
Comment protéger celle-ci à l’aide d’un JWT ? et bien nous allons utiliser un middleware.
Hey dis donc Jamy, c’est quoi un middleware ?
Un middleware est simplement une fonction permettant d’effectuer un traitement avant celui défini par les routes. Il possible de créer ce qu’on appelle une chaîne de middleware ou pipeline comme nous le montre le schéma suivant :
Chaîne de middleware
Chaque middleware, une fois son traitement terminé, peut soit faire appel au middleware suivant ou stopper la chaîne et envoyer une réponse au client.
La signature d’une fonction middleware est la suivante :
1 | function(req, res, next) { /* ... */ } |
req
: La requête HTTP du client;res
: La réponse HTTP à envoyer au client;next
: Un callback vers le prochain middleware;
Il existe un autre type de middleware permettant de traiter les erreurs qui possède la signature suivante :
1 | function(err, req, res, next) { /* ... */ } |
Celui-ci peut être appelé en passant l’erreur en paramètre de la fonction next
.
Middleware d’authentification
Revenons à nos moutons ! Voyons maintenant comment créer un middleware permettant de gérer l’authentification via les JWT et de renvoyer une erreur au client si l’authentification échoue.
Comme nous l’avons vu précédemment, un middleware permet d’accéder à la requête HTTP du client, nous devons donc récupérer le JWT présent dans les en-têtes de celle-ci, vérifier que celui-ci est valide et qu’il est bien associé à un utilisateur présent dans notre base de données :
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 | const jwt = require('jsonwebtoken'); const User = require('./models/User'); const { secret, algorithm } = require('./config'); async function auth(req, res, next) { try { const { headers } = req; /* 1. On vérifie que le header Authorization est présent dans la requête */ if (!headers || !headers.authorization) { return res.status(401).json({ message: 'Missing Authorization header' }); } /* 2. On vérifie que le header Authorization contient bien le token */ const [scheme, token] = headers.authorization.split(' '); if (!scheme || scheme.toLowerCase() !== 'bearer' || !token) { return res.status(401).json({ message: 'Header format is Authorization: Bearer token' }); } /* 3. 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(token, secret, { algorithms: algorithm }); /* 4. 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` }); } /* 5. On passe l'utilisateur dans notre requête afin que celui-ci soit disponible pour les prochains middlewares */ req.user = user; /* 7. On appelle le prochain middleware */ return next(); } catch (err) { return res.status(401).json({ message: 'Invalid token' }); } } |
Protégeons ensuite notre route. Pour cela il suffit d’appeler notre middleware juste avant le « handler » de notre route :
1 2 3 4 5 6 | app.get('/anything', auth, (req, res) => { let data; /* récupération de données ... */ res.json(data); }); |
Notre route est maintenant protégée ! Rien de bien compliqué.
On a fini ?
Oui c’est terminé, ce n’était pas la mer à boire hein ? Comme je l’ai dit en introduction le but de l’article n’était pas de vous montrer comment créer une application Express de A à Z, mais de se concentrer sur la mise en place du protocole HTTPS et de la sécurisation de nos routes à l’aide de JWT. Certaines parties n’ont donc pas été présentées notamment celles concernant l’interaction avec la base de données ou encore la connexion d’un utilisateur et la génération d’un JWT associé, c’est pourquoi j’ai créé un projet complet sur github. N’hésitez surtout pas à aller voir et me poser des questions si besoin.
Des middlewares permettant de gérer l’authentification via JWT existent, comme par exemple express-jwt ou encore passport-jwt je vous invite grandement à aller y jeter un coup d’œil, la curiosité est la meilleure des qualités pour un développeur.
J’ai dit dans la première partie qu’il était également possible de protéger son API à l’aide d’une clé d’API donc pour celles et ceux que ça intéresse, j’ai écrit un middleware disponible sur github.
Bon, on a pas encore fini avec la sécurité d’une API REST, il y aura une partie 3 à cet article qui portera sur le stockage des JWT notamment coté front cela parlera de failles XSS et CSRF je n’en dis pas plus… par contre je ne sais pas encore pour quand c’est prévu, mais promis pas dans 2 ans ! Entre-temps d’autres articles arriveront. On se retrouve bientôt !
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.
Sans votre fichier model ainsi que votre fichier config c’est compliqué de comprendre, j’avou etre un peu perdu du coup sur vos méthode
J’ai volontairement simplifié les exemples pour ne pas surcharger l’article avec la gestion de la BDD qui peut être différente suivant les projets (utilisation de MySQL ou de MongoDB, utilisation d’un ORM ou non, etc.). J’ai malgré tout mis à disposition sur Github (https://github.com/arkerone/express-security-example) un projet qui met en œuvre ce qui est expliqué dans l’article.
Super Article et exemple de code, notamment sur Github, magnifique ! Je m’inspire de ton approche model/controller pour construire ma nouvelle codebase backend. J’utilise Fastify en lieu et place d’Express par contre ainsi que Redis en DB. Pour la partie frontend de ma codebase j’ai choisi Svelte . Encore merci pour ton travail 🙂
Merci pour cet article inspirant. 🙂