Aller au contenu principal

NodeJS et JWT

Introduction

Dans le laboratoire précédent, nous avons vu comment sécuriser les routes avec une identification de type Basic. Aujourd'hui, nous allons voir le JWT. Il s’agit d’une autre façon de s’identifier. En effet, le token envoyé contient toutes les informations nécessaires et grâce à des techniques cryptographiques, il est impossible de falsifier les données. Nous en profiterons aussi pour parler des fonctions de hash qui permettent d’éviter de stocker les mots de passe en clair dans la base de données.

Les fonctions de hash

Les fonctions de hash sont des fonctions issues de la cryptographie. Une fonction de hash prend en entrée une chaîne de caractères et renvoie une autre chaîne de caractères. Voici un exemple :

Image montrant des phrases en bleu sur un fond noir, suivies de fonctions de hachage cryptographiques indiquées dans des boîtes jaunes et roses.

(source: https://fr.wikipedia.org/wiki/Fonction_de_hachage_cryptographique)

Les fonctions de hash possèdent l’avantage d’être très sensibles aux modifications. Par exemple : entre l’input numéro 2 et 3, il n’y a qu’une seule lettre de différence et pourtant, la chaîne de caractères en sortie (digest) est complètement différente ! Dans une base de données, on sauvegardera donc le digest et non le mot de passe en clair, car "il n’est pas possible" de retrouver la chaîne de caractères qui est à l’origine du digest. Cependant, il existe des fonctions de hash qui ont été brisées. En effet, avec l’augmentation de la puissance de calcul, il est désormais possible de trouver une chaîne de caractères qui donne le digest désiré (exemples : MD5, SHA1,…). Il est donc toujours important d’utiliser des fonctions de hash qui sont encore sécurisées (Bcrypt, SHA512, par exemple).

Pré-requis

Ce laboratoire s'appuie sur le laboratoire précédent. Reprenez le code et modifiez-le directement (ou faites une copie si vous désirez garder une copie de chaque laboratoire.)

Initialisation

Changez le contenu du fichier initDB.sql par ceci :

./scripts/SQL/initDB.sql
DROP TABLE IF EXISTS client CASCADE;

CREATE TABLE client(
id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
name varchar,
firstname varchar,
address varchar,
email varchar UNIQUE,
password varchar
);

INSERT INTO client (name, firstname, address, email, password)
VALUES ('Poirier', 'Tevin', '11, rue du Faubourg National 95150 TAVERNY', 'poirier@mail.com', '$argon2id$v=19$m=65536,t=3,p=4$/Q561kAj/EFK1piBB3xVSQ$oXEfBolWlydcu0Hj2qtJ/ytNnyfs24aYkwTuGqAAgF4'); --motdepasse

DROP TABLE IF EXISTS manager CASCADE;

CREATE TABLE manager(
id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
name varchar,
email varchar UNIQUE,
password varchar
);

INSERT INTO manager (name, email, password)
VALUES ('John', 'john@mail.com', '$argon2id$v=19$m=65536,t=3,p=4$Zlt4ajEyoZ2T9JZeo6fqfQ$N3u7B55JfJti5sEKl0mYbKRRgid2bxzk1XYGbNVJjRk'); --password

DROP TABLE IF EXISTS product CASCADE ;

CREATE TABLE product (
id int primary key generated always as identity,
name text,
price decimal
);

INSERT INTO product(name, price)
VALUES ('Playstation 5', 499.99),
('NVIDIA RTX 4090 FE', 1829),
('Xbox Series X', 499.99);


DROP TABLE IF EXISTS purchase CASCADE;

CREATE TABLE purchase (
product_id integer REFERENCES product(id) DEFERRABLE INITIALLY IMMEDIATE,
client_id integer REFERENCES client(id) DEFERRABLE INITIALLY IMMEDIATE,
quantity integer,
"date" date default NOW(),
PRIMARY KEY(product_id, client_id, "date")
);

Ensuite, lancez le conteneur qui contient la base de données et initialisez la nouvelle base de données avec npm run initDB.

Dans le laboratoire précédent, nous stockions les mots de passe en clair par souci de simplicité. Mais vous vous en doutez, dans la réalité, il s’agit d’une très mauvaise pratique. Le but de cet exercice sera donc de régler ce problème ! Pour cela, nous allons utiliser une fonction de hash appelée Argon2id qui est la fonction conseillée pour créer les "hashs" des mots de passe. Ouvrez un terminal au niveau de votre projet et effectuez la commande suivante :

npm i argon2

Sel ou poivre ?

Vous aurez surement entendu qu'il faut saler les mots de passe. Mais de quoi s'agit-il ?

Un homme portant des lunettes de soleil et un t-shirt blanc saupoudre quelque chose avec une main.

Le "salage" (salting en anglais) d'un mot de passe consiste à rajouter une chaîne de caractères au mot de passe avant de passer dans la fonction de hash. Cette chaîne est unique à chaque utilisateur et empêche une attaque: les "rainbows tables". Derrière ce nom adorable, se cache un principe assez simple. Ces tables consistent à stocker un hash avec la chaîne de caractères donnant ce hash. Avec ces tables, il devient possible de "cracker" les mots de passes rapidement.

Le salage protègent ces tables étant donné qu'une chaîne de caractères unique est rajoutée à chaque mot de passe. Imaginons que le scénario suivant:

  • Soit P1 pour le mot de passe
  • Soit S1 (le salage) associé à notre utilisateur
  • Soit H1 le hash produit par hash(P1+S1)

Si notre attaquant trouve H1 dans sa table, il utilisera la chaine de caractères associée: C1 Quand il enverra C1, nous ferons hash(C1+S1). Ce qui nous donnera un autre hash: H2 !

Alors, faut-il encore saler les mots de passe ? Oui ! Mais... les fonctions de hash modernes le font automatiquement !

Pour le poivre (peppering) ?

La technique est légèrement différente. Elle consiste à utiliser une chaîne de caractères dans le processus de hash (on ne concatène pas du texte au mot de passe). En combinant avec le salage, il devient impossible pour un attaquant de trouver le mot de passe sans cette chaîne de caractères (ou alors, il faut utiliser une technique brute-force). Il est donc conseillé d'utiliser cette technique.

Exercice 1 : mot de passe et hash

Maintenant, nous pouvons utiliser Argon2id dans notre projet. Ce sera grâce à l’adresse email que vous pourrez récupérer un utilisateur et comparer si les hash sont les mêmes selon la fonction de hash (Argon2id vient avec une fonction verify qui permet de faire la vérification pour vous). Vous devrez donc faire les étapes suivantes :

  1. Modifiez readClient pour n'utiliser que l'adresse mail pour la recherche
./model/client.js
export const readClientByEmail = async (SQLClient, {email}) => {
//TODO
};
  1. Modifiez readManager pour n'utiliser que l'adresse mail pour la recherche
./model/manager.js
export const readManager = async (clientSQL, {email}) => {
//TODO
};
  1. Modifier la méthode readPerson, car elle utilise les fonctions du point 1 et 2.
  2. Toujours dans readPerson, utilisez la fonction de hash pour vérifier si le mot de passe envoyé correspond au hash.
remarque

La solution du laboratoire utilise l'encapsulation pour éviter que les modèles utilisent directement les fonctions de hash

Voici le lien concernant la documentation pour Argon2id : https://www.npmjs.com/package/argon2

Regardez les méthodes hash() et verify() pour comprendre comment les utiliser dans ce laboratoire (en particulier sur les exemples).

remarque

Vous devrez fouiller un peu la documentation pour le peppering

important

Dans votre code, vous utiliserez cecinestpasunpepper comme pepper. En effet, le hash de l'utilisateur fourni a été généré en utilisant ce dernier.

JWT

JWT signifie Json Web Tokens. Ce sont des structures de données représentées en JSON, contenant de l’information (relative à l’utilisateur à l’origine de la requête HTTP). Elles sont encodées dans un format spécifique et sont signées.

Il est composé de 3 parties encodées en base 64 :

  • Les métadonnées

  • La charge utile (le payload)

  • La signature

  • Prenons par exemple le JWT suivant : eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

En jaune, nous avons les métadonnées, en vert le payload et en bleu la signature. Chaque morceau est délimité par .. Les métadonnées sont des informations sur le JWT (fonction de hash à utiliser, le type, etc), la partie verte contient les informations relatives à la session et au JWT (date d’expiration, par exemple) et la partie en bleu contient la signature qui permet de s’assurer que les données n’ont pas été falsifiées.

Le corps du token contient les informations utiles à l’identification de l’utilisateur (ex : son adresse e-mail, son ID dans la table des utilisateurs de la BD servant à l’identification…) Les informations qu’on y retrouve sont des claims. Il en existe un ensemble défini, mais cet ensemble est extensible (vous pouvez ajouter vos propres claims à un token JWT). Pour la liste des claims déjà définie voir la section 4.1 de Internet Engineering Task Force (IETF)

Pourquoi parle-t-on de claims ? L’utilisateur qui envoie la requête HTTP prétend (de l’anglais to claim) être l’utilisateur X et avoir tel et tel droit. Rappelez-vous (voir support théorique + OWASP TOP 10): votre API ne doit pas croire aveuglément à ce que prétend un utilisateur : il doit le vérifier. Et c’est là que se trouve l’intérêt de la troisième partie du token : la signature.

La signature est utilisée pour vérifier que le token a bien été généré par un émetteur de confiance et qu’il n’a pas été altéré. Lorsque le token est généré par un fournisseur d’identité de confiance, la signature lui est ajoutée. Le tiers qui cherche à identifier l’utilisateur va vérifier si la signature est correcte avant de faire confiance aux informations contenues dans le token. La manière dont cette vérification de signature va se dérouler dépend de la manière dont le fournisseur de token a été configuré. C’est la raison pour l’inclusion du nom de l’algorithme de chiffrement au header du token JWT : le tiers cherchant à vérifier la signature sait sur quelle logique se baser.

Prenez le temps de lire la brève introduction sur JWT.io et poursuivez ensuite cet énoncé.

danger

Remarque importante : JWT est portable. Il ne s’agit pas d’une spécificité NodeJS. Mais c’est dans votre implémentation d’une API REST en NodeJS que nous l’utiliserons. De plus le token est encodé en base 64, donc RIEN n’est confidentiel.

Exercice 2 : le JWT

Dans ce second exercice, nous allons créer notre fonction de login qui renvoie un JWT qui expirera au bout de 24 heures. Ensuite, nous créerons un nouveau middleware qui utilisera le token pour savoir si l’utilisateur a le niveau d’autorisation nécessaire pour effectuer l’action demandée.

Dans un premier temps, revenons à notre fonction login. Nous allons utiliser un module qui va nous aider à gérer les tokens : jsonwebtoken. Pour l’installer, utilisez la commande suivante :

npm i jsonwebtoken

Normalement, avec la fonction readPerson précédemment faite, nous pouvons facilement créer un JWT.

./controler/client.js
export const login = async (req, res) => {
try {
// récupérer la personne
if(/*check si on a un résultat*/) {
// créer le JWT
// envoyer le jwt
} else {
// code d'erreur approprié
}
} catch (err) {
console.error(err);
res.sendStatus(500);
}
};

Quand ceci est fait, testez si la fonction renvoie bien le JWT correctement (pensez à changer le routage également). Prenez le JWT et analysez-le (via https://jwt.io/#debugger-io) pour voir s’il contient bien les valeurs attendues.

Maintenant, notre but est d’utiliser correctement le token pour que notre API puisse le lire. Dans Bruno, lorsque vous désirez envoyer un token via le header Authorization, vous devez sélectionner le mode d’identification Bearer Token et ensuite mettre la valeur dans la partie Token:

Capture d'écran d'une interface de programmation montrant un jeton d'authentification Bearer.

Exercice 3: nouveau middleware d'identification

Maintenant, il ne reste plus qu’à créer un middleware qui se basera sur le JWT pour s’occuper de l’identification. Pour cela, dans le dossier ./middleware/identification, créez le fichier jwt.js:

./middleware/identification/jwt.js
export const checkJWT = async (req, res, next) => {
//TODO
};

Avant de continuer, quelques petites remarques :

  • Inspirez-vous de ce que vous avez fait dans le laboratoire précédent. En effet, vous devrez obtenir les mêmes modifications à l’objet request à la fin de l’exercice.
  • Donc, vous ne pouvez pas modifier le middleware qui vérifie les autorisations.
  • Vous n’utilisez plus une identification de type "Basic" mais de type "Bearer" (Bruno)
  • N’oubliez pas de tester votre code pour voir si tout fonctionne

Conclusion

Dans ce laboratoire, nous avons vu comment gérer les tokens JWT. Vous devriez donc être capables de les utiliser dans votre projet. Vous êtes capables de comprendre comment se passe la création d’un token et comment l’utiliser. Vous aurez remarqué que nous avons indiqué que notre token a une durée de vie de 24 heures. Dans votre projet, vous serez surement amenés à mettre une date d’expiration et vous devrez être capables d’expliquer pourquoi vous avez choisi ce délai. Imaginez aussi les implications si vous n’aviez pas mis un délai d’expiration (le mieux est de tester). Imaginez aussi ce qui se passerait si le délai était extrêmement court (comme 10 secondes, par exemple).

De plus, vous devriez être capables de stocker des mots de passe de façon sécurisée et vous devriez être capables d’utiliser la fonction de hash Argon2id.