Grégory Bourguin
SysReIC - LISIC - ULCO
Authentification - JSON Web Token (JWT)

JSON Web Token

Il est courant pour une application web complexe de restreindre les accès à tout ou partie de ses fonctionnalités, ou de customiser ses réponses, en fonction d'informations d'authentification comme une paire login/password.

Pour ne pas avoir à (re)demander ces informations d'authentification à chaque requête sensible du client au serveur, différentes stratégies peuvent être envisagées (ex. tokens, sessions, ...) : dans ce cours, nous étudierons l'approche par JSON Web Token (JWT).

Mécanisme de l'approche par token

Le mécanisme de token permet de garantir que les requêtes envoyées par un client proviennent bien d'un utilisateur qui a précédemment été authentifié par le serveur.

Le principe général est qu'une fois que le serveur web a authentifié un client, il lui transmet un token crypté que le client doit lui-même renvoyer à chaque requête sensible pour que le serveur puisse en vérifier la provenance.

Pour mettre en oeuvre de tels échanges, il est nécessaire que le client stocke le token en local, et qu'il l'envoie à chaque requête sensible.

Différentes stratégies peuvent être envisagées : nous utiliserons l'approche par cookies.

jsonwebtoken

Le module jsonwebtoken fournit une implémentation qui facilite la génération et la vérification de tokens d'authentification.

Installation : npm install jsonwebtoken

Pour l'utiliser, il suffit de l'importer :

const jwt = require('jsonwebtoken') ;

Un token JWT contient 3 parties (que vous pouvez tester sur https://jwt.io/) :

  • header : une méta-information qui indique le type de token et l'algorithme de chiffrement utilisé.
  • payload : toute information que vous souhaitez inscrire dans le token pour la récupérer lorsqu'il est transmis avec une requête (ex. l'_id de l'utilisateur à l'origine de la requête).
  • signature : une chaîne générée cryptée contenant la concaténation du header, du payload et d'un secret en utilisant l'algorithme de chiffrement indiqué dans le header.

Le secret est une chaîne aléatoire complexe que seul le serveur doit posséder !

Génération d'un token

La génération d'un token est réalisée avec la méthode jwt.sign(payload, secret, options):

const payload = {
    userId: 'id_de_l_utilisateur'
    // on pourrait ajouter d'autres informations non 'volatiles'
}
const SECRET_KEY = '!!! une chaine aleatoire complexe qui ne doit pas etre partagee !!!' ;
const options = {
    expiresIn: '30s' // on peut utiliser 's', 'h', 'd', ou pas d'unité pour les ms
    // algorithm : 'HS256', //  HS256 est la valeur par défaut
    // ... il existe d'autre options possibles (cf. lien ci-dessous)
}
const token = jwt.sign(payload, SECRET_KEY, options) ; // création du token

Les options JWT possibles sont listée ICI.

Vérification d'un token

La vérification d'un token est réalisée avec la méthode jwt.verify(token, secret):

try {
    // Vérification du token et récupération du payload
    // (si la vérification échoue, une exception est levée)
    const payload = jwt.verify(token, SECRET_KEY);
} catch {
    // code à exécuter en cas de token invalide
}

Exemple d'implémentation

Dans cet exemple très simplifié, nous allons créer 4 routes :

  • / : une route de "home page" d'un site qui sera accessible à tous.
  • /admin : une route protégée qui ne sera accessible qu'aux utilisateurs ayant un JWT valide.
  • /login : une route qui renvoie un JWT.
  • /logout : une route qui "deconnecte" le client.

Pour simplifier, nous n'allons ici créer qu'un seul et unique router mainRouter et un seul et unique controller nommé mainController.

Home Page : / (accessible à tous)

La route / est une route très classique avec les déclarations suivantes :

Dans mainRouter.js

router.get('/', mainController.home); 

Dans mainController.js

module.exports.home = (req, res)=>{
    res.render('pages/home') ;
}

Page Login : /login

Dans un exemple réel, la page de login enverrait un formulaire d'authentification avec une méthode GET, puis soumettrait les informations remplies par l'utilisateur dans une méthode POST.

