b) Sécurisation d'une RESTful API
C'est quoi l'authentification et l'autorisation ?
Authentification, c'est quoi ?
L'authentification, c'est le processus de vérifier qui est l'utilisateur d'une application.
Pour authentifier un utilisateur, on va généralement passer via un formulaire de connexion, lui demandant un username et un password.
Autorisation, c'est quoi ?
L'autorisation, c'est le processus de vérifier ce à quoi un utilisateur à accès.
Une API va autoriser les accès à certaines opérations en fonction des privilèges associés aux utilisateurs. Il y aura des opérations qui seront autorisées :
- pour tous les utilisateurs, tant anonymes qu'authentifiés ;
imaginez par exemple les opérations de lecture de produits sur un site "vitrine". Il ne faut pas devoir créer de compte utilisateur pour pouvoir afficher les produits du site. - pour tous les utilisateurs authentifiés, peu importe leurs privilèges ;
imaginez le fait de pouvoir poster des messages dans un forum ; il faut avoir un compte pour pouvoir le faire, mais il ne faut pas de privilèges spécifiques (pas besoin d'être admin). - pour seulement un ou plusieurs utilisateur(s) authentifié(s) ayant les privilèges requis ;
imaginez une opération de lecture de tous les profils des utilisateurs d'une banque ; il faut avoir un compte admin de la banque pour pouvoir le faire. Ca serait catastrophique si n'importe quel utilisateur authentifié pourrait accéder aux profils de tous les utilisateurs !
Différents moyens d'authentification
Authentification stateful
Traditionnellement, ou anciennement, nous avons l'authentification qui est supportée à l'aide de cookies. L'utilisateur envoie via un formulaire son username et password, le serveur vérifie ceux-ci et crée un id de session et le renvoie à l'utilisateur via un cookie.
Après l'authentification, à chaque requête du client sur ce serveur, le cookie est envoyé, et le serveur, qui a sauvegardé la session, va la retrouver sur base de l'id de session présent dans le cookie et va autoriser ou non l'accès à l'opération demandée par le client.
C'est ce qu'on appelle une authentification stateful.
Un mécanisme d'authentification stateful indique que le serveur est responsable de sauvegarder les données de session des utilisateurs.
On parle de session d'un utilisateur comme étant toute la durée où le client s'authentifie à l'application web jusqu'à ce qu'il quitte cette application, lorsqu'il ferme son browser.
Authentification stateless
De manière plus moderne, nous avons des mécanismes d'authentification qui sont dits stateless. Dans ce genre de mécanisme, c'est le client qui doit sauvegarder les données de session, et donc le browser. L'authentification à l'aide de tokens devient très populaire.
Il existe différents moyen de l'implémenter. Dans le cadre de ce cours, nous focaliserons sur la façon la plus habituelle, les JSON Web Tokens, ou JWT.
Il existe d'autres mécanismes très modernes qui utilisent des tokens, et qui sont mis en place par des tiers : OAuth, OpenId... Ces mécanismes sont offerts par Microsoft, Google, Facebook... Nous ne les verrons pas dans le cadre de cours. Néanmoins, à la fin de ce cours, vous devriez être apte à pouvoir les utiliser, sous réserve de bien lire la documentation 😉.
Stateful VS stateless authentication
Quels sont les avantages & inconvénients d'une authentification stateful ? et d'une authentification stateless ?
Authentification stateful
Inconvénients :
- La session utilise de la mémoire pour chaque utilisateur.
- Le backend n'a aucune manière de déterminer si le frontend s'est déconnecté du site ou non : gestion de l'expiration d'une session plus compliquée, notamment si le frontend revient après une longue durée.
- Toutes les sessions sont perdues en cas de redémarrage du serveur (ré-authentification).
- Load balancing compliqué ; en effet, si un client fait une requête, pendant toute la durée de la session, s'il y a plusieurs serveurs qui peuvent y répondre, comment est gérée la session de ce client ? Par quel(s) serveur(s) ? Comment se partagent-ils les données de session ? Est-ce que ça doit toujours être le même serveur qui réponde au même client ?
Avantages :
- Peu gourmand en ressource point de vue processing et très rapide ; en effet, une fois qu'une session est en place, la vérification que le cookie contient le bon id de session se fait vite.
Authentification stateless
Inconvénients : Comme le serveur ne retient plus l'utilisateur en mémoire, il doit utiliser de la cryptographie pour créer et valider les token ; c'est donc consommateur en ressource point de vue processing.
Avantages :
- Pas de session à gérer, même après redémarrage du serveur, il n'y a pas de ré-authentification nécessaire.
- Évolutif, utilisation facile de plusieurs serveurs si du load balancing est nécessaire ; en effet, comme chaque requête du client contient toutes les infos pour se faire autoriser, il n'est pas nécessaire de savoir quel serveur va prendre en charge la requête.
Notons que la "scalability" horizontale, la possibilité de permettre à une application d'augmenter sa capacité de répondre à une charge grandissante simplement en ajoutant des machines, est quelque chose de très important à notre époque.
Certaines applications web ont des centaines de millions d'utilisateurs ; pour celles-ci, on ne peut pas compter sur la "scalability" verticale, c'est-à-dire le fait d'augmenter les ressources d'une machine, en augmentant sa RAM, son processeur, son espace de stockage...
Dès lors, dans le cadre de ce cours, nous allons préférer les applications web qui peuvent tourner sur des serveurs stateless.
C'est quoi les tokens JWT ?
Les JSON Web token, ou JWT, appartiennent à un standard internet permettant l'échange sécurisé de tokens entre plusieurs parties.
Un JWT contient trois parties encodées en base64 et ressemble à qqch du style :
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Voici les 3 parties d'un JWT et leurs caractéristiques :
- l'entête (ou header) : un objet JSON identifiant le type de token (JWT) et l'algorithme utilisé pour générer la signature du token (HS256), un algorithme de hachage.
- le payload : un objet JSON permettant de spécifier le claim ; ce sont des paramètres optionnels précisant les affirmations associées au token, comme la date d'expiration du token, son créateur, le fait que l'utilisateur est admin... généralement, on y met pas trop d'info, principalement l'identifiant de l'utilisateur ; si l'API a besoin de plus d'info concernant l'utilisateur, elle ira généralement les chercher dans ses données.
- la signature : c'est une signature numérique construite à partir d'un secret privé ou d'une clé publique/privée, via l'algorithme précisé dans la signature.
💭 C'est bien joli tout ça, mais comment ça permet de sécuriser la session d'un utilisateur ?
Généralement, l'API, et elle uniquement, connaît le secret privé. Elle va donc créer le token d'un utilisateur authentifié, en y ajoutant le claim (ou les affirmations) qu'elle juge utile.
Lorsque le client reçoit son token, il va le sauvegarder dans son browser.
Pour chaque requête nécessitant une autorisation de l'API, le client enverra son token au sein du header de la requête.
L'API utilisera le secret privé, connu d'elle-même uniquement, pour vérifier la signature du token. Si tout est OK, c'est que l'utilisateur est bien authentifié et que son claim est OK.
Imaginez maintenant qu'un hacker tente de modifier le token, pour modifier le claim, notamment en changeant l'id de l'utilisateur présent dans le payload du token.
Et bien la signature ne correspondra plus à ce token là ! L'API le détectera.
Pour que le hacker réussisse son acte malicieux, il est obligé de connaître le secret privé qui ne doit être connu que de l'API. Ca ne serait que sur base du secret privé que les hackers pourraient générer un nouveau token qui serait validé par l'API. Pas évident à faire...
Ainsi, on peut imaginer un token comme une enveloppe sécurisé par un cadenas très spécial : ce cadenas, qui est mis sur l'enveloppe contenant, par exemple, l'identifiant d'un utilisateur, est fermé par la clé 🔑 que seulement connaît l'API. Si quelqu'un touche à l'enveloppe, le cadenas ne s'ouvrira plus avec la 🔑 de l'API : la tentative de hack sera détectée ! Si quelqu'un tente de créer une enveloppe sécurisé, il le fera avec une autre clé, 🗝 par exemple, car il ne connaît pas 🔑. L'API, tentant d'ouvrir l'enveloppe sécurisée à l'aide de 🔑, elle ne s'ouvrira pas : la tentative de hacking sera détectée !
💭 Est-ce que cette métaphore d'enveloppe sécurisée vous parle ?
Attention que dans la réalité, le payload classique d'un token sera decodable, que l'on connaisse ou pas le secret privé. Il est encodé en base64 ! Ne mettez donc jamais de secrets dans le payload d'un token !
Si vraiment un jour vous deviez mettre un secret dans un payload, bien que déconseillé, vous pourriez toujours le faire en cryptant le payload.
👍 Le mécanisme de token assure que l'on aie une très haute probabilité de détecter les altérations faites sur les tokens, les tentatives de forgeage, grâce à la signature de ceux-ci.
⚡ Par contre, si nous laissions traîner des informations dans le payload d'un token, comme un password d'un utilisateur et son username, alors là, c'est juste un beau cadeau que l'on offre aux hackers ; ils n'auraient plus qu'à trouver un moyen de voler à distance nos données de session ; ils pourraient ensuite utiliser le formulaire de connexion et prendre possession de notre identité 😨.
Il existe un site fort intéressant qui montre la structure d'un token et qui permet de les décoder : jwt.io [R.63]. Nous l'utiliserons plus tard pour décoder les tokens générés par nos RESTful API.
Authentification et création de token
Introduction
Via un exemple concret associé à notre RESTful API qui gère les ressources de type "pizzas" et de type "boisson", nous allons mettre en place un processus d'authentification et d'autorisation JWT.
La librairie que nous allons utiliser dans le cadre de ce cours pour gérer des tokens JWT est jsonwebtoken.
Dans ce nouveau tutoriel, nous allons continuer le développement de l'API services pour ajouter des opérations permettant l'authentification et l'autorisation d'utilisateurs.
Veuillez créer un nouveau projet nommé auths
(dans votre repo git /web2/tutorials/pizzeria/api/auths
) sur base d'un copié/collé du tutoriel services.
Veuillez installer la librairie jsonwebtoken
au sein de votre nouveau projet auths
:
bashnpm i jsonwebtoken
Comme nous utilisons TS, nous devons aussi installer les types de cette librairie :
bashnpm i -D @types/jsonwebtoken
Mécanisme d'authentification et création du token
Toujours à l'aide d'Express, nous allons créer un router auths
qui mettra à disposition les opérations de login
et de register
.
Voici le contrat associé à ces nouvelles opérations :
Opérations sur les ressources de type "auths"
URI | Méthode HTTP | Opération |
---|---|---|
auths/login | POST | Vérifier les credentials d'une ressource de type "users" et renvoyer le username et un token JWT si les credentials sont OK |
auths/register | POST | Créer une ressource de type "users" et renvoyer le username et un token JWT |
Le service users
s'occupera de créer les utilisateur, de vérifier leurs credentials ainsi que de créer des tokens.
Voici le workflow attendu pour une opération de login
ou de register
:

Ce que l'on voit dans l'image ci-dessus :
- si le service considère que l'utilisateur est authentifiable, que ses credentials sont OK, alors il va utiliser la méthode
sign
de l'objetjwt
pour créer un token. - le token est envoyé dans le body de la réponse à l'utilisateur.
Ce que l'on ne voit pas dans l'image ci-dessus :
- c'est le job du client de sauvegarder le token. Si l'application cliente est un browser, alors celui-ci pourra être sauvegardé dans le web storage du browser.
- l'application cliente peut être faite avec n'importe quelle technologie. Par exemple, nous pourrons utiliser REST Client pour faire une requête à l'API...
Dans notre RESTful API, nous avons décidé que lorsqu'un client s'enregistre, quand il fait appel à l'opération register
pour créer un compte, l'API considère automatiquement que cet utilisateur est authentifié. Tout comme la méthode login
, la méthode register
créera et renverra un token JWT à l'utilisateur.
NB : Il est possible d'envisager un workflow différent : après le register
, l'application demanderait à l'utilisateur un login
, register
ne renvoyant jamais de token à l'utilisateur.
Implémentation de login & register
Nous allons à présent mettre en place le code permettant d'implémenter le workflow que l'on vient de découvrir.
Veuillez créer le service users
en créant le fichier /services/users.ts
et y inclure ce code :
ts1import jwt from "jsonwebtoken";2import path from "node:path";3import { parse, serialize } from "../utils/json";4import { AuthenticatedUser, User } from "../types";56const jwtSecret = "ilovemypizza!";7const lifetimeJwt = 24 * 60 * 60 * 1000; // in ms : 24 * 60 * 60 * 1000 = 24h89const jsonDbPath = path.join(__dirname, "/../data/users.json");1011const defaultUsers: User[] = [12{13id: 1,14username: "admin",15password: "admin",16},17];1819function login(20username: string,21password: string22): AuthenticatedUser | undefined {23const userFound = readOneUserFromUsername(username);24if (!userFound) return undefined;25if (userFound.password !== password) return undefined;2627const token = jwt.sign(28{ username }, // session data added to the payload (payload : part 2 of a JWT)29jwtSecret, // secret used for the signature (signature part 3 of a JWT)30{ expiresIn: lifetimeJwt } // lifetime of the JWT (added to the JWT payload)31);3233const authenticatedUser: AuthenticatedUser = {34username,35token,36};3738return authenticatedUser;39}4041function register(42username: string,43password: string44): AuthenticatedUser | undefined {45const userFound = readOneUserFromUsername(username);46if (userFound) return undefined;4748createOneUser(username, password);4950const token = jwt.sign(51{ username }, // session data added to the payload (payload : part 2 of a JWT)52jwtSecret, // secret used for the signature (signature part 3 of a JWT)53{ expiresIn: lifetimeJwt } // lifetime of the JWT (added to the JWT payload)54);5556const authenticatedUser: AuthenticatedUser = {57username,58token,59};6061return authenticatedUser;62}6364function readOneUserFromUsername(username: string) {65const users = parse(jsonDbPath, defaultUsers);66const userFound = users.find((user) => user.username === username);67if (!userFound) return undefined;6869return userFound;70}7172function createOneUser(username: string, password: string) {73const users = parse(jsonDbPath, defaultUsers);7475const nextId =76users.reduce((acc, user) => (user.id > acc ? user.id : acc), 0) + 1;7778const createdUser: User = {79id: nextId,80username,81password,82};8384users.push(createdUser);8586serialize(jsonDbPath, users);8788return createdUser;89}9091export { login, register, readOneUserFromUsername };
Dans le code ci-dessus, jwtSecret
est le secret privé connu uniquement du serveur. C'est la même secret qu'il faudra utiliser pour vérifier un token.
jwt.sign()
permet de créer le token et ses 3 parties :
- le payload du token, la 2ème partie du token, est complété principalement via le permier argument de
sign()
; ici, le token affirme que l'utilisateur possédant leusername
donné est authentifié. - le payload du token sera aussi modifié sur base de la durée d'expiration du token, selon l'argument
expiresIn
. jwtSecret
est utilisé pour créer la 3ème partie du token, sa signature.
Pour le service users
, nous avons du créer deux nouveaux types : AuthenticatedUser
et User
. Veuillez mettre à jour le fichier /types.ts
et y inclure ce code :
ts1interface AuthenticatedUser {2username: string;3token: string;4}56interface User {7id: number;8username: string;9password: string;10}1112export type {13Pizza,14NewPizza,15PizzaToUpdate,16Drink,17NewDrink,18AuthenticatedUser,19User,20};
Il faut maintenant que nous créions le router auths
offrant les opérations de login
et de register
, en faisant appel au service users
.
Veuillez créer le fichier /routes/auths.ts
et y inclure le code suivant :
tsimport { Router } from "express";import { PotentialUser } from "../types";import { login, register } from "../services/users";const router = Router();/* Register a user */router.post("/register", (req, res) => {const body: unknown = req.body;if (!body ||typeof body !== "object" ||!("username" in body) ||!("password" in body) ||typeof body.username !== "string" ||typeof body.password !== "string" ||!body.username.trim() ||!body.password.trim()) {return res.sendStatus(400);}const { username, password } = body as PotentialUser;const authenticatedUser = register(username, password);if (!authenticatedUser) {return res.sendStatus(409);}return res.json(authenticatedUser);});/* Login a user */router.post("/login", (req, res) => {const body: unknown = req.body;if (!body ||typeof body !== "object" ||!("username" in body) ||!("password" in body) ||typeof body.username !== "string" ||typeof body.password !== "string" ||!body.username.trim() ||!body.password.trim()) {return res.sendStatus(400);}const { username, password } = body as PotentialUser;const authenticatedUser = login(username, password);if (!authenticatedUser) {return res.sendStatus(401);}return res.json(authenticatedUser);});export default router;
Il n'y a rien de bien spécial à ce code. On fait simplement appel aux opérations du service users
.
Nous avons néanmoins du créer un nouveau type PotentialUser
. Veuillez mettre à jour le fichier /types.ts
et y inclure ce code :
tstype PotentialUser = Omit<User, "id">;export type {Pizza,NewPizza,PizzaToUpdate,Drink,NewDrink,AuthenticatedUser,User,PotentialUser,};
Attention, il faut rajouter le nouveau router au sein de app.js
pour que notre API puisse offrir les nouvelles opérations ; veuillez donc ajouter ce code dans /app.ts
:
ts1import express, { ErrorRequestHandler } from "express";23import usersRouter from "./routes/users";4import pizzaRouter from "./routes/pizzas";5import drinkRouter from "./routes/drinks";6import authsRouter from "./routes/auths";78const app = express();910app.use((_req, _res, next) => {11console.log(12"Time:",13new Date().toLocaleString("fr-FR", { timeZone: "Europe/Brussels" })14);15next();16});1718app.use(express.json());19app.use(express.urlencoded({ extended: false }));2021app.use("/users", usersRouter);22app.use("/pizzas", pizzaRouter);23app.use("/drinks", drinkRouter);24app.use("/auths", authsRouter);2526const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => {27console.error(err.stack);28return res.status(500).send("Something broke!");29};3031app.use(errorHandler);32export default app;
Utilisation de Rest Client pour tester les nouvelles opérations
Veuillez démarrer votre API auths
.
On va utiliser REST Client pour tester ces nouvelles opérations.
Veuillez créer le fichier /REST Client/auths.http
et y ajouter le code suivant :
http@baseUrl = http://localhost:3000### Try to login an unknown userPOST {{baseUrl}}/auths/loginContent-Type: application/json{"username":"unknown","password":"admin"}### Login the default adminPOST {{baseUrl}}/auths/loginContent-Type: application/json{"username":"admin","password":"admin"}### Create the manager userPOST {{baseUrl}}/auths/registerContent-Type: application/json{"username":"manager","password":"manager"}### Login the manager userPOST {{baseUrl}}/auths/loginContent-Type: application/json{"username":"manager","password":"manager"}
Veuillez exécuter les différentes requêtes. Tout devrait fonctionner, vous devriez récupérer le username et le token d'un utilisateur authentifié.
Pour le fun, nous allons décoder un token :
- Veuillez copier le token de ce que renvoie votre API pour la requête de login de l'utilisateur
manager
(qqch qui doit ressembler à une string du genre :eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Im1hbmFnZXIiLCJpYXQiOjE2NjEyNDg1MjksImV4cCI6MTc0NzY0ODUyOX0.JvYzM0gtmVkqFr9R3f1Bf6ow_QuyHJY-vedJ39N7JKw
). - Veuillez vous rendre sur le site JWT et coller votre token dans le champs
Encoded
. Dans la colonneDecoded
, vous devriez voir l'algorithme utilisé par la méthodesign
, ainsi que les données présentes dans le payload, dont"username": "manager"
!
Vous comprenez maintenant pourquoi on n'ajoute pas des secrets dans un token 😉.
Autorisation et vérification de token
Mécanisme d'autorisation d'utilisateurs à des opérations d'une API
Intro
Dans le workflow que nous avons choisi, les utilisateurs reçoivent un token lors du register
ou du login
. C'est le job de l'application cliente de trouver un moyen de sauvegarder ce token.
Lorsqu'une application client souhaite créer une pizza, on souhaite autoriser cette opération qu'à l'administrateur du site gérant la pizzeria.
Nous décidons donc pour l'opération de création de pizza que l'utilisateur doit non seulement être authentifié, mais qu'en plus, il doit être l'admin du site.
Mécanisme d'autorisation quand le token JWT est valide
Pour que l'application client qui fait la requête à l'API puisse s'autoriser, elle doit ajouter un authorization header
à sa requête contenant comme valeur le token reçu lors du login
ou du register
:

Le router de "pizzas", avant même de passer la requête à la route POST /pizzas
, va lancer la fonction middleware d'autorisation nommée authorize
. La fonction middleware authorize
va s'occuper de vérifier le token envoyé par le client dans un header à l'aide de la méthode verifiy
de l'objet jwt
(de la librairie jsonwebtoken
).
💭 Si le token est valide, est-ce que ça signifie que l'utilisateur est bien authentifié ?
Hé bien non, car imaginez qu'entre le moment où l'utilisateur se soit logué, il ait été supprimé de l'application par un admin... dans ce cas-ci, on ne veut pas l'autoriser à ajouter une pizza au menu de la pizzeria ! Il n'est p-e même plus un employé, il souhaite p-e écrire du contenu malicieux 😨
Donc si le token est valide, authorize
fera appel au service users
pour vérifier que l'utilisateur existe bien dans le support de données (fichiers JSON, base de données ou autres) et pour obtenir toutes les infos sur cet utilisateur. Si l'utilisateur existe, alors cela signifie que l'utilisateur est bien authentifié. La fonction middleware authorize
va passer la main à une autre fonction middleware pour vérifier que l'utilisateur est bien l'admin.
💭 Comment est-ce que authorize
peut faire appel au service pour vérifier que l'utilisateur existe bien ? Est-ce que cela signifie que le token doit contenir tout un tas de données sur l'utilisateur ?
👍 En règle générale, on va juste mettre un identifiant de l'utilisateur dans ce qu'on appelle le payload du token. C'est ensuite l'API, suite au décodage de l'identifiant de l'utilisateur, qui fera appel aux données pour retrouver tout ce qui concerne l'utilisateur.
La fonction middleware isAdmin
va vérifier que l'utilisateur est bien l'admin. Si c'est OK, elle passera la main à la fonction middleware qui gère la route POST /pizzas
au sein du router de pizzas en faisant l'appel à la fonction next
.
🍬 NB : on pourrait aussi laisser la fonction creatPizza
s'occuper de vérifier que l'utilisateur demandant la création de la pizza soit bien l'admin. Ici, comme il s'agit de vérifier que le contrat de l'API soit respecté, c'est-à-dire que l'utilisateur soit bien authentifié & admin, alors on préfère réaliser cette action en dehors du service. Dans nos choix architecturaux pour nos RESTful API, nous avons décidé que ce n'est pas le service qui s'occupe de présenter les données aux clients, c'est le rôle des routers (et les fonctions middleware associées).
Maintenant que le client a les bons privilèges, l'opération de création de la pizza peut donc être autorisée. L'opération de createPizza
du service renverra la nouvelle pizza au router qui s'occupera de présenter la nouvelle pizza au client, au format JSON.
Mécanisme d'autorisation quand le token JWT est invalide
Voici le workflow d'autorisation si le token n'est pas valide :

Dans ce cas là, authorize
ne passe pas la main à isAdmin
ni même à la fonction middleware qui gère la route POST /pizzas
.
La fonction middleware authorize
renvoie directement un code d'erreur 401 Unauthorized
au client.
Mécanisme d'autorisation quand l'utilisateur n'est pas admin
Vous pourriez aussi imaginer le workflow où le client envoie un token valide, mais l'utilisateur associé n'est pas admin. Dans ce cas-ci, authorize
ferait appel à isAdmin
, mais isAdmin
renverrait directement un code d'erreur 403 Forbidden
au client.
En effet, l'API indiquerait ainsi qu'elle aurait vérifié que l'utilisateur est bien authentifié, mais que celui ne possède pas les privilèges suffisant pour accéder à l'opération demandée.
Implémentation du mécanisme d'autorisation
😨 Wow, le workflow d'autorisation pour la création d'une pizza est assez long.
Nous allons maintenant l'implémenter dans notre tutoriel en cours.
Nous vous inquiétez pas, ce qui importe dans le code qui va suivre, ce n'est pas de savoir écrire tout le code, mais de bien comprendre les mécanismes associés. En effet, en règle générale, vous allez utiliser des librairies vous permettant d'autoriser les accès aux opérations de vos API. Vous écrirez donc rarement les mécanismes d'autorisation, par contre, vous devrez pouvoir les utiliser.
Veuillez créer les nouvelles fonctions middleware authorize
et isAdmin
au sein d'un nouveau fichier /utils/auths.ts
:
ts1import jwt from "jsonwebtoken";2import { readOneUserFromUsername } from "../services/users";3import { NextFunction, RequestHandler, Response } from "express";4import { AuthenticatedRequest, JwtPayload, User } from "../types";56const jwtSecret = "ilovemypizza!";78const authorize = (9req: AuthenticatedRequest,10res: Response,11next: NextFunction12) => {13const token = req.get("authorization");14if (!token) {15return res.sendStatus(401);16}1718try {19const decoded = jwt.verify(token, jwtSecret) as JwtPayload;20const { username } = decoded;2122const existingUser = readOneUserFromUsername(username);2324if (!existingUser) {25return res.sendStatus(401);26}2728req.user = existingUser; // request.user object is available in all other middleware functions29return next();30} catch (err) {31console.error("authorize: ", err);32return res.sendStatus(401);33}34};3536const isAdmin: RequestHandler = (req: AuthenticatedRequest, res, next) => {37const { username } = req.user as User;3839if (username !== "admin") {40return res.sendStatus(403);41}42return next();43};4445export { authorize, isAdmin };
Voici quelques explications sur le code de la fonction middleware authorize
:
- Grâce à
req.get('authorization')
(ligne 13), on récupère le token qui a été envoyé par le client au sein de l'authorization header
de la requête sous forme de string. - La méthode
jwt.verify(token, jwtSecret)
(ligne 19) vérifie tant la signature du token que son éventuelle expiration. Elle utilise le secretjwtSecret
qui doit être le même que celui pris en compte lors de la création du token. - Elle charge toutes les données de l'utilisateur authentifié au sein de l'objet
req
(ligne 28), dansuser
. Cela est une bonne pratique, cela permet, pour toute la durée du traitement de cette requête, de mettre à disposition ces données à toutes les fonctions middleware.
💭 Mais pourquoi faire cela ? Imaginez que vous faites un appel à une base de données externes à chaque fois que vous souhaitez obtenir les informations d'un utilisateur... Cela est très consommateur en temps... Lorsque vous allez utiliser d'autres fonctions middleware commeisAdmin
, vous n'avez plus besoin de faire appel à la base de données.
La fonction middleware isAdmin
récupère les données de l'utilisateur authentifié via l'objet req.user
.
Si l'utilisateur n'est pas admin
, c'est le code 403 Forbidden
qui est renvoyé à l'application cliente, signifiant que l'utilisateur est bien authentifié, mais il n'a pas les privilèges pour accéder à l'opération demandée (création de pizza).
Si tout est OK, isAdmin
fait appel à next()
, ce qui consiste à exécuter la prochaine fonction middleware qui est présente après l'appel de isAdmin
.
Pour créer ces deux fonctions middleware, nous avons du créer deux nouveaux types : AuthenticatedRequest
et JwtPayload
. Veuillez mettre à jour le fichier /types.ts
et y inclure ce code :
ts1import { Request } from "express";23// Reste du code45interface AuthenticatedRequest extends Request {6user?: User;7}89interface JwtPayload {10username: string;11exp: number; // Expiration time (in seconds since the epoch)12iat: number; // Issued at time (in seconds since the epoch)13}1415export type {16Pizza,17NewPizza,18PizzaToUpdate,19Drink,20NewDrink,21AuthenticatedUser,22User,23PotentialUser,24AuthenticatedRequest,25JwtPayload,26};
Ici nous avons ajouté un nouveau type AuthenticatedRequest
qui étend le type Request
d'Express, ceci afin de pouvoir ajouter un objet de type User
à req
.
Nous avons aussi ajouté un nouveau type JwtPayload
qui représente le payload d'un token JWT. Ce type est utilisé pour typer l'objet decoded
retourné par la méthode jwt.verify
.
Où allons-nous utiliser les nouvelles fonctions middleware authorize
et isAdmin
?
Nous pouvons le faire au niveau que nous souhaitons, soit au niveau :
- de l'application, pour toutes les routes, via
app.use(authorize)
. - d'un router, pour toutes les routes associées ; par exemple, on pourrait dire que toutes les routes du router de pizzas sont protégées par une autorisation JWT. On écrirait :
app.use("/pizzas", authorize, pizzaRouter);
.
Cela signifierait que toutes les opérations sur des ressources de type "pizzas" ne seraient autorisées que si l'utilisateur était authentifié. Cela serait problématique pour deux raisons :- On veut pouvoir afficher le menu des pizzas pour tous les utilisateurs, même s'ils sont anonymes.
- On ne veut pas simplement vérifier qu'un utilisateur est authentifié pour créer une pizza, on veut aussi vérifier qu'il est admin.
- d'une route, pour une opération de notre RESTful API. C'est ce que nous souhaitons faire ici. Veuillez mettre à jour le code du router de "pizzas" au sein de
/routes/pizzas.ts
:
js// Code existant...import { authorize, isAdmin } from "../utils/auths";// Code existant...// Create a pizza to be added to the menu.router.post("/", authorize, isAdmin, (req, res) => {const body: unknown = req.body;if (!body ||typeof body !== "object" ||!("title" in body) ||!("content" in body) ||typeof body.title !== "string" ||typeof body.content !== "string" ||!body.title.trim() ||!body.content.trim()) {return res.sendStatus(400);}const { title, content } = body as NewPizza;const addedPizza = createPizza({ title, content });return res.json(addedPizza);});
Ainsi, nous avons juste fait l'appel de deux fonctions middleware pour vérifier :
- que l'utilisateur est authentifié via
authorize
; si tout est OK au niveau du token fournit par l'application cliente,authorize
fait appel vianext()
à la prochaine fonction middleware. Dans ce cas-ci, c'estisAdmin
. S'il y a un problème,authorize
termine le traitement de la requête en envoyant un code d'erreur au client et les prochaines fonctions middleware (isAdmin
, puis la fonctionarrow
) ne sont pas exécutées. - que l'utilisateur est admin via
isAdmin
; si tout est OK, que l'utilisateur authentifié est l'admin,isAdmin
fait appel vianext()
à la prochaine fonction middleware. Dans ce cas-ci, c'est la fonctionarrow
qui appelle l'opération demandée pour créer la pizza :createPizza
. Si l'utilisateur authentifié n'est pas l'admin, alorsisAdmin
termine le traitement en envoyant un code d'erreur au client et la fonction traitant de l'opération de création n'est pas exécutée.
Comment pouvons-nous tester l'opération de création d'une pizza ? Via REST Client.
Tentons le coup à l'aide de cette requête (elle est déjà présente dans /REST Client/pizzas.http
) :
httpPOST {{baseUrl}}/pizzasContent-Type: application/json{"title":"Magic Green","content":"Epinards, Brocolis, Olives vertes, Basilic"}
Après avoir exécuté cette requête, vous devriez avoir reçu un status code 401 Unauthorized
.
C'est normal, comme nous n'avons pas envoyé de token, nous ne pouvons donc pas être autorisé.
Au prochain point nous allons voir comment utiliser REST Client pour sauvegarder de l'information, comme un token, suite à une requête vers une API.
Client REST avec JWT
Précédemment, nous avons appris à utiliser REST Client, l'extension de VS Code, pour faire des requêtes vers des API.
Voici quelques notions supplémentaire pour utiliser REST Client avec des JWT
:
- Il est possible de créer des
Request Variables
afin de récupérer la réponse associée à une requête au sein d'une variable. - On va donc pouvoir récupérer le token, suite à une requête d'authentification,
au sein d'une
Request Variable
, pour ensuite fournir ce token dans leAuthorization header
de toutes les requêtes demandant une autorisation JWT.
Si vous souhaitez plus d'infos sur les Request Variables
, vous pouvez consulter la documentation de REST Client [R.55].
Voici comment mettre à jour le script /REST Client/pizzas.http
pour créer une pizza en passant le token de l'utilisateur admin
:
http### Create a pizza by using the admin account#### First login as the admin##### Define a request variable named admin :# @name adminPOST {{baseUrl}}/auths/loginContent-Type: application/json{"username":"admin","password":"admin"}#### Create a pizza with the admin tokenPOST {{baseUrl}}/pizzasContent-Type: application/jsonAuthorization: {{admin.response.body.token}}{"title":"Magic Green","content":"Epinards, Brocolis, Olives vertes, Basilic"}
Pour tester l'opération de création de pizza, veuillez d'abord exécuter la première requête, puis la seconde donnée ci-dessus.
A ce stade-ci, il serait aussi intéressant de tester certains cas d'erreurs associés aux tokens. Veuillez compléter le script /REST Client/pizzas.http
avec :
http### 1. Create a pizza without a tokenPOST {{baseUrl}}/pizzasContent-Type: application/json{"title":"Magic Green","content":"Epinards, Brocolis, Olives vertes, Basilic"}### 2. Create a pizza without being the admin, use manager account#### 2.1 First login as the manager##### 2.1.1 Define a request variable nammed manager# @name managerPOST {{baseUrl}}/auths/loginContent-Type: application/json{"username":"manager","password":"manager"}##### 2.1.2 Define a file variable to simplify the access to the token of manager@managerToken = {{manager.response.body.token}}#### 2.2 Try to create a pizza with the manager tokenPOST {{baseUrl}}/pizzasContent-Type: application/jsonAuthorization: {{managerToken}}{"title":"Magic Green","content":"Epinards, Brocolis, Olives vertes, Basilic"}
Nous voyons qu'à l'aide de REST Client, nous pouvons utiliser une File Variable
pour allouer une partie de la réponse faite à une requête.
Dans ce cas, on peut faire appel à l'API en passant le token via la File Variable
nommé managerToken
(code associé : Authorization: {{managerToken}}
) au lieu d'utiliser une partie seulement de la Request Variable
nommée manager
(code possible : Authorization: {{manager.response.body.token}}
).
Cela permet de créer des requêtes plus concises.
Protection des opérations d'écriture d'une API
Veuillez mettre à jour votre RESTful API gérant les pizzas afin que toutes les opérations d'écriture soient protégées par une autorisation JWT, n'autorisant que l'utilisateur admin.
Voici le code à mettre à jour dans /routes/pizzas
pour correctement autoriser les opérations de suppression et de modification sur des ressources de type "pizzas":
js// Delete a pizza from the menu based on its idrouter.delete("/:id", authorize, isAdmin, (req, res) => {const id = Number(req.params.id);const deletedPizza = deletePizza(id);if (!deletedPizza) return res.sendStatus(404);return res.json(deletedPizza);});// Update a pizza based on its id and new values for its parametersrouter.patch("/:id", authorize, isAdmin, (req, res) => {const body: unknown = req.body;if (!body ||typeof body !== "object" ||("title" in body &&(typeof body.title !== "string" || !body.title.trim())) ||("content" in body &&(typeof body.content !== "string" || !body.content.trim()))) {return res.sendStatus(400);}const pizzaToUpdate: PizzaToUpdate = body;const id = Number(req.params.id);const updatedPizza = updatePizza(id, pizzaToUpdate);if (!updatedPizza) return res.sendStatus(404);return res.json(updatedPizza);});
Pour vous assurer que les opérations de suppression et de modification sont bien fonctionnelles, veuillez mettre à jour les requêtes associées afin d'utiliser un token. Veuillez mettre à jour /REST Client/pizzas.http
en ajoutant ces lignes :
http1### Delete pizza identified by 2 with the admin token2DELETE {{baseUrl}}/pizzas/23Authorization: {{admin.response.body.token}}45### Update the pizza identified by 6 with the admin token6PATCH {{baseUrl}}/pizzas/67Content-Type: application/json8Authorization: {{admin.response.body.token}}910{11"title":"Magic Green 2"12}
Veuillez exécutez les requêtes de type DELETE et de type PATCH afin de vous assurer que l'API est en ordre.
En cas de souci, vous pouvez utiliser le code du tutoriel auths.
Si vous le souhaitez, vous pouvez aussi faire les modifications pour les ressources de type "drinks".
Veuillez mettre à jour /routes/drinks
pour ajouter l'appel aux fonctions middleware authorize
et isAdmin
pour les opérations de création, suppression et de modification :
ts1router.post("/", authorize, isAdmin, (req, res) => {2const body: unknown = req.body;3if (4!body ||5typeof body !== "object" ||6!("title" in body) ||7!("image" in body) ||8!("volume" in body) ||9!("price" in body) ||10typeof body.title !== "string" ||11typeof body.image !== "string" ||12typeof body.volume !== "number" ||13typeof body.price !== "number" ||14!body.title.trim() ||15!body.image.trim() ||16body.volume <= 0 ||17body.price <= 018) {19return res.sendStatus(400);20}2122const { title, image, volume, price } = body as NewDrink;2324const newDrink = createOneDrink({ title, image, volume, price });25return res.json(newDrink);26});2728router.delete("/:id", authorize, isAdmin, (req, res) => {29const id = Number(req.params.id);30const deletedDrink = deleteOneDrink(id);31if (!deletedDrink) {32return res.sendStatus(404);33}34return res.json(deletedDrink);35});3637router.patch("/:id", authorize, isAdmin, (req, res) => {38const id = Number(req.params.id);3940const body: unknown = req.body;4142if (43!body ||44typeof body !== "object" ||45("title" in body &&46(typeof body.title !== "string" || !body.title.trim())) ||47("image" in body &&48(typeof body.image !== "string" || !body.image.trim())) ||49("volume" in body &&50(typeof body.volume !== "number" || body.volume <= 0)) ||51("price" in body && (typeof body.price !== "number" || body.price <= 0))52) {53return res.sendStatus(400);54}5556const { title, image, volume, price }: Partial<NewDrink> = body;5758const updatedDrink = updateOneDrink(id, { title, image, volume, price });5960if (!updatedDrink) {61return res.sendStatus(404);62}6364return res.json(updatedDrink);65});
N'oubliez pas de faire l'import de authorize
et isAdmin
au début du fichier /routes/drinks.ts
.
Pour tester les opérations de création, de suppression et de modification sur des ressources de type "drinks", veuillez mettre à jour le script /REST Client/drinks.http
:
http1### Read all drinks with File variable2@baseUrl = http://localhost:30003GET {{baseUrl}}/drinks45### Read a single drink6GET {{baseUrl}}/drinks/378### Read all drinks cheaper or equal to 3 €9GET {{baseUrl}}/drinks/?budget-max=31011### Create a drink12##### Define a request variable named admin :13# @name admin14POST {{baseUrl}}/auths/login15Content-Type: application/json1617{18"username":"admin",19"password":"admin"20}2122### Create a drink with the admin token23POST {{baseUrl}}/drinks24Content-Type: application/json25Authorization: {{admin.response.body.token}}2627{28"title":"Virgin Tonic",29"image":"https://plus.unsplash.com/premium_photo-1668771899398-1cdd763f745e?q=80&w=1374&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",30"volume":0.25,31"price":4.532}333435### Try to create a drink with incomplete data36POST {{baseUrl}}/drinks37Content-Type: application/json38Authorization: {{admin.response.body.token}}394041{42"title":"Missing Data Drink",43"volume":0.25,44"price":4.545}4647### Delete a drink48DELETE {{baseUrl}}/drinks/549Authorization: {{admin.response.body.token}}505152### Update a drink53PATCH {{baseUrl}}/drinks/354Content-Type: application/json55Authorization: {{admin.response.body.token}}5657{58"title":"Citronnade"59}
Sécurisation des passwords
Nous souhaitons maintenant assurer que les passwords enregistrés dans un support de données (un fichier ou une base de données) ne puissent pas être récupérés.
👍 Pour ce faire, il est recommandé d'hacher les passwords avant de les enregistrer au niveau d'une API.
Afin de se protéger contre les "hash attacks", on utilise du salt :
- un salt : c'est une donnée aléatoire qui est utilisé en entrée d'une fonction qui hache des données. Ainsi, si un hacker utilise une base de données de passwords hachés, il devra en plus trouver le bon salt pour que son attaque puisse fonctionner.
- salt round : nombre de fois que l'opération de hachage est faite, rendant les attaques de force brute plus lente et donc difficile ; une valeur de 10 semble être généralement raisonnable et recommandée.
Pour hacher sous Node.js, nous utiliserons la librairie bcrypt.
Veuillez installer la librairie bcrypt
et les types associés à bcrypt au sein de votre API auths
:
bashnpm i bcryptnpm i @types/bcrypt -D
En résumé, nous allons utiliser :
- la fonction asynchrone
hash
debcrypt
pour hacher un password ; - la fonction asynchrone
compare
debcrypt
pour comparer un password en clair à un password haché ; si le résultat est positif, cela signifie que le password fournit pour un utilisateur correspond au password initial.
Nous préférons utiliser les librairies asynchrone afin que l'API reste disponible à gérer des requêtes clientes et ne bloque pas celles-ci jusqu'à la fin d'une opération de bcrypt
!
Veuillez effacer le fichier /data/users.json
contenant les credentials d'utilisateurs où les passwords sont donnés en clair.
Nous allons maintenant mettre à jour le service "users" pour utiliser bcrypt
. Veuillez modifier /services/users.ts
:
ts1import jwt from "jsonwebtoken";2import bcrypt from "bcrypt";3import path from "node:path";4import { parse, serialize } from "../utils/json";5import { AuthenticatedUser, User } from "../types";67const jwtSecret = "ilovemypizza!";8const lifetimeJwt = 24 * 60 * 60 * 1000; // in ms : 24 * 60 * 60 * 1000 = 24h910const saltRounds = 10;1112const jsonDbPath = path.join(__dirname, "/../data/users.json");1314const defaultUsers: User[] = [15{16id: 1,17username: "admin",18password: bcrypt.hashSync("admin", saltRounds),19},20];2122async function login(23username: string,24password: string25): Promise<AuthenticatedUser | undefined> {26const userFound = readOneUserFromUsername(username);27if (!userFound) return undefined;28const passwordMatch = await bcrypt.compare(password, userFound.password);29if (!passwordMatch) return undefined;3031const token = jwt.sign(32{ username }, // session data added to the payload (payload : part 2 of a JWT)33jwtSecret, // secret used for the signature (signature part 3 of a JWT)34{ expiresIn: lifetimeJwt } // lifetime of the JWT (added to the JWT payload)35);3637const authenticatedUser: AuthenticatedUser = {38username,39token,40};4142return authenticatedUser;43}4445async function register(46username: string,47password: string48): Promise<AuthenticatedUser | undefined> {49const userFound = readOneUserFromUsername(username);50if (userFound) return undefined;5152await createOneUser(username, password);5354const token = jwt.sign(55{ username }, // session data added to the payload (payload : part 2 of a JWT)56jwtSecret, // secret used for the signature (signature part 3 of a JWT)57{ expiresIn: lifetimeJwt } // lifetime of the JWT (added to the JWT payload)58);5960const authenticatedUser: AuthenticatedUser = {61username,62token,63};6465return authenticatedUser;66}6768function readOneUserFromUsername(username: string) {69const users = parse(jsonDbPath, defaultUsers);70const userFound = users.find((user) => user.username === username);71if (!userFound) return undefined;7273return userFound;74}7576async function createOneUser(username: string, password: string) {77const users = parse(jsonDbPath, defaultUsers);7879const hashedPassword = await bcrypt.hash(password, saltRounds);8081const nextId =82users.reduce((acc, user) => (user.id > acc ? user.id : acc), 0) + 1;8384const createdUser: User = {85id: nextId,86username,87password: hashedPassword,88};8990users.push(createdUser);9192serialize(jsonDbPath, users);9394return createdUser;95}9697export { login, register, readOneUserFromUsername };
Voici les modifications apportées :
- pour créer un password haché : on utilise la fonction asynchrone
hash
pour créer le password haché. Dès lors,createOneUser
devient asynchrone. De plus, commecreateOneUser
est devenue asynchrone, dansregister
, pour chaîner le traitement de création du token, on met unawait
à l'appel decreateOneUser
. Et comme unawait
est ajouté au sein deregister
, il faut mettre unasync
à la méthoderegister
. Attention, du coup, commeregister
est devenue asynchrone, il faudra aussi bien chaîner les traitements oùregister
est appelé, dans le routerauths
. Notez aussi que nous avons changé le type de retour des fonctions asynchrones qui renvoient maintenant desPromise
. - pour vérifier qu'un password en clair "match" à un password haché : on utilise la fonction asynchrone
compare
que l'on chaîne à la création du token à l'aide deawait
. Dès lors, la fonctionlogin
doit elle aussi être déclaréeasync
. Attention, du coup, commelogin
est devenue asynchrone, il faudra bien chaîner les traitements oùlogin
est appelé, dans le routerauths
. - pour créer l'utilisateur
admin
se trouvant dans lesdefaultUsers
avec des credentials par défaut : on souhaite indiquer commepassword
le password haché correspondant au password"admin"
. Pour se simplifier la vie, on appelle la fonction synchronehashSync
(voir ligne 18). Bien évidemment, dans une application robuste et sécurisée, il est interdit de coder en dur ce genre de secrets dans les sources de notre application 😉 !
Afin de traiter des deux fonctions du service users
qui sont devenues asynchrones, login
et register
, nous allons modifier le router /routes/auths
:
ts1/* eslint-disable @typescript-eslint/no-misused-promises */2/*3In Express V4, asynchronous functions are not fully supported in TypeScript4(only void return type for RequestHandler is allowed).5In Express V5, this issue has been addressed, but V5 is still in beta.6Consequently, the ESLint rule "no-misused-promises" is disabled. */7import { Router } from "express";8import { PotentialUser } from "../types";9import { login, register } from "../services/users";10const router = Router();1112/* Register a user */13router.post("/register", async (req, res) => {14const body: unknown = req.body;15if (16!body ||17typeof body !== "object" ||18!("username" in body) ||19!("password" in body) ||20typeof body.username !== "string" ||21typeof body.password !== "string" ||22!body.username.trim() ||23!body.password.trim()24) {25return res.sendStatus(400);26}2728const { username, password } = body as PotentialUser;2930const authenticatedUser = await register(username, password);3132if (!authenticatedUser) {33return res.sendStatus(409);34}3536return res.json(authenticatedUser);37});3839/* Login a user */40router.post("/login", async (req, res) => {41const body: unknown = req.body;42if (43!body ||44typeof body !== "object" ||45!("username" in body) ||46!("password" in body) ||47typeof body.username !== "string" ||48typeof body.password !== "string" ||49!body.username.trim() ||50!body.password.trim()51) {52return res.sendStatus(400);53}5455const { username, password } = body as PotentialUser;5657const authenticatedUser = await login(username, password);5859if (!authenticatedUser) {60return res.sendStatus(401);61}6263return res.json(authenticatedUser);64});6566export default router;
Nous avons simplement chaîné les réponses à faire au client seulement une fois les opérations register
et login
terminées. Pour ce faire, nous avons précédé le nom de ces méthodes par await
. Nous avons donc du ajouter async
aux fonctions middleware s'occupant des routes POST /auths/register
et POST /auths/login
.
Notons que nous avons ajouté un commentaire pour désactiver la règle @typescript-eslint/no-misused-promises
. En effet, dans Express V4, les fonctions asynchrones ne sont pas pleinement supportées en TypeScript (seul le type de retour void
pour RequestHandler
est autorisé). Dans Express V5, ce problème a été résolu, mais V5 est toujours en version bêta. Par conséquent, la règle ESLint no-misused-promises
est désactivée.
Veuillez vérifier que votre application fonctionne correctement.
Via l'IHM, veuillez faire un register d'un nouvel utilisateur.
Au niveau de l'API, allez voir le contenu du nouveau fichier /data/users.json
. Les passwords devraient maintenant être hachés, comme par exemple :
json[{"id": 1,"username": "admin","password": "$2b$10$RioLKlPFyYFEhv/46gR7dufDkke07eDpWH9tBt/A4Z9tJh0oJnnf2"},{"id": 2,"username": "manager","password": "$2b$10$NZZ1zxOPdby6gl4Dw8K0Q.v4ZRWTbh1Ta7qcYzH5G4SrO5z71H0kO"}]
En cas de souci, vous pouvez utiliser le code du tutoriel auths.
Exercice 3.1 : Authentification & autorisation d'opérations
Vous allez sécuriser la RESTful API de myMovies.
Mise en place du projet
Veuillez créer un nouveau projet ans votre repo git dans /exercises/3.1
en clonant le boilerplate du cours offrant l'authentification et l'autorisation JWT : jwt-ts-api-boilerplate. Dans ce boilerplate, vous trouverez déjà un service users
qui gère les utilisateurs, ainsi que des routes pour les opérations de login
et de register
.
⚡️ Attention, n'oubliez pas les soucis d'avoir un git dans le git... Pensez à supprimer le dossier .git
de votre nouveau projet.
Identification des opérations & création de la documentation de l'API
Nous allons maintenant passer à une phase d'analyse de votre API. Nous allons documenter les opérations de votre API, ainsi que les opérations qui seront protégées par JWT.
Pour les opérations existantes sur des films, vous devez planifier de protéger les opérations d'écriture (création, modification, suppression) de vos films par JWT. Les opérations de lecture de films ne doivent pas être protégées par JWT.
Pour la protection des opérations, vous ne devrez pas gérer de rôles, mais simplement vérifier que l'utilisateur est authentifié.
Vous devez planifier des nouvelles opérations :
- lire tous les commentaires et pouvoir les filtrer par film ;
- ajouter des commentaires sur un film, en y associant l'utilisateur ou l'utilisatrice authentifiée ;
- effacer un commentaire sur un film sur base de l'utilisateur ou de l'utilisatrice authentifiée.
Veuillez identifier toutes les opérations mises à disposition par votre API (y compris l'authentification), ainsi que si celles-ci sont protégées par JWT, au sein du fichier README.md
(fichier Markdown) de votre projet. Voici un exemple de comment nous vous recommandons de documenter votre API, sous forme de tableau :
URI | Méthode HTTP | Auths? | Opération |
---|---|---|---|
films | GET | Non | READ ALL : Lire toutes les ressources de la collection |
films/:id | GET | Non | READ ALL : Lire une ressource de la collection |
films | POST | Oui | CREATE ONE : Créer une ressource de la collection basée sur un body au format {title: string; director: string; duration: number; budget?: number; description?: string; imageUrl?: string; } |
... | ... | ... | ... |
comments | GET | JWT | READ ALL FILTERED : Lire toutes les ressources de la collection |
comments | POST | JWT | CREATE ONE : Créer une ressource basée sur un body au format {...} |
... | ... | ... | ... |
Comment créer un tableau dans un fichier Markdown (pour README.md
) ?
Voici deux options :
- Soit vous utiliser des
|
pour délimiter les cellules et des|---|
pour séparer les headers du corps du tableau. Voici le Markdown de l'exemple donné ci-dessus :
text| URI | Méthode HTTP | Auths? | Opération ||---|---|---|---|| **`films`** | GET | Non | READ ALL : Lire toutes les ressources de la collection || **`films/:id`** | GET | Non | READ ALL : Lire une ressource de la collection || **`films`** | POST | Oui | CREATE ONE : Créer une ressource de la collection basée sur un body au format `{title: string; director: string; duration: number; budget?: number; description?: string; imageUrl?: string; }` || ... | ... | ... | ... || **`comments`** | GET | JWT | READ ALL FILTERED : Lire toutes les ressources de la collection || **`comments`** | POST | JWT | CREATE ONE : Créer une ressource basée sur un body au format `{...}` || ... | ... | ... | ... |
- Soit c'est simplement un tableau HTML (
<table>
).
Une fois la documentation de votre API prête, veuillez faire un commit avec le message suivant : new:ex3.1-doc
.
Sécuriser les opérations d'écriture pour les films
Vous allez maintenant travailler sur le code de votre nouveau projet.
Dans votre nouveau projet, dans un premier temps, sans utiliser JWT, veuillez intégrer le code utile de vos opérations (router & service de films
, requêtes HTTP pour tester les opérations sur les films
) de votre /exercises/1.8
.
Veuillez protéger les opérations d'écriture (création, modification, suppression) de vos films par JWT (en intégrant l'authentification) et vérifier que les opérations protégées fonctionnent correctement via REST Client.
Vous devez assurer qu'un utilisateur non authentifié ne puisse pas créer, modifier ou supprimer un film.
Une fois tout fonctionnel, veuillez faire un commit avec le message suivant : new:ex3.1
.
Implémenter les opérations associées aux commentaires
Vous pouvez implémenter les nouvelles opérations de gestion de commentaires et les protéger par JWT.
Veuillez vérifier que vos opérations sont bien protégées et fonctionnelles en les testant avec Rest Client.
Veuillez bien nettoyer votre code et ne pas laisser de code mort.
Quand votre api est finalisée et testée, veuillez faire un commit
de votre code avec comme message : new: ex3.1-comments
.
Sécurisation de vos opérations
💭 Avez-vous bien protégé vos opérations ?
Est-ce que pour l'opération de création d'un commentaire, vous avez bien associé l'utilisateur ou l'utilisatrice authentifiée à ce commentaire sur base du token fourni par l'application cliente ? En effet, si vous utilisez le body pour valider le username sans checker le token, il est facilement possible de modifier le body et de créer un commentaire au nom d'un autre utilisateur...
De même pour l'opération de suppression d'un commentaire, avez-vous avez bien vérifié l'utilisateur ou l'utilisatrice authentifiée via le token ? Ou est-ce que vous avez simplement vérifié le username à partir de l'URL ?
💭 Concernant la gestion du contrat associé à vos opérations, les status codes, avez-vous pensé à :
- vérifier lors de la création d'un commentaire que le film existe bien ? que l'utilisateur ou l'utilisatrice n'a pas déjà commenté ce film ?
- vérifier lors de la suppression d'un commentaire que le commentaire existe bien pour l'utilisateur ou l'utilisatrice authentifiée et pour le film indiqué e ?
Si nécessaire, veuillez mettre à jour votre API, sa documentation et vos tests pour corriger les éventuelles soucis.
Quand votre api est finalisée et testée, veuillez faire un commit
de votre code avec comme message : new: ex3.1-sécurité
.
🍬 Exercice 3.2 : une autre API sécurisée
Cet exercice est optionnel, n'hésitez pas à le faire si vous avez du temps.
Peut-être avez-vous déjà entendu parler de cette mode montante dans le Web qu'est la JAMstack ?
Nous souhaitons développer un Headless CMS, c'est-à-dire un outil permettant de créer du contenu, très souvent utilisé dans le cadre de sites JAMstack.
Un Headless CMS fournira une interface web pour créer du contenu (ou des données), ainsi qu'une RESTful API mettant à disposition ce contenu.
L'Headless CMS que nous souhaitons développer doit permettre de créer le contenu associé à un blog.
Un blog sera structuré en pages. Une page aura un id, un titre, un URI, un contenu, un auteur ainsi qu'un statut de publication.
Le statut de publication d'une page peut actuellement prendre comme valeur :
- « hidden »
- « published »
Seul un utilisateur authentifié pourra lire, créer, modifier ou supprimer des pages.
La modification d'une page, tout comme la suppression d'une page ne pourra se faire que par son auteur.
L'ajout ou la modification d'une page ne sera possible que si le statut de publication donné correspond à un des deux statuts actuels (« hidden ou « published »). Sinon un message d'erreur devra être renvoyé.
La lecture d'une page, ou de toutes les pages, pourra être réalisée par n'importe quel utilisateur authentifié pour les pages dont le statut vaut « published ».
Pour les pages dont le statut vaut « hidden », seul leur auteur authentifié pourra les lire.
Veuillez créer un nouveau projet dans votre repo git dans /exercises/3.2
sur base du boilerplate : jwt-ts-api-boilerplate.
⚡ Si vous avez fait un clone du boilerplate, attention au Git dans le Git, n'oubliez pas de supprimer le dossier .git
présent dans votre nouveau projet.
Puis, veuillez formaliser les opérations associées à vos RESTful APIs sous forme d'un tableau dans un fichier README.md
.
Veuillez tester toutes les méthodes offertes par votre application à l'aide du client HTTP de REST Client.
Veuillez faire un commit
de votre code avec le message suivant : new: ex3.2
.
🍬 Bonus
- N°1 : vous souhaitez rapidement découvrir les principes d'une architecture Web associée à la JAMstack ? https://jamstack.wtf/
- N°2 : si vous souhaitez découvrir le format Markdown, notamment très souvent utilisé dans certains sites JAMstack (source des données pour certains Headless CMS) : https://www.markdownguide.org/basic-syntax/
🍬 Challenges
- N°1 : Modifiez les autorisations au niveau des opérations de lecture : la lecture d'une page, ou de toutes les pages, pourra être réalisée par n'importe quel utilisateur anonyme ou authentifié pour les pages dont le statut vaut « published ». Pour les pages dont le statut vaut « hidden », seul leur auteur authentifié pourra les lire.
- N°2 : lorsqu'un utilisateur tente d'effacer ou modifier une page dont il n'est pas l'auteur, renvoyez un code d'erreur approprié au client.
- N°3 : Si ça n'est pas déjà fait, pensez à ne pas renvoyer les passwords des utilisateurs dans les réponses de votre API.
🍬 Authentification & autorisation JWT à l'aide de cookies
Pourquoi utiliser l'autorisation JWT à l'aide de cookies ?
Nous avons vu une façon de gérer des token JWT, sans gérer de cookies.
Cela impose aux client de sauvegarder les tokens et de les envoyer dans un authorization header
lorsqu'ils souhaitent accéder à une opération protégée.
Il est aussi possible d'intégrer les tokens au sein de cookies. Dans ce cas-là, les clients qui ont reçu un cookie de l'API renverront automatiquement ce cookie (mécanisme des browsers). Les tokens JWT voyageront automatiquement grâce au mécanisme de cookies.
💭 Faut-il mieux intégrer les token JWT dans des cookies ou pas ?
Il est difficile de donner une réponse à cette question. Chaque approche a des avantages et des inconvénients. Ce qui devrait être le point d'attention, c'est la sécurité. Et dans les deux cas, on atteint un niveau de sécurité raisonnable.
Pour ce cours, nous avons choisi la façon la plus moderne, en laissant au client le choix de sauvegarder les token dans le web storage (nous allons voir ça tout prochainement pour l'aspect frontend).
Notons que le cas le plus sûr est probablement d'avoir deux types de token, ce qui est d'une complexité qui dépasse les objectifs de ce cours. Néanmoins, pour votre info, les mécanismes d'authentification comme OAuth (authorization de MS Azure) & OpenID Connect (authentification de MS Azure) gère deux types de token :
- un token à durée de vie courte qui sera enregistré en mémoire vive au niveau du client (access token) ou dans le web storage.
- un token à durée de vie longue qui sera enregistré par le client dans un cookie (refresh token, notamment utilisé par l'API pour créer un nouvel access token).
Un cookie, c'est quoi ?
Un cookie représente des données qu'un serveur envoie à un browser.
Le browser peut sauver ce cookie. Pour chaque requête faite au serveur sur la même origine (que l'origine où le cookie a été reçu), le cookie sera automatiquement envoyé au serveur.
Il fut un temps où les cookies étaient utilisés comme un mécanisme général de stockage de données côté client.
👍 Actuellement, si les cookies sont utilisés pour sauvegarder les données de session, il faut se protéger contre les attaques XSS et rendre les cookies inaccessibles au JavaScript : on utilise donc les cookies HttpOnly
qui sont inaccessibles à la Document.cookie API
.
Pour la suite, on va donc voir :
- comment utiliser Express pour créer des cookies au niveau d'une API ;
- comment rendre ces cookies inaccessibles aux attaques XSS en configurant
HttpOnly
.
Implémentation de l'authentification & l'autorisation JWT au sein de cookies
Intro
Nous allons donc créer une nouvelle version de l'API sauvegardant le token d'un utilisateur au sein d'un cookie, ainsi que son username, sans que ces infos soient accessible au JS côté client.
Pour ce faire nous allons utiliser la librairie cookie-session qui permet d'enregistrer des données de session dans des cookies.
Veuillez créer un nouveau projet nommé /back/api/cookies
sur base d'un copié/collé du projet auths).
Veuillez installer la librairie cookie-session
au sein de votre nouveau projet cookies
et les types associés (pour TypeScript):
bashnpm i cookie-sessionnpm install @types/cookie-session -D
Utilisation de la fonction middleware cookieSession
Veuillez mettre à jour /app.ts
pour mettre en place la gestion de cookies :
ts1import express, { ErrorRequestHandler } from "express";2import cookieSession from "cookie-session";34import usersRouter from "./routes/users";5import pizzaRouter from "./routes/pizzas";6import drinkRouter from "./routes/drinks";7import authsRouter from "./routes/auths";89const app = express();1011const expiryDateIn3Months = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30 * 3);12const cookieSecreteKey = 'YouWouldnot!not!like!mypizza';13app.use(14cookieSession({15name: 'user',16keys: [cookieSecreteKey],17httpOnly: true,18expires: expiryDateIn3Months,19}),20);2122app.use((_req, _res, next) => {23console.log(24"Time:",25new Date().toLocaleString("fr-FR", { timeZone: "Europe/Brussels" })26);27next();28});2930app.use(express.json());31app.use(express.urlencoded({ extended: false }));3233app.use("/users", usersRouter);34app.use("/pizzas", pizzaRouter);35app.use("/drinks", drinkRouter);36app.use("/auths", authsRouter);3738const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => {39console.error(err.stack);40return res.status(500).send("Something broke!");41};4243app.use(errorHandler);44export default app;
Nous avons donc bien indiqué que le cookie est inaccessible au JS via : httpOnly: true
.
Nous avons fait en sorte que le cookie soit signé via la clé cookieSecreteKey
.
Le mécanisme de signature correspond à ce qui a été vu dans le cadre des tokens JWT.
Ainsi, si un cookie venait être modifié par un utilisateur, lors de la vérification du cookie, cela sera automatiquement détecté par la fonction middleware cookieSession
et la session ne sera pas créée.
Pour info, la fonction middleware cookieSession
va créer deux cookies :
- un cookie portant comme nom la valeur de
name
; il est encodé en base64. N'hésitez pas à vous amuser à décoder un cookie généré parcookieSession
sur base64decode. - un cookie portant comme nom la valeur de
name
+.sig
: c'est la signature qui prévient contre le "tempering" (acte intentionnel mais non autorisé qui amène à la modification d'un système ou de données).
Lecture et ajout de données de session via req.session
Pour créer des données de session, il suffit de simplement les ajouter à l'objet req.session
.
Dans le cadre de la RESTful API gérant les pizzas, cela est fait lors d'une opération de type register
ou login
.
Veuillez mettre à jour le router /routes/auths.ts
:
ts1/* eslint-disable @typescript-eslint/no-misused-promises */2/*3In Express V4, asynchronous functions are not fully supported in TypeScript4(only void return type for RequestHandler is allowed).5In Express V5, this issue has been addressed, but V5 is still in beta.6Consequently, the ESLint rule "no-misused-promises" is disabled. */7import { Request, Router } from "express";8import { AuthenticatedUser, PotentialUser } from "../types";9import { login, register } from "../services/users";10const router = Router();1112/* Register a user */13router.post("/register", async (req, res) => {14const body: unknown = req.body;15if (16!body ||17typeof body !== "object" ||18!("username" in body) ||19!("password" in body) ||20typeof body.username !== "string" ||21typeof body.password !== "string" ||22!body.username.trim() ||23!body.password.trim()24) {25return res.sendStatus(400);26}2728const { username, password } = body as PotentialUser;2930const authenticatedUser = await register(username, password);3132if (!authenticatedUser) {33return res.sendStatus(409);34}3536createCookieSessionData(req, authenticatedUser);3738return res.json({ username: authenticatedUser.username });39});4041/* Login a user */42router.post("/login", async (req, res) => {43const body: unknown = req.body;44if (45!body ||46typeof body !== "object" ||47!("username" in body) ||48!("password" in body) ||49typeof body.username !== "string" ||50typeof body.password !== "string" ||51!body.username.trim() ||52!body.password.trim()53) {54return res.sendStatus(400);55}5657const { username, password } = body as PotentialUser;5859const authenticatedUser = await login(username, password);6061if (!authenticatedUser) {62return res.sendStatus(401);63}6465createCookieSessionData(req, authenticatedUser);6667return res.json({ username: authenticatedUser.username });68});6970/* Logout a user */71router.get("/logout", (req, res) => {72req.session = null;73return res.sendStatus(200);74});7576function createCookieSessionData(77req: Request,78authenticatedUser: AuthenticatedUser79) {80if (!req.session) {81return;82}83req.session.username = authenticatedUser.username;84req.session.token = authenticatedUser.token;85}8687export default router;
Dans le code ci-dessus, nous préparons les données de session qui seront écrites dans le cookie à l'aide de l'objet req.session
.
Lorsque nous renvoyons du JSON aux clients, nous ne renvoyons plus le token, mais juste le username de l'utilisateur. L'application cliente, le browser, pourra utiliser cette info pour afficher le nom de l'utilisateur. Pour rappel, le browser n'a pas accès, via le JS, à l'info se trouvant dans le cookie.
Quand nous gérons une session via des cookies, il n'est pas évident de bien clore une session. Nous avons créé une nouvelle opération de type GET /auths/logout
qui permet d'effacer les données de session d'un utilisateur.
Il nous reste à changer le mécanisme d'autorisation.
Les tokens ne seront plus reçus via un authorization header, mais via un cookie.
Nous allons donc mettre à jour le middleware /utils/authorize
:
ts1import jwt from "jsonwebtoken";2import { readOneUserFromUsername } from "../services/users";3import { NextFunction, RequestHandler, Response } from "express";4import { AuthenticatedRequest, JwtPayload, User } from "../types";56const jwtSecret = "ilovemypizza!";78const authorize = (9req: AuthenticatedRequest,10res: Response,11next: NextFunction12) => {13if (!req.session) {14return res.sendStatus(401);15}16const { token } = req.session;17if (!token) {18return res.sendStatus(401);19}2021try {22const decoded = jwt.verify(token, jwtSecret) as JwtPayload;23const { username } = decoded;2425const existingUser = readOneUserFromUsername(username);2627if (!existingUser) {28return res.sendStatus(401);29}3031req.user = existingUser; // request.user object is available in all other middleware functions32return next();33} catch (err) {34console.error("authorize: ", err);35return res.sendStatus(401);36}37};3839const isAdmin: RequestHandler = (req: AuthenticatedRequest, res, next) => {40const { username } = req.user as User;4142if (username !== "admin") {43return res.sendStatus(403);44}45return next();46};4748export { authorize, isAdmin };
🍬 Test via REST Client d'une RESTful API attendant des cookies
Il nous reste à tester nos requêtes via REST Client.
Il n'y a pas de nouvelles notions à apprendre pour utiliser REST Client avec des cookies : le comportement par défaut de REST Client, lorsqu'un cookie est renvoyé dans une réponse, est d'inclure ce cookie dans chaque requête vers la même origine.
Dès lors, pour tester l'API, il suffit d'enlever tous les authorization headers et de rajouter une requête pour tester l'effacement d'une session.
Veuillez tester les requêtes à l'aide de /REST Client/pizzas.http
:
http######### NORMAL OPERATION ############## Read all pizzasGET http://localhost:3000/pizzas### Read all pizzas with File variable@baseUrl = http://localhost:3000GET {{baseUrl}}/pizzas### Read all pizzas sorted by title (ascending)GET {{baseUrl}}/pizzas/?order=+title### Read all pizzas sorted by title (descending)GET {{baseUrl}}/pizzas/?order=-title### Read pizza identified by 2GET {{baseUrl}}/pizzas/2### Create a pizza by using the admin account#### First login as the adminPOST {{baseUrl}}/auths/loginContent-Type: application/json{"username":"admin","password":"admin"}#### Create a pizza with the admin tokenPOST {{baseUrl}}/pizzasContent-Type: application/json{"title":"Magic Green","content":"Epinards, Brocolis, Olives vertes, Basilic"}### Delete pizza identified by 2 with the admin tokenDELETE {{baseUrl}}/pizzas/2### Update the pizza identified by 6 with the admin tokenPATCH {{baseUrl}}/pizzas/6Content-Type: application/json{"title":"Magic Green 2"}######### ERROR OPERATION ############## 1. Create a pizza without a tokenPOST {{baseUrl}}/pizzasContent-Type: application/json{"title":"Magic Green","content":"Epinards, Brocolis, Olives vertes, Basilic"}### 2. Create a pizza without being the admin, use manager account#### 2.1 First login as the managerPOST {{baseUrl}}/auths/loginContent-Type: application/json{"username":"manager","password":"manager"}#### 2.2 Try to create a pizza with the manager tokenPOST {{baseUrl}}/pizzasContent-Type: application/json{"title":"Magic Green","content":"Epinards, Brocolis, Olives vertes, Basilic"}### Read pizza which does not existsGET {{baseUrl}}/pizzas/100### Create a pizza which lacks a propertyPOST {{baseUrl}}/pizzasContent-Type: application/json{"content":"Epinards, Brocolis, Olives vertes, Basilic"}### Create a pizza without info for a propertyPOST {{baseUrl}}/pizzasContent-Type: application/json{"title":"","content":"Epinards, Brocolis, Olives vertes, Basilic"}### Update for a pizza which does not existPATCH {{baseUrl}}/pizzas/200Content-Type: application/json{"title":"Magic Green 2"}### Update for a pizza which does not provide any info for a propertyPATCH {{baseUrl}}/pizzas/1Content-Type: application/json{"title":"Magic Green 2","content":""}
Veuillez mettre à jour /REST Client/auths.http
pour tester GET /auths/logout
:
http### Logout any userGET {{baseUrl}}/auths/logout
Pour ajouter une pizza, il suffit juste :
- De loguer l'admin.
- De créer une nouvelle pizza ; le cookie est automatiquement envoyé.
Admettons que vous souhaitez tester l'ajout d'une pizza sans envoyer de token :
- Lancez l'opération de logout (
GET /auths/logout
) ; le cookie renvoyé ne contient pas de données de session ; - Tentez la création d'une pizza qui renverra un code
401 Unauthorized
.
En cas de souci, vous pouvez utiliser le code du tutoriel cookies.