Aller au contenu principal

NodeJS et Identification Basic

Introduction

Dans ce laboratoire, nous verrons comme sécuriser nos routes. En effet, actuellement, n’importe qui peut faire n’importe quoi avec notre projet. Pour cela, nous utiliserons les middlewares et nous apprendrons à récupérer les informations de connexion depuis le bon header. Dans ce laboratoire, nous nous attarderons sur l’identification basic. Dans les prochains laboratoires, nous verrons le JWT.

Nous allons avoir deux types d'utilisateurs:

  • les clients: ce sont des utilisateurs classiques qui peuvent consulter la liste des produits et faire des achats
  • les manageurs: ce sont des administrateurs qui peuvent tout faire (par exemple: ajouter/supprimer des produits)

Quand nous allons recevoir une requête, nous allons devoir:

  1. Vérifier son identité (si nécessaire)
  2. Vérifier les accès (si nécessaire)

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

Changer 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', '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', '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")
);

N’oubliez pas de lancer le container qui contient la base de données avec :

docker run --name postgres -e POSTGRES_PASSWORD=password -e POSTGRES_USER=john -e POSTGRES_DB=exercices -p 5432:5432 --rm -d postgres

et de créer la base de données avec:

npm run initDB

Middleware

Un middleware est une fonction qui se place entre 2 composants (dans express, ce sont des fonctions). Vous pouvez en placer autant que vous le souhaitez pour créer des couches. Souvent utilisés pour l’identification, ils peuvent avoir d’autres utilités (comme analyser un texte pour le transformer en JSON).

attention

Nous allons énormément utiliser les middlewares dans ce laboratoire et les suivants. Il est donc important que vous preniez le temps de bien comprendre cette théorie !

Dans ExpressJS, les fonctions middlewares prennent 3 paramètres :

  • La requête
  • La réponse
  • Une fonction next (c’est grâce à cette dernière qu’il est possible de passer à la fonction suivante)

Un exemple sera utile pour comprendre le fonctionnement. Celui-ci est repris de la documentation d’express :

app.use('/user/:id', function(req, res, next) {
console.log('Request URL:', req.originalUrl);
next();
}, function (req, res, next) {
console.log('Request Type:', req.method);
next();
});

Ici, lorsque que nous arriverons à la route /user/:id, nous emprunterons le premier middleware qui va simplement afficher l’URL complète de la requête HTTP. Grâce au fait que nous appelons next() dans la première fonction, nous pouvons passer à la seconde fonction. Celle-ci va afficher le type de la requête HTTP.

Il est aussi possible d’ajouter de l’information dans les paramètres des fonctions qui suivent. En effet, imaginons que la première fonction ajoute une entrée à req, par exemple : req.client = {id : 1, nom : 'John' } ;. La deuxième fonction pourra récupérer l’ID du client en faisant un req.client.id ;

Grâce à cette approche, nous allons pouvoir utiliser une architecture en layers où chaque couche aura un rôle à jouer.

Si nous devions transposer ceci en code, nous aurions quelque chose ressemblant à ceci:

function checkIdentity(req, res, next){
// code
if(/*votre verification*/){
//code
next(); //important !
} else {
//envoie du code d'erreur
}
}

function checkAuthorization(req, res, next){
// code
if(/*votre verification*/){
//code
next(); //important !
} else {
//envoie du code d'erreur
}
}

// les autres middlewares

app.use(checkIdentity, checkAuthorization, /*les autres middlewares*/, controler);
astuce

On passe des fonctions comme paramètres. Donc pas de () après les noms de fonctions. Autrement, elles seraient exécutées !

Quand la requête arrive, elle va d'abord être traitée par checkIdentity.

  • Si la fonction exécute le code dans le if, alors elle appellera la fonction next().
  • Sinon, elle enverra un code d'erreur au client (et aucun next() n'est appelé).

Grâce à l'appel à next(), Express va passer au middleware suivant: checkAuthorization.

  • Si la fonction exécute le code dans le if, alors elle appellera la fonction next().
  • Sinon, elle enverra un code d'erreur au client (et aucun next() n'est appellé).

Grâce à l'appel à next(), Express va passer au middleware suivant. Le processus se répète tant qu'on appellera la fonction next().

attention

Si vous apportez des modifications à req dans un middleware, le nouveau req sera transmis au middleware suivant. Ce qui va nous permettre d'enrichir cette variable. Par exemple, nous pourrions ajouter les valeurs validées ou les informations de l'utilisateur dans req.

Phase 1: identification

Nous allons créer les fichiers manquants ou compléter les fichiers existants. Nous allons nous concentrer uniquement sur la mise à jour d'un client.