Le JWT ne serait alors créé et envoyé au client que si les informations d'authentification ont été jugées correctes par le serveur (par exemple en vérifiant la paire login/password stockée dans une DB).

Dans notre exemple simpliste, la route /login va toujours automatiquement créer et envoyer un JWT au client (donc sans aucune vérification de quoi que ce soit).
Bien entendu, on ne procéderait pas ainsi dans une application réelle !

Dans mainRouter.js

router.get('/login', mainController.login);

Pour regrouper toutes les méthodes dont nous aurons besoin autour de JWT, nous allons créer un controller "utilitaire" nommé authorization.

Sa première méthode nommée createToken(res, userId) recevra un objet réponse (le fameux res) et un identifiant utilisateur qui sera mis dans le payload du token créé.

autorization.js

const jwt = require('jsonwebtoken') ;

// Le nom du cookie dans lequel le JWT sera stocké
const COOKIE_NAME = 'token' ; 

// Le secret est sensible et a donc été mis dans le fichier .env (utilisation du module 'dotenv')
// (NB : il aura pu être généré dans un script externe avec le module 'crypto')
const SECRET_KEY = process.env.SECRET_KEY ; 

// Pour les tests, le JWT ne sera valide que 30s
const EXPIRATION = '30s'

module.exports.createToken = (res, userId)=>{

    // Création du token
    const token = jwt.sign(
        { userId },
        SECRET_KEY,
        { expiresIn: EXPIRATION });

    // Enregistrement du token dans un cookie
    res.cookie(COOKIE_NAME, token, { httpOnly: true })
}

On peut maintenant écrire le handler login(req, res) de notre mainController :

Dans mainController.js

const authorization = require("./authorization");

module.exports.login = (req, res)=>{
    let userId = 666 ; // cette information devrait provenir d'une DB
    authorization.createToken(res, userId) ;
    res.redirect('/admin') ;
}

Page Admin : /admin (protégée)

L'affichage de la page Admin doit être conditionné à la présence d'un JWT valide dans le cookie qui a été crée lors de l'accès à /login.

Pour cette vérification, nous allons créer un handler autorize(req, res, next) dans le controller authorization :

Dans authorization.js

module.exports.authorize = (req, res, next)=>{

    // Récupération du cookie
    const token = req.cookies[COOKIE_NAME];

    // On vérifie si le cookie existe
    if (!token) {
        return res.sendStatus(403); // Stoppe le traitement en renvoyant une erreur 'forbidden'
    }

    // -> puisque le cookie existe, on va vérifier le token qu'il contient
    try {
        // Vérification et récupération du payload :
        const payload = jwt.verify(token, SECRET_KEY);
         
        // Place le payload dans req pour l'envoyer au middleware suivant :
        req.userId = payload.userId ; 
        
        // Tout est ok : on exécute le handler/middleware suivant :
        next() ; 
        
    } catch {
        // Le token n'était pas/plus valide :
        res.clearCookie(COOKIE_NAME) ; // On efface le cookie
        return res.sendStatus(403); // Stoppe le traitement en renvoyant une erreur 'forbidden'
    }
}

Lorsque tout ira bien, le traitement de la page Admin sera réalisé par le handler admin(req, res) dans mainController :

Dans mainController.js

module.exports.admin = (req, res)=>{
    // Si le token a été jugé valide par authorize, req contiendra le champ userId 
    let userId = req.userId ;
    res.render('pages/admin', { userId }) ;
}

Il ne reste plus qu'à enchainer ces handlers dans la route /admin de mainRouter :

Dans mainRouter.js

const authorization = require('../controllers/authorization');

// Route protégée par le middleware authorization.authorize :
router.get('/admin', authorization.authorize, mainController.admin);

Du fait de l'insertion d'authorize, un accès à /admin sans être passé par /login ou après l'expiration du JWT aura pour effet :


Par contre, si je JWT existe et est valide, on aura :

Déconnexion : /logout

Pour terminer, on peut ajouter la route /logout qui a pour tâche de déconnecter l'utilisateur.

