On ne plaisante pas avec un contrôle d’identité ! Il en va de même en sécurité applicative où l’authentification est une fonctionnalité primordiale à mettre en œuvre. Mais quand il faut s’y mettre, le développeur sera confronté à de nombreuses problématiques et se posera beaucoup de questions sans y trouver de réponses, faute d’avoir un expert en sécurité applicative à sa disposition. Tout au long de cet article, vous suivrez pas à pas le questionnement d’un développeur back end accompagné d’un expert sécurité pour l’aider dans son raisonnement.
Pour illustrer cet article, un prototype est disponible sur GitHub (https://github.com/MiscMag101). Vous pourrez ainsi y consulter l’intégralité du code source pour avoir une meilleure vue d’ensemble de l’application. Vous pourrez également tester le prototype en suivant le guide d’installation.
1. Problème 1 : C’est facile, on verra plus tard !
- Dev : Pour le développement de cette nouvelle application, j’ai préféré faire appel à un expert en sécurité applicative pour éviter toute déconvenue à la suite d’un test d’intrusion. Mais bon, l’authentification c’est simple ! Il suffit de mettre un formulaire et une fonction qui vérifie le mot de passe de l’utilisateur.
- Sec : Hélas, ce n’est pas si simple ! L’authentification nécessite de nombreuses fonctionnalités : créer un nouvel utilisateur, enregistrer correctement le mot de passe en base de données, vérifier les règles de complexité des mots de passe, authentifier l’utilisateur, suivre les tentatives d’authentification, désactiver le compte après N tentatives, débloquer le compte par SMS, réinitialiser le mot de passe en cas d’oubli…
Le développement et le test de l’ensemble de ces fonctionnalités représentent une charge non négligeable. Ainsi, une application métier ne devrait pas porter ces fonctionnalités, mais utiliser un fournisseur d’identité (Identity Provider). Ce service peut être assuré au sein de votre société avec votre propre plateforme d’authentification (Oauth0, Keycloak…) ou utiliser une solution externe (Google, Facebook, GitHub…). Ces services sont relativement bien sécurisés et ces prestataires ont tout intérêt à bien sécuriser leurs solutions pour conserver la confiance de leurs utilisateurs.
Seul bémol, le pistage des utilisateurs par les réseaux sociaux. En effet, chaque authentification d’un utilisateur sur votre application, sera connue par le réseau social utilisé.
2. Problème 2 : Jackpot au casino !
- Dev : Les services d’authentification utilisent un protocole OAuth2 (notamment pour GitHub). Je n’y comprends pas grand-chose et il y a plein de jetons (access token, refresh token, authentication code…). Je gagne quoi avec ces jetons ?
- Sec : Le protocole OAuth2 est un protocole qui formalise les interactions entre les différentes parties prenantes du système. Ci-dessous, le schéma représentant les interactions entre votre application et GitHub conformément au processus « Authorization Code Grant » retenu par GitHub :
Fig. 1 : Diagramme de séquence du processus d’autorisation OAuth2 entre l’application Node.JS et GitHub.
L’utilisateur demande à s’authentifier avec son compte GitHub (1). Le back-end renvoie alors l’utilisateur vers le serveur OAuth de GitHub (2) en indiquant a minima son identifiant client et l’adresse de retour.
L’utilisateur est renvoyé vers le serveur OAuth de GitHub (3) sur lequel il est invité à s’authentifier puis à approuver la demande d’accès à son profil par le back-end (4 et 5). L’utilisateur est alors redirigé vers le back-end avec un code d’autorisation (6 et 7).
Le back-end peut désormais obtenir, auprès de GitHub, le jeton d’accès (8 et 9) en indiquant le code d’autorisation, son identifiant client et son secret. Grâce à ce jeton d’accès, le back-end peut ensuite demander les informations du profil utilisateur (10 et 11).
3. Problème 3 : OAuth2 c’est compliqué !!!
- Dev : Le protocole OAuth2 est bien trop compliqué pour l’intégrer dans mon application. Y a-t-il une alternative plus simple ?
- Sec : Pour ton application qui utilise Express, la mise en œuvre du protocole OAuth2 s’effectue en quelques lignes de code grâce au « middleware » Passport. Il vous décharge ainsi de toute la logique liée à l’authentification. Passport est le middleware de base auquel s’ajoutent de nombreux modules supplémentaires que l’on appelle « Strategy ».
Ce qui suit décrit l’implémentation de la stratégie GitHub au sein du prototype préalablement créé avec express-generator. Les plus curieux iront consulter le code complet sur GitHub (Prototype niveau 1).
3.1 Étape 1 - Installer Passport
Tout d’abord, il faut installer les paquets Passport avec la stratégie GitHub :
$ npm install --save passport passport-github
3.2 Étape 2 - Configurer Passport
Pour configurer Passport avec la stratégie « GitHub », il suffit de créer le fichier MyPassport.js dans le dossier config et d’y insérer le code suivant :
01: // Import Passport middleware
02: const MyPassport = require('passport');
03: // Import GitHub Strategy for Passport
04: const MyGitHubStrategy = require('passport-github').Strategy;
05: // Configure the new GitHub Strategy for Passport
06: MyPassport.use(new MyGitHubStrategy(
07: // Settings for GitHub Strategy
08: {
09: clientID: process.env.CLIENT_ID,
10: clientSecret: process.env.CLIENT_SECRET,
11: callbackURL: "https://" + process.env.HOST + ":" + process.env.PORT + "/signin/github/callback",
12: scope: "read:user"
13: },
14: // Verification function
15: function(accessToken, refreshToken, profile, done) {
16: return done(null, profile);
17: }
18: );
10: // Export passport object
20: module.exports = MyPassport
Ce module permet de créer une nouvelle stratégie avec les paramètres suivants :
- les options (lignes 8 à 13) dont le clientID et clientSecret vous seront communiqués par GitHub lorsque vous déclarez votre application. Le scope permet de préciser les droits que vous demandez, dans notre cas, un accès en lecture seule au profil de l’utilisateur (read:user) ;
- une fonction de vérification (lignes 14 à 17) qui permet d’exécuter les contrôles nécessaires à l’authentification de l’utilisateur (exemple : vérifier le mot de passe). Dans notre cas, l’authentification étant déjà réalisée par GitHub, nous pouvons simplement appeler la fonction de rappel done()et permettre à Passport de poursuivre le traitement de la requête.
3.3 Étape 3 : Créer les routes
Nous devons maintenant configurer les routes qui seront appelées par les utilisateurs pour s’authentifier. Les routes seront définies dans le fichier routes/signin.js avec le code suivant :
01: // Import Express module
02: const express = require('express');
03: const router = express.Router();
04: // Passport Setup
05: const MyPassport = require('passport');
06: // route to start OAuth2 authentication flow with Github
07: router.get('/github',MyPassport.authenticate('github'));
08: // route for callback from GitHub
09: router.get('/github/callback',
10: // Get user profile with authorization code and access token
11: MyPassport.authenticate('github', {session: false}),
12: // Greetings
13: function(req, res) {
14: if(req.isAuthenticated()){
15: res.send("Hello " + req.user.username + "!")
16: }
17: }
18: ));
19: module.exports = router;
La première route (ligne 7) permet de rediriger l’utilisateur vers le serveur OAuth de GitHub avec en paramètre le ClientID (flux 1 et 2 sur le diagramme de séquence en figure 1).
La seconde route (lignes 9 à 18) est l’adresse vers laquelle sera redirigé l’utilisateur après avoir accepté la demande d’authentification sur GitHub. Pour cette route, deux fonctions seront exécutées l’une après l’autre : Authenticate puis Greetings.
La première fonction authenticate()permet de dérouler la suite du processus OAuth2 (flux 8 à 11 sur le diagramme de séquence en figure 1) et d’exécuter la fonction de vérification qui a été définie dans le fichier config/MyPassport.js. Ensuite, la seconde fonction Greetings permet simplement de saluer l’utilisateur.
3.4 Étape 4 : Configurer l’application
Il faut maintenant configurer l’application Express pour prendre en compte la configuration de Passport et les nouvelles routes. Ajoutez le code suivant dans votre fichier app.js :
01: // Passport Setup
02: const MyPassport = require('./config/MyPassport.js');
03: app.use(MyPassport.initialize());
04: // route for sign-in
05: const SigninRouter = require('./routes/signin.js');
06: app.use('/signin', SigninRouter);
Le code ci-dessus démontre la simplicité de mise en œuvre de l'authentification avec Passport. Néanmoins, le flux d’exécution peut rester encore énigmatique pour certains d’entre vous et en particulier pour ceux qui ne seraient pas familiers avec le système de « callback » utilisé par Express. Pour vous permettre de comprendre le fonctionnement du framework, vous trouverez une description complète du flux d’exécution à la fin de l’article.
4. Problème 4 : Référentiel utilisateur
- Dev : J’ai tout de même besoin de conserver en base de données les utilisateurs afin de pouvoir y faire référence ultérieurement. Par exemple, comment dois-je stocker les données pour pouvoir afficher l’auteur d’un commentaire ?
- Sec : Après authentification de l’utilisateur, il est préférable de conserver les informations de vos utilisateurs dans une base de données. Toutefois, il faudra porter une attention particulière à l’identifiant qui sera retenu surtout si vous utilisez plusieurs réseaux sociaux pour authentifier un utilisateur. En effet, un même pseudo sur les réseaux sociaux peut représenter des personnes différentes.
Vous trouverez sur le Gist (https://git.io/fpPb3) une description étape par étape pour implémenter l’enregistrement des utilisateurs à partir du prototype développé précédemment. Pour cela, nous utiliserons une base de données MongoDB associée à Mongoose, un ODM (Object Data Model) qui facilite l’accès aux données. Vous pouvez consulter à tout moment le résultat final sur GitHub (Prototype niveau 2).
5. Problème 5 : Conserver l’authentification
- Dev : L’utilisateur devra-t-il s’authentifier avec GitHub pour accéder à chaque route de mon application ?
- Sec : Non, il n’est pas envisageable de réaliser une authentification via le protocole OAuth2 pour chaque requête effectuée par l’utilisateur. Le protocole HTTP est certes sans état, mais nous pouvons conserver le contexte de la session en cours sur le serveur.
Ce qui suit décrit la mise en œuvre des sessions au sein du prototype créé précédemment. Les plus curieux iront consulter le résultat final sur GitHub (Prototype niveau 3).
5.1 Étape 1 - Installer le middleware
Tout d’abord, il faut installer le middleware d’Express nécessaire au fonctionnement des sessions :
$ npm install --save express-session memorystore
Le module « MemoryStore » sera utilisé au sein de ce prototype pour stocker les sessions en mémoire. Toutefois, pour un environnement de production réel, il faudra utiliser une autre solution plus robuste, généralement une base de données clé/valeur comme Redis ou Cassandra.
5.2 Étape 2 - Sérialisation
Le fonctionnement du middleware Passport nécessite de définir deux fonctions : SerializeUser appelée à la création de la session et DeserializeUser appelée à chaque requête contenant un cookie de session valide. Pour situer davantage ces fonctions dans le processus d’authentification, vous trouverez une description complète du flux d’exécution à la fin de l’article.
Voici la fonction SerializeUser, extraite du fichier config/MyPassport.js du prototype :
// Save user object into the session
MyPassport.serializeUser(function (user, done) {
// If user doesn't exist
if (!user) {
return done(null, false);
}
// If everything all right, store object into the session
return done(null, user.id);
});
Si l’utilisateur est bien authentifié, l’identifiant de l’objet (MongoDB) est sauvegardé dans la session de l’utilisateur.
Voici maintenant la fonction DeserializeUser, extraite du fichier config/MyPassport.js du prototype :
// Restore user object from the session
MyPassport.deserializeUser(function (id, done) {
MyUser.findById(id, function (err, user) {
// If technical error occurs (such as loss connection with database)
if (err) {
return done(err);
}
// If user doesn't exist
if (!user) {
return done(null, false);
}
// If everything all right, the user will be authenticated
return done(null, user);
});
});
L’identifiant de l’utilisateur (ObjectID MongoDB) est utilisé pour effectuer une requête en base de données et retrouver l’ensemble des éléments nécessaires. Cela permet également de s’assurer que l’utilisateur n’a pas été supprimé ou désactivé. Si tel était le cas, nous pouvons immédiatement clore la session.
5.3 Étape 3 - Définir les routes
Pour notre prototype, nous avons besoin d’une route dont l’accès serait restreint uniquement aux personnes authentifiées. Pour cela, nous créons la route Greeting dans le fichier routes/private.js :
var express = require('express');
var router = express.Router();
//Greeting
router.get('/greeting', function(req, res, next) {
res.send("Hello " + req.user.username + "!")
});
module.exports = router;
À noter que cette route n’effectue aucun contrôle d’accès à ce stade. Cela sera implémenté ultérieurement pour l’ensemble des routes du fichier private.js. Cela évite ainsi de devoir appliquer le contrôle unitairement sur chaque route au risque d’oublier par inadvertance d’appliquer le contrôle sur une route.
Nous ajoutons également une route pour permettre à l’utilisateur de se déconnecter (fichier routes/signout.js) et nous modifions la route d’authentification pour activer les sessions et rediriger l’utilisateur vers la route Greeting (fichier routes/signin.js).
5.4 Étape 4 - Configurer l’application
Il faut maintenant configurer l’application Express (fichier app.js) pour prendre en compte l’utilisation des sessions :
const MySession = require('express-session');
// Session Store
const MyMemoryStore = MySession.MemoryStore;
const MysessionStore = new MyMemoryStore();
app.use(MySession(
{
store: MysessionStore,
secret: process.env.SessionSecret,
resave: false,
saveUninitialized: true,
cookie:{path: '/', httpOnly: true, secure: true, maxAge: 60000, sameSite: 'strict'},
}
));
// Passport Setup
const MyPassport = require('./config/MyPassport.js');
app.use(MyPassport.initialize());
app.use(MyPassport.session());
Si vous activez l’option Secure Cookie, mais que vous n’utilisez pas le protocole HTTPS, votre navigateur n’enverra jamais le cookie au serveur.
Pour vérifier facilement qu’un utilisateur est authentifié, nous allons créer la fonction de vérification suivante :
//Check if user is authenticated
var Authenticate = function (req, res, next) {
if (req.isAuthenticated()) {
return next();
} else {
res.sendStatus(401);
}
};
Nous pouvons désormais importer les routes toujours dans le fichier app.js :
// route for signin
const SigninRouter = require('./routes/signin.js');
app.use('/signin', SigninRouter);
// route for signout
const SignoutRouter = require('./routes/signout.js');
app.use('/signin', SignoutRouter);
// Restricted route
const PrivateRouter = require('./routes/private.js');
app.use('/private', Authenticate, PrivateRouter);
code
À noter que la fonction Authenticate sera exécutée avant chaque route définie dans le fichier private.js.
6. Problème 6 : RESTful
- Dev : Mon back-end est une API RESTful ! Cela implique donc qu’il ne doit pas avoir de session côté serveur (stateless). Comment dois-je procéder pour maintenir l’authentification de mes utilisateurs ?
- Sec : La spécification RESTful s’est imposée en tant que standard pour le développement d’API et tout développeur hype se doit de la respecter. Mais d’un point de vue sécurité, il y a déjà de quoi s’arracher les cheveux pour maintenir l’identité de l’utilisateur tout au long de sa visite !
La solution alternative aux sessions est l’utilisation de jeton d’accès, souvent au format JWT (JSON Web Token) que l’utilisateur devra transmettre à chaque requête. Ce jeton contient toutes les informations de l’utilisateur et les données sont signées pour détecter toutes modifications malveillantes.
Les données, autrefois présentes en session (c’est-à-dire en mémoire vive du serveur), sont reprises dans le jeton. Attention à ne pas mettre trop d’information sous peine d'alourdir le trafic réseau puisque le jeton est transmis à chaque requête. De plus, le contenu n’est pas chiffré, mais simplement encodé en base64. Par conséquent, ce jeton ne doit pas contenir d’informations sensibles que l’utilisateur ne devrait pas connaître (mot de passe, api key…).
Vous trouverez sur le Gist (https://git.io/fpPNz) une description étape par étape pour implémenter une authentification par jeton au sein du prototype avec la stratégie passport-http-bearer. Comme toujours, les plus curieux pourront consulter la version finale du prototype de niveau 4 sur GitHub.
7. Problème 7 : Stockage du JWT
- Dev : Comment dois-je procéder pour sauvegarder les jetons JWT dans le Front-end ? Puis-je utiliser le session-storage ?
- Sec : Les jetons JWT sont généralement stockés dans le « session-storage » permettant ainsi à la SPA d’utiliser ce jeton pour l’insérer dans l’entête HTTP « Authorization » de chaque requête qui sera effectuée vers le back-end.
Néanmoins, votre application s’expose à un risque de vol de jeton, car la moindre faille XSS permettra à un attaquant d’exécuter du code JavaScript et ainsi récupérer les jetons de vos utilisateurs. La solution recommandée est donc de stocker vos jetons dans des cookies protégés par l’option « HTTP only » évitant ainsi un accès malveillant aux jetons. Cependant, il est rare de trouver des exemples d’implémentation d’une telle solution.
Ce qui suit décrit l’implémentation de la stratégie « Passport Cookie » en modifiant légèrement le prototype de niveau 4. Le résultat final est disponible dans le prototype de niveau 5 sur GitHub.
7.1 Étape 1 : Installation des paquets
$ npm install --save passport-cookie
7.2 Étape 2 : Création des jetons dans un cookie
Nous allons maintenant modifier la fonction d’envoi du jeton JWT, dans le fichier routes/signin.js, afin que celui-ci soit inséré dans un cookie :
// Issue JSON Web Token
function(req, res) {
// define the token payload
let payload = {id: req.user.id, username: req.user.username}
// sign the token
let token = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '10m', algorithm: 'HS512'});
// send the token in a secure cookie
res.cookie('token', token, { path: '/', secure: true, httpOnly: true , maxAge: 600000, sameSite: 'strict'});
res.redirect('/private/greeting');
}
Le jeton sera désormais transmis à l’utilisateur dans un cookie sécurisé (HTTP Only et HTTPS) et la durée de validée du cookie (10 minutes) est la même que celle du jeton JWT.
7.3 Étape 3 : Configuration de passport
Nous allons configurer une nouvelle stratégie pour prendre en compte les cookies contenant le jeton JWT dans le fichier config/MyPassport.js. L’utilisateur devra transmettre le cookie dans chacune de ses requêtes.
// Import Cookie Strategy for Passport
MyCookieStrategy = require('passport-cookie').Strategy;
// Import JWT module
const jwt = require('jsonwebtoken');
// Configure the cookie Strategy for Passport
MyPassport.use(new MyCookieStrategy(
function (token, done) {
// Check JWT
jwt.verify(token, process.env.JWT_SECRET, function(err, payload) {
// If checking failed
if (err) {
return done(err)
};
// If user is empty
if (!payload){
return done(null, false);
}
// If everything all right, the user will be authenticated
return done(null, payload);
});
}));
7.4 Étape 4 : Configurer l’application
Nous devons maintenant modifier la configuration (fichier app.js) des routes privées pour prendre en compte la nouvelle stratégie :
const PrivateRouter = require('./routes/private.js');
app.use('/private', MyPassport.authenticate('cookie', {session: false}), PrivateRouter);
8. Problème 8 – Déconnexion
- Dev : Mais si les utilisateurs souhaitent se déconnecter de l’application, comment dois-je procéder ?
- Sec : Lorsque l’utilisateur se déconnecte de votre application, il faudra a minima supprimer le jeton du front-end, qu’il soit stocké dans le local storage ou dans un cookie. Toutefois, ceci n’est pas une mesure suffisante en soi, car votre application reste vulnérable à une attaque par rejeux. En effet, si l’utilisateur ou un attaquant a conservé le jeton, il pourra l’utiliser tant que la date d’expiration ne sera pas atteinte.
Pour y remédier, il faut maintenir une liste noire qui contiendra tous les jetons révoqués à la suite de la déconnexion de l’utilisateur. Cela aura un impact sur les performances, car il sera nécessaire de consulter la liste noire à chaque requête effectuée par un utilisateur. Mais ceci est indispensable pour assurer un bon niveau de sécurité.
Vous trouverez sur le Gist (https://git.io/fpPAr) une description étape par étape pour implémenter une liste noire avec Redis, une base de données clé/valeur. Le résultat final est disponible dans le prototype de niveau 6 sur GitHub.
Conclusion
Comme vous avez pu le constater au travers de cet article, l’authentification n’est pas si simple à mettre en œuvre et il ne faut pas en négliger la charge de développement. Même avec des frameworks dédiés à l’authentification comme Passport, il ne suffit pas de l’installer et de copier deux à trois lignes de code pour que cela fonctionne en toute sécurité. Il faut prendre le temps de maîtriser le framework et de réfléchir à la meilleure implémentation.
Cet article décrit un cas d’usage du framework Passport. À vous de l’adapter à votre contexte : méthode d’authentification, mode d’autorisation Oauth2...
ANNEXES
Passport - Flux d’exécution
Au premier abord, le fonctionnement de Passport peut vous paraître très abstrait. Voici une description pas à pas du flux d’exécution d’une requête représenté sous la forme d’un objet req :
1. La requête sera tout d’abord traitée par le middleware Express Session pour récupérer les données relatives au contexte de l’utilisateur à partir de l’identifiant de session présent dans le cookie « connect.sid » . Les informations ainsi collectées seront ajoutées à la requête (req.session). Les données concernant l'authentification se trouvent donc dans req.session.passport.user.
2. La requête est ensuite traitée par la fonction passport.initialize() pour ajouter à la requête les objets nécessaires au fonctionnement de Passport (req.passport, req.login, req.logout, req.isauthenticated...) et initialiser les valeurs.
3. La requête sera ensuite traitée par la fonction passport.session()qui permet de restaurer l’objet utilisateur (req.user) à partir des informations disponibles dans la session (req.session.passport).
4. La fonction passport.deserializeUser()permet d’effectuer des actions supplémentaires comme effectuer une requête en base de données pour vérifier si l’utilisateur n’a pas été révoqué.
5. La requête est ensuite redirigée vers la route concernée.
6. Si la requête concerne une route d’authentification, la fonction passport.authenticate()est exécutée et appelle la fonction de vérification que vous avez définie au sein de votre stratégie. Cette fonction effectue tous les contrôles nécessaires à l’authentification de l’utilisateur.
7. La fonction de rappel, généralement nommée done(), sera appelée, pour rendre la main à la fonction passport.authenticate(), de la manière suivante :
- done(err): en cas d’erreur technique (erreur d’accès à la base de données…) ;
- done(null,false): si votre fonction de vérification échoue ;
- done(null,user): si tout se passe bien (où user représente l’objet de l’utilisateur qui peut être parfois nommé profile).
8. La fonction passport.authenticate()ajoute l’objet de l’utilisateur (user), transmis par la fonction de rappel, à la requête (req.user).
9. La fonction passport.serializeUser()que vous avez définie sera appelée pour stocker les informations de l’utilisateur dans la session.