./model/client.js
export const readClientByEmail = async (SQLClient, {email}) => {
let query = "SELECT * FROM client WHERE email = $1";
const {rows} = await SQLClient.query(query, [email]);
return rows[0];
};

export const updateClient = async (SQLClient, id, {name, firstname, address, password}) => {
let query = "UPDATE client SET ";
const querySet = [];
const queryValues = [];
if(name){
queryValues.push(name);
querySet.push(`name = $${queryValues.length}`);
}
if(firstname){
queryValues.push(firstname);
querySet.push(`firstname = $${queryValues.length}`);
}
if(address){
queryValues.push(address);
querySet.push(`address = $${queryValues.length}`);
}
if(password){
queryValues.push(password);
querySet.push(`password = $${queryValues.length}`);
}
if(queryValues.length > 0){
queryValues.push(id);
query += `${querySet.join(", ")} WHERE id = $${queryValues.length}`;
return await SQLClient.query(query, queryValues);
} else {
throw new Error("No field given");
}
};
./model/manager.js
export const readManager = async (clientSQL, {email}) => {
const {rows} = await clientSQL.query(
"SELECT * FROM manager WHERE email = $1 ",
[email]
);
return rows[0];
};
./model/person.js
import {readClientByEmail} from "./client.js";
import {readManager} from "./manager.js";

export const readPerson = async (clientSQL, {email, password}) => {
const responses = await Promise.all([
readClientByEmail(clientSQL, {email}),
readManager(clientSQL, {email})
]);

if (responses[0]) {
return password === responses[0].password ?
{id: responses[0].id, status: "client"} :
{id: null, status: null};
} else if (responses[1]) {
return password === responses[1].password ?
{id: responses[1].id, status: "manager"} :
{id: null, status: null};
} else {
return {id: null, status: null};
}
};
./controler/client.js
import {pool} from "../database/database.js";
import * as clientModel from "../model/client.js";

export const updateClient = async (req, res) => {
try {
await clientModel.updateClient(pool, req.session.id, req.body);
res.sendStatus(204);
} catch (e) {
console.error(e);
res.sendStatus(500);
}
};
./route/client.js
import Router from 'express';
import {updateClient} from "../controler/client.js";

const router = Router();

router.patch("/me", /*TODO*/, updateClient); // nous allons faire le middleware dans l'exercice 1

export default router;

Ces fichiers nous serviront pour la création de nos middlewares. La plupart ne font que des appels à des modèles. Ces derniers font uniquement des appels basiques à la base de données. Pour la suite de la conception du code, nous devons voir un point de théorie sur les middlewares et leur fonctionnement dans ExpressJS.

Voyons ce que font ces fichiers: Les différents modèles sont proches de ce que nous avions fait aux laboratoires précédents. Il n'y a que person.js qui mérite des explications. Ce fichier permet, à partir d'une adresse mail, de vérifier si une personne est un client ou un manager en interrogeant les deux tables en même temps.

  • La méthode Promise.all() nous permet d'attendre la résolution de nos promesses.
  • Nous vérifions ensuite si la première promesse a bien trouvé une correspondance, si c'est le cas, nous avons un manager.
  • Dans le cas contraire, nous vérifions s'il s'agit d'un client.
  • Enfin, si nous n'avons aucune correspondance pour les deux tables, cela signifie que le couple email/mot de passe donné ne trouve aucune correspondance dans la base de données.

Dans tous les cas, nous renvoyons toujours un objet avec l'attribut id et l'attribut status. Ils sont mis à null si nous ne trouvons aucune correspondance.

astuce

Vous pouvez constater que nous faisons la vérification du mot de passe côté serveur et non de la base de données. Vous vous dites surement que nous aurions pu utiliser notre requête SQL pour filtrer directement les résultats.... Et vous auriez totalement raison !

En réalité, cette structure nous sera utile lors du prochain laboratoire où nous utiliserons des mots de passe hashés.

Autorisation Basic

Une autorisation de ce type consiste à envoyer un header de la forme suivante : Authorization Basic username:password

Par un souci de lisibilité, je n’ai pas encodé les identifiants en base 64. Mais cela doit être fait. Ce qui donnerait l’en-tête suivant : Authorization Basic dXNlcm5hbWU6cGFzc3dvcmQ=

L’identification basic ne renvoie rien. Cependant, vous pouvez renvoyer un token si vous le désirez, mais cela ne fait pas partie du standard. Cela signifie que vous devriez renvoyer l’en-tête pour chaque requête qui nécessite un accès sécurisé.

danger

De plus, l’encodage en base 64 ne cache en rien les données. Si on arrive à récupérer l’en-tête, il est donc facile de récupérer les identifiants de connexion.