Dans notre exemple simple, nous allons simplement retirer le cookie contenant le JWT : de ce fait, les prochaines requêtes du client ne contiendront plus de JWT et l'accès à la route protégée sera invalidé.

Dans authorization.js

module.exports.clearToken = (res)=>{
    // Suppression du cookie contenant le token
    res.clearCookie(COOKIE_NAME) ;
}

Dans mainRouter.js

router.get('/logout', mainController.logout);

Dans mainController.js

module.exports.logout = (req, res)=>{
    authorization.clearToken(res) ;
    res.redirect('/') ;
}

NB : dans une application réelle, on pourrait en plus conserver dans le serveur une liste de tokens "bannis" pour éviter qu'un utilisateur ne trouve le moyen de renvoyer un token dont la date d'expiration n'est pas dépassée, mais qu'on ne souhaite plus autoriser.

TP 05

Transformez le TP03 pour qu'il mette maintenant en effectivement oeuvre mécanisme de connexion basé sur JWT.

La démo ci-dessous contient un seul router homeRouter et 4 routes :

  • GET / :
    - si l'utilisateur n'est pas authentifié -> invite à se logger (page home.ejs).
    - si l'utilisateur est authentifié -> page utilisateur (page userPage.ejs).
  • GET /login : envoie le formulaire de login.
  • POST /login : traite le formulaire de login (et redirige vers /).
  • GET /logout : déconnecte l'utilisateur (et redirige vers /).

L'identifiant stocké dans le payload est ici le login de l'utilisateur.

La correction se trouve ICI.

Utilisateurs

Grâce à JWT, nous sommes capable de fournir un mot de passe temporaire à nos utilisateurs authentifiés pour leur permettre d'accéder aux parties privées d'un site.

Il nous reste à mettre en place la partie authentification avec des informations du type login/password.

Les informations d'authentification sont généralement conservées dans une DB. Nous allons donc les enregistrer dans notre gestionnaire MongoDB et y accéder grâce à mongoose.

Pour ces tests, nous mettrons les utilisateurs dans une collection nommée users de la base existante movies_db.

Modèle Utilisateur

La première étape consiste à définir un modèle mongoose.

Dans notre exemple les utilisateurs auront :

  • Un login : une chaine (obligatoire et unique).
  • Un password : une chaine (obligatoire).
  • Un _id : clé primaire (créée automatiquement).

Nous ne voulons pas avoir dans notre DB plusieurs utilisateurs qui ont le même login.

Le module mongoose-unique-validator permet d'indiquer dans un schéma mongoose que la valeur d'un champ doit être unique dans la collection. Si ce n'est pas le cas lors de la création d'un document, le document n'est pas créé et une erreur est déclenchée.

Installation : npm install mongoose-unique-validator
Documenation : https://www.npmjs.com/package/mongoose-unique-validator

On peut alors définir le modèle User comme suit :

User.js

const mongoose = require('mongoose') ;
const validator = require('mongoose-unique-validator');

const userSchema = mongoose.Schema({

    login: { type: String, required: true, unique: true },
    // 'unique: true' empêchera d'enregistrer plusieurs User avec le même login 
    
    password: { type: String, required: true }
}) ;

// Activation de mongoose-unique-validator
userSchema.plugin(validator);

module.exports = mongoose.model('User', userSchema, 'users') ;

Enregistrement

Pour créer des utilisateurs, nous allons créer une route /user/create.

Un GET /user/create renverra un formulaire contenant les champs login et password.



Un POST /user/create (soumission du formulaire) correspondra à une requête de création d'utilisateur dans MongoDB avec les champs login et password.

Il serait malvenu d'enregistrer le password en clair dans la DB.
La méthode hash du module bcrypt permet de crypter des données.

Installation : npm install bcrypt.
Documenations : https://www.npmjs.com/package/bcrypt


On aura alors :

Dans app.js

const usersRouter = require('./routes/userRouter');
app.use('/user', usersRouter);

Dans userRouter.js

const controller = require('../controllers/userController');

router.get('/create', controller.userCreateForm) ;
router.post('/create', controller.userCreate) ;

Dans userController.js

const bcrypt = require('bcrypt') ;
const User = require("../models/User");