Exercice 1 : sécuriser la mise à jour du client

Actuellement, il n’est pas possible de mettre à jour un client. En effet, le contrôleur que nous venons de créer est incapable de gérer la session. Il l’utilise, mais il ne peut pas toucher au header Authorization. Pour cela, nous allons créer un middleware qui se chargera de faire cela pour nous.

Créez le dossier middleware à la racine et créez le fichier Identification.js avec le code suivant :

./middleware/identification/basic.js
import {pool} from '../../database/database.js';
import {readPerson} from '../../model/person.js';

export const authBasic = async (req, res, next) => {
//1. récupérer le header authorization
if(/*Vérifier que le header est correct*/){
//2. décoder le header
if(/*Vérifier que nous avons bien un id et donc qu'il y a correspondance dans la DB*/){
//3. Mettre les informations dans l'objet req et appeler la fonction next.
} else {
//Code d'erreur envoyé, car aucune correspondance dans la DB
}
} else {
//Code d'erreur envoyé, car pas d'identification basic
}
}

Voici quelques liens qui devraient vous aider à réaliser le code :

Quand vous pensez votre middleware prêt, n’oubliez pas de changer le fichier ./route/client.js pour inclure et utiliser votre middleware.

Pour envoyer une requête avec le header Authorization, vous pouvez utiliser Bruno qui se chargera de convertir les identifiants en base 64.

Interface de connexion avec des champs pour le nom d'utilisateur et le mot de passe dans un outil d'API.

Phase 2: autorisation

Il y a deux choses à distinguer : l’identification et les autorisations. Vous pouvez être identifiés, mais ne pas avoir les droits pour effectuer une action. Vous pouvez être connectés avec le statut de membre sur un site sans être capable de modifier ou supprimer les fiches d’un produit. Il est donc intéressant de diviser cela en 2 middlewares :

  • Le premier vérifie que les identifiants sont bons et donne le niveau de droit à l’utilisateur
  • Le second vérifie que le niveau de droit est suffisant pour l’action souhaitée.

Pour l'instant, un client peut changer ses informations. Mais maintenant, on aimerait qu'un manager puisse modifier les informations de n'importe qui. Nous allons donc créer une nouvelle route pour permettre cette action. Mais cette fois, en plus de vérifier l'identité de la personne, nous allons vérifier si elle peut faire l'action !

Exercice 2 : management des clients

Créez les différents fichiers avec leur contenu:

./model/manager.js
import {updateClient as updateC} from "./client.js";

export const updateClient = (SQLClient, info) => {
return updateC(SQLClient, info.id, info);
};

export const readManager = async (clientSQL, {email}) => {
const {rows} = await clientSQL.query(
"SELECT * FROM manager WHERE email = $1 ",
[email]
);
return rows[0];
};
./controler/manager.js
import {pool} from "../database/database.js";
import {updateClient as updateC} from "../model/manager.js";

export const updateClient = async (req, res) => {
try {
await updateC(pool, req.body);
res.sendStatus(204);
} catch (err) {
console.error(err);
res.sendStatus(500);
}
};
./route/manager.js
import Router from 'express';
import {updateClient} from "../controler/manager.js";

const router = Router();

router.patch("/client", /*TODO*/, updateClient);

export default router;
./route/index.js
import Router from 'express';
import {default as produitRouter} from "./product.js";
import {default as managerRouter} from "./manager.js";
import {default as purchaseRouter} from "./purchase.js";

const router = Router();

router.use("/product", produitRouter);
router.use("/manager", managerRouter);
router.use("/purchase", purchaseRouter);

//Gestion d'une URL hors application
router.use((req, res) => {
console.error(`Bad URL: ${req.path}`);
return res.status(404).send("Il ne s'agit pas d'une URL prise en charge par l'application");
});

export default router;
./middleware/authorization/mustBe.js
export const manager = (req, res, next) => {
if(/*regarder dans req les infos disponibles*/){
// passer au middleware suivante
} else {
// envoyer le bon status code
}
};

Comme consignes :

  • Compléter les parties manquantes dans ./route/manager.js et ./middleware/authorization/mustBe.js
  • N'oubliez pas de réutiliser le middleware fait à l'exercice 1 !

Validation des données

Jusqu'à présent, nous faisions confiance aux informations que l'utilisateur envoyait. Il s'agit d'une erreur à absolument éviter !

Une femme en vêtements médiévaux avance avec un air déterminé, entourée de personnes qui semblent en colère. Le texte "SHAME. SHAME. SHAME." est en gros caractères en bas de l'image.