module.exports.userCreateForm = (req, res) =>{
    res.render('pages/userCreate') ; // userCreate.ejs contient le formulaire
}

module.exports.userCreate = (req, res) =>{

    bcrypt.hash(req.body.password, 10) // Cryptage (en 10 passes) du password
        .then(pwd => {
            
            // Instanciation du nouvel utilisateur
            const user = new User({ 
                login: req.body.login,
                password: pwd
            });
            
            // enregistrement dans la DB
            user.save() 
                .then((user) => res.status(201).send(`User successfully created`))
                .catch(error => {
                    return res.status(400).send( error )
                });
        })
        .catch(error => {
            return res.status(500).send( error )
        });
}

Authentification

Pour authentifier les utilisateurs, nous allons créer une route /user/login.

Un GET /user/login renverra un formulaire demandant les champs login et password.



Un POST /user/login (soumission du formulaire) correspondra à une tentative d'authentification qui, si elle réussit, renverra un JWT permettant d'accéder aux parties privées du site.

Recherche de l'utilisateur

La première étape de l'authentification consiste à vérifier si le login entré dans le formulaire de Log In correspond à celui d'un utilisateur enregistré dans la DB : pour ce faire, on peut utiliser la méthode findOne de mongoose.

Vérification du mot de passe

Si tel est le cas, on doit ensuite vérifier que le password de cet utilisateur dans la DB, correspond à celui entré dans le formulaire.

La méthode compare du module bcrypt permet de vérifier si une donnée non cryptée correspond à une autre donnée précédemment cryptée.

Implémentation

Dans userRouter.js

router.get('/login', controller.userLoginForm) ;
router.post('/login', controller.userLogin) ;

Dans userController.js

const authorization = require("../controllers/authorization");

module.exports.userLoginForm = (req, res) =>{
    res.render('pages/userLogin') ;
}

module.exports.userLogin = (req, res) =>{
    // Recherche de l'utilisateur dans MongoDB
    User.findOne({ login: req.body.login })
        .then((user) => {

            // Si l'utilisateur n'est pas enregistré dans la DB, on envoie un message d'erreur
            if(!user) {
                return res.status(401).send('No such user') ; // Ceci devrait être amélioré !
            }

            // Si un utilisateur a été trouvé, on vérifie le password
            bcrypt.compare(req.body.password, user.password)
                .then(ok=>{
                    if(ok){ // Si les passwords correspondent :
                        // on envoie un JWT 
                        authorization.createToken(res, user._id);
                        // et on peut rediriger vers une page protégée
                        return res.redirect('/admin');
                    }else{ // Si les passwords ne correspondent pas :
                        return res.status(401).send('Bad Credentials') ; // Ceci devrait être amélioré !
                    }
                })
        })
        .catch(error => {
            return res.status(500).send( error ) // Ceci devrait être amélioré !
        });
}

Nettoyage

Notre site permet maintenant de créer des utilisateurs et leur permet d'accéder à des pages protégées.

Il reste cependant à fait un peu de "ménage" dans l'exemple que nous venons d'implémenter :

  • La route de création des utilisateurs est pour l'instant accessible à tous : une fois l'admin créé et le mécanisme de Login fonctionnel, une bonne idée serait la protéger !
  • Il faut implémenter une route de de Logout.
  • Le modèle d'utilisateur que nous avons créé est pour l'instant très simple. On pourrait étoffer la DB et ajouter de nombreux champs comme par exemple la notion de rôle qui permettrait de réellement personnaliser l'expérience des utilisateurs en leur accordant des privilèges différents.

La version complète se trouve ICI.

TP 06

Reprenez le TP 05 en créant des utilisateurs dans MongoDB, et en personnalisant leur page d'accueil avec des données stockées dans leur document une fois authentifiés.

Dans la démo ci-dessous, les Personal Data ('address') des utilisateurs sont stockées dans les documents de la collection users et récupérées dynamiquement lors de l'affichage de la page d'un utilisateur.

Vous pouvez créer vos utilisateurs "à la main", ou importer le fichier users.json.

La correction se trouve ICI.

À suivre...