En effet, les données utilisateurs doivent toujours être vérifiées. Nous avons dès lors deux options:

  • Validation par nos soins
  • Validation par une librairie

La première approche n'est pas la meilleure. En effet, même si elle permet d'éviter de rajouter une libraire au projet (ce qui n'est pas réellement important étant donné qu'il s'agit d'un backend), elle montre rapidement ses limites. En plus de demander des connaissances avancées en JavaScript. Prenons un exemple: avez-vous une idée de pourquoi parseInt("0xF") vous renvoie 15 ? Etant donné que nous n'avons pas précisé la base numérique utilisée pour la conversion (il s'agit du second argument de la fonction), elle essaie de deviner la base utilisée. Or, 0x indique un nombre en base hexadécimal. De plus, nous pouvons aussi discuter des performances de nos traitements...

Pour toutes ces raisons, nous allons opter pour une librairie dédiée à la validation. Il en existe plusieurs dont Zod. Cependant, je vous propose d'utiliser Vine. Voici un exemple de code:

import vine from '@vinejs/vine'

const schema = vine.object({
email: vine.string().email(),
password: vine
.string()
.minLength(8)
.maxLength(32)
})

const data = getDataToValidate()
const val = await vine.validate({ schema, data })
console.log(val)
  1. Nous définissons un schéma qui devra être validé par l'objet qui sera validé par Vine. Cet objet devra contenir deux propriétés: email et password.
    • email devra être un string (string()) et devra correspondre au format d'un email (email())
    • password devra être un string (string()), devra être de longueur comprise entre 8 caractères (minLength(8)) et maximum 32 caractères (maxLength(32))
  2. const data = getDataToValidate() nous permet d'avoir un objet que nous devons valider
  3. vine.validate() prend 2 arguments:
    • Le premier est le schéma (celui défini au point 1)
    • L'objet que vous désirez valider
  4. vine.validate() renvoie une promesse qui:
    • réussit si les champs présents dans data remplissent les conditions du schéma. Dans ce cas, un objet est renvoyé avec les données validées
    • échoue si les champs présents ne remplissent pas les conditions du schéma. Un objet d'erreur est alors envoyé avec les messages d'explications pour les champs problématiques
astuce

Vine convertit les données. Par exemple: si vous lui indiquez qu'un champ est un nombre, l'objet renvoyé aura ce champ de type nombre.

Accélération de la validation

Le point fort de Vine est le fait qu'il "pré-compile" les schémas de validation (il ne s'agit pas à proprement de compilation comme Java), ce qui lui permet d'accélération la validation des données.

import vine from '@vinejs/vine'

const schema = vine.object({
username: vine.string(),
email: vine.string().email(),
password: vine
.string()
.minLength(8)
.maxLength(32)
})

const data = {
username: 'virk',
email: 'virk@example.com',
password: 'secret',
}

const validator = vine.compile(schema)
const output = await validator.validate(data)

console.log(output)

Exercice 3: validation des données clientes

Nous allons vérifier que les informations pour mettre à jour le client soient correctes. Nous voulons la structure suivante (valeurs à titre indicatif):

{
"id": "1",
"name": "SuperName",
"firstName": "SuperFirstName",
"address": "SuperAddress",
"email": "super@mail.com",
"password": "superPassword"
}

où:

  • id sera une chaîne de caractères convertible en nombre
  • name, firstName, address et password seront une chaîne de caractères
  • email sera une chaîne de caractères correspondante à un mail
  • Chaque champ est optionnel, sauf id

N'oubliez pas de modifier vos contrôleurs pour utiliser les valeurs vérifiées et de mettre à jour vos routes pour utiliser ces nouveaux middlewares !

Exercice 4: Gestion des produits (récap)

Nous allons pouvoir combiner tout ce que nous avons appris et nous allons pouvoir sécuriser l'accès à la gestion des produits. Nous souhaitons que la création, mise à jour et suppression d'un produit ne soit réalisable que par un manager. Dans l'ordre, vous devrez:

  1. Identifier la personne qui a envoyé la requête HTTP (via un middleware)
  2. Vérifier si la personne est bien un manager (via un middleware)
  3. Vérifier les données reçues (via un middleware)
  4. Adaptez votre contrôleur pour utiliser les données vérifiées au point 3
Homme avec une barbe disant "Que la Force soit avec vous."

Conclusion

Grâce à ce laboratoire, vous pouvez utiliser les middlewares. Grâce à eux, vous pouvez sécuriser des routes qui ne l’étaient pas, sans modifier des contrôleurs. Vous êtes également capables de récupérer et manipuler les headers des requêtes. De plus, vous comprenez dorénavant l’identification Basic.