Préambule

  • Le code de démonstration repose sur des paquets non standards. C'est pourquoi l'archive disponible ci-dessous inclut toute l'arborescence du projet, excepté le répertoire node_modules.
  • Pour les plus curieux qui veulent voir comment est conçu le back-end, une section à la fin de cet article vous permet de le télécharger et vous donne les indications pour l'installer et le configurer sur votre propre machine.

 

  • Le code du front-end peut être téléchargé [ ici ].
  • Une fois l'archive décompactée, un répertoire authdemo est créé.
  • Aller dans ce répertoire, et exécuter : npm install
  • Une fois l'installation terminée : npm run serve
  • Le serveur de développement est en https, donc il faut utiliser https://localhost:8080 dans le navigateur. Lors du premier accès, cela produit une erreur car le navigateur ne reconnaît pas le certificat du serveur de développement. Il faut donc lui "forcer la main" et accepter le certificat.

Truc utile :

  • Pour que le front-end puisse accéder au back-end, il faut lui donner l'URL d'accès.
  • En général, lors du développement, on utilise un back-end également de développement, qui n'est pas forcément celui de production.
  • Pour faciliter le passage de version de développement vers production, il est possible de créer à la racine du projet front-end 2 fichiers définissant des variables : .env.development et .env.production
  • Par exemple, le fichier .env.development de l'archive contient :
VUE_APP_API_ENDPOINT=https://localhost:3334/authapi
  • La variable VUE_APP_API_ENDPOINT permet de donner l'URL du back-end. On met donc une valeur pour la version de développement et on met une autre URL dans le fichier .env.production pour la version de production.
  • En effet, si on lance un serveur de dev. pour le front-end (avec npm run serve), c'est le contenu de .env.development qui est utilisé. Et si on lance la création de la version de production (avec npm run build), c'est le contenu de .env.production qui est utilisé.

 

  • Ces variables peuvent être manipulées dans le code du front-end avec process.env.nom_variable.  (cf. config.js dans l'archive)

1°/ Authentification

A l'heure actuelle, de nombreuses applications web ne gèrent plus elles-mêmes l'authentification de leur utilisateurs. Elles font appel à des web services que proposent bon nombres d'entreprises, notamment google, amazon, microsoft, etc. Ce mécanisme n'est cependant pas applicable lorsque les accès aux parties privées nécessitent des informations qui ne sont pas disponibles dans un compte de type google/microsoft. C'est souvent le cas avec les applications internes à une entreprise. Dans ce cas, il faut utiliser les bases de données utilisateurs de l'entreprise. NB: ces BdD peuvent malgré tout être externalisées dans un cloud et accessibles via des web services, mais est-ce judicieux ?

Pour cela, soit l'application web fait elle-même l'authentification en accédant directement à la base données, soit elle fait appel à un serveur d'authentification interne, par exemple un serveur CAS (comme à l'université). Comme on est dans le cadre d'une application web, s'exécutant au sein d'un navigateur, l'authentification se fait à priori sur la base de requête http. C'est pourquoi les principes d'authentification restent généralement les mêmes :

  • une page permet de saisir un login +  mot de passe.
  • le couple est envoyé au serveur via une requête POST, et en principe une connexion cryptée, afin que le couple ne soit pas lisible directement en cas d'interception.
  • le serveur vérifie (soit lui-même, soit auprès d'un autre serveur d'authentification) que le couple est valide.
  • si c'est le cas, le serveur renvoie au client une (ou plusieurs) information lui signifiant qu'il est bien logué. Sinon, il renvoie une erreur.

Historiquement, le contenu de ces informations a été très divers, même si le principe restait le même : utiliser une ou plusieurs valeurs créés par le serveur, que le navigateur va renvoyer à chaque requête. Le serveur peut ainsi vérifier si la requête doit être bloquée car le navigateur n'envoie pas des valeurs valides. L'un des premiers type de valeur utilisé dans les sites en php a été un identifiant de session, souvent sous la forme d'une chaîne de caractères de type uuid. Cet identifiant était soit renvoyé "en clair" dans l'URL des requêtes suivantes, soit dans le corps de la requête, soit sous forme de cookie. A l'heure actuelle, on utilise plutôt les Json Web Token, dont le contenu est crypté donc non lisible directement côté navigateur.

En revanche, la façon dont le serveur renvoie renvoie les informations a pris essentiellement 2 formes :

  • cookie (via l'entête de réponse Set-cookie)
  • données au format xml/json, contenu dans le corps de la réponse à la requête http d'authentification.

L'avantage des cookies est qu'ils sont automatiquement renvoyés par le navigateur lors des requêtes suivantes au serveur. Cependant, il faut prendre garde à la sécuriser si l'on ne veut pas qu'ils soient accessibles et piratables, via du code javascript. Si on reçoit du xml/json, il faut stocker les informations en mémoire, ce qui signifie qu'elles sont forcément accessibles via du javascript. Il faut également penser à renvoyer ces informations lors de chacune des requêtes suivantes. En apparence, la solution des cookies semble plus simple et efficace mais on va voir que la meilleure solution consiste a mixer les 2 moyens.

 

2°/ Les JSON Wen Token (JWT)

2.1°/ principe

Un JWT est simplement un objet JSON contenant une partie donnée et une partie indiquant la validité de cette donnée. La donnée est cryptée grâce à une clé symétrique qui n'est connue que par l'émetteur du JWT, donc généralement le serveur.

Les données stockées dans le JWT peuvent être aussi diverses qu'un identifiant de session, un nom d'utilisateur, des informations liées à l'accès à l'application web, etc. C'est le serveur qui décide de ce qu'il y a à l'intérieur et le navigateur ne peut en principe pas décrypter ses informations. Si le JWT est modifié d'une quelconque façon, par exemple par un pirate qui aurait réussi à se le procurer, les informations stockées dedans restent normalement inaccessibles et toute tentative de modification du JWT provoquera une erreur au niveau du serveur qui, à priori, n'arrivera pas à le décrypter correctement.

2.2°/ failles classiques

En revanche, le simple fait de récupérer le JWT peut donner au pirate l'occasion d'usurper l’identité du propriétaire légitime, selon la façon dont le serveur est implémenté. En effet, s'il suffit d'envoyer un JWT en cours de validité pour accéder aux pages privées d'un site, alors en voler un autorise un piratage facile.

2.2.1°/ faille XSS

Une des façons les plus courantes de récupérer un JWT est d'explorer l'un des espaces de stockage locaux du navigateur. Par exemple, l'espace local représenté par la variable localStorage en javascript, permet de stocker des clés, associées à des valeurs. On peut donc stocker un JWT en l'associant à un nom.

Malheureusement, n'importe quel code javascript s'exécutant au sein du navigateur permet d'accéder au localStorage et donc récupérer les données liées à une clé, pourvu que l'on connaisse le nom de la clé. Pour trouver ce dernier, il suffit d'enregistrer le code javascript de l'application web et de faire une recherche sur localStorage.

Pour exécuter un code JS accédant au localStorage, il suffit de faire un peu de "social engineering" et persuader un utilisateur de l'application de cliquer sur un lien vers un site, ce dernier envoyant au navigateur une page avec du JS accédant au localStorage, pour l'envoyer au pirate, donc avec le JWT dedans. Le pirate peut donc se faire passer pour l'utilisateur. Ce type de faille est nommée XSS = Cross Site Scripting car un utilise un site fournissant un script JS permettant d'attaquer un autre site écrit en JS.

Il est également possible que l'application contienne une faille permettant au pirate d'enregistrer des données contenant du JS, dans les Bdd utilisées par le site. Quand un utilisateur demande une page qui affiche ces données, le navigateur exécutera le code JS, qui accédera au localStorage pour envoyer le JWT de l'utilisateur. Ce type de faille utilise le principe de " l'injection de code" puis le XSS.

2.2.2°/ faille XSRF

XSRF = Cross Site Request Forgery.

Cette faille est utilisable lorsque le JWT est stocké dans un cookie, au lieu du localStorage. La première précaution à prendre est de rendre les cookies non accessibles via du JS. Cela se fait dans le code du serveur qui envoie le cookie (cf. attribut httpOnly). Si cette précaution n'est pas prise, alors un pirate peut utiliser directement une faille de type XSS pour récupérer le cookie.

Sinon, il faut également que le navigateur accepte de stocker ce cookie. En effet, si un navigateur visite un site A, qui fait appel à une API B avec un nom de domaine différent, et que B envoie un cookie au navigateur, ce dernier refuse par défaut le cookie. Il faut que B configure l'attribut sameSite du cookie afin de forcer la main au navigateur. Ensuite, chaque fois que le navigateur fera une requête HTTP vers B, le cookie sera automatiquement renvoyé.

C'est justement ce renvoi automatique (nécessaire si l'API utilise un cookie JWT pour authentifier les requêtes) qui peut être exploité par un pirate :

  • un utilisateur consulte un serveur web A, qui lui envoie une application vuejs.
  • Cette application tire ses données d'un autre serveur B, auprès duquel il faut d'abord faire une requête d'authentification. Dans ce cas, B envoie un cookie avec le JWT dedans.
  • l'utilisateur est encouragé à accéder à site pirate C.
  • C envoie au navigateur une page contenant du JS et qui fait un requête vers B, avec bien entendu des données à même d'endommager ou corrompre B.
  • le code JS est exécuté par le navigateur et comme la requête va vers B, le cookie contenant le JWT est automatiquement envoyé.
  • B reçoit la requête et comme le JWT est valable, si B est mal protégé/implémenté, il va stocker les données qui vont le faire planter, ou bien introduire une faille XSS.

2.3°/ principes pour limiter les XSS + XSRF

Il n'existe malheureusement pas de méthode infaillible mais on peut remarquer que les deux solutions (localStorage et cookie) ne sont pas sensibles au même type de faille. Une solution à peu près sécurisée consiste donc à mixer les deux principes :

  • un token dit xsrf est généré aléatoirement sous forme d'un hashtag assez long, et transmis au navigateur qui le stocke dans le localStorage.
  • un jwt est généré, avec comme données un identifiant de session/utilisateur + le token xsrf
  • le jwt est transmis au navigateur sous la forme d'un cookie, sans oublier de fixer ses champs httpOnly (afin d'éviter qu'il soit lisible grâce à du code JS), securesigned à true, et sameSite à none (pour que le navigateur accepte de le stocker)

Quand le navigateur envoie une requête au serveur :

  • le cookie contenant le jwt est automatiquement transmis,
  • le token xsrf est récupéré dans le localStorage et est ajouté explicitement (via du code JS) aux entêtes (= headers) de la requête.

Quand le serveur reçoit une requête :

  • il récupère le jwt, le décrypte et récupère le token xsrf contenu dedans,
  • il récupère dans les entêtes le token xsrf
  • il compare les 2 tokens xsrf ainsi obtenus et s'il constate une différence, renvoie une erreur au navigateur,
  • sinon, il récupère le reste des informations dans le jwt, vérifie si elles sont valides et si ce n'est pas le cas, renvoie une erreur au navigateur.

 

ATTENTION : Il faut bien comprendre que cette méthode n'est pas infaillible puisqu'il est toujours possible via une faille XSS de récupérer le token xsrf de l'utilisateur, puis d'utiliser une faille XSRF. Cependant, cela limite fortement la probabilité qu'une telle attaque réussisse.

 

2.4°/ Rafraîchir un JWT : les pours et les contres

Pour limiter les possibilités d'attaque, on lit fréquemment qu'il convient surtout de limiter le temps de validité d'un JWT. C'est très facile quand le JWT est dans un cookie puisque la durée de vie de ces derniers est fixée par le serveur qui envoie le cookie. Par exemple, si un JWT est valide pendant seulement une minute, un pirate n'a quasi aucune chance de récupérer le token xsrf via une faille XSS pour ensuite lancer une attaque type XSRF. 

Cependant, cela impliquerait de se reloguer toutes les minutes, ce qui est impensable. De ce fait, on doit mettre en place un mécanisme permettant de "rafraîchir" le JWT :

  • Quand l'utilisateur se logue, il reçoit un JWT sous forme de cookie, un token xsrf ET un token de rafraîchissement qui sont stockés dans le localStorage ou en mémoire dans les données de l'application (par ex, le store)
  • Par la suite, si le serveur reçoit une requête avec seulement le token xsrf, cela veut dire que le cookie n'est plus valide, et il rejette la requête en renvoyant un erreur identifiable par l'application cliente.
  • Quand elle reçoit une telle erreur, elle envoie une requête avec le token de rafraîchissement et le serveur renvoie un cookie JWT.

A noter que le token de rafraîchissement a également une validité, généralement assez longue (plusieurs minutes/heures) par rapport à celle du JWT.

Malheureusement, si les token xsrf et rafraîchissement sont stockés dans le localStorage, une faille XSS permet à un pirate d'accéder au contenu du localStorage. Il peut donc demander lui-même un rafraîchissement et obtenir un JWT valide. On aboutit donc à une situation presque pire puisque le pirate peut se faire passer pour l'utilisateur, comme avec une faille XSS. Même si on stocke le token de rafraîchissement dans le store, un pirate chevronné analysera le code JS de l'application et réussira à accéder à la variable contenant le token.

Heureusement, il existe des solutions pour compliquer la tâche aux pirates. Par exemple :

  • le serveur vérifie que la demande de rafraîchissement vient bien de la même IP que celle utilisée lors du login (attention, l'usurpation d'IP n'est pas très compliquée)
  • à chaque rafraîchissement, le hashtag du token est régénéré et renvoyé au client. Si le rafraîchissement a lieu toutes les minutes, le pirate a donc une minute pour récupérer le token et l'exploiter (attention, c'est faisable).
  • stocker le token dans un cookie comme le JWT avec une validité modérée (par ex, 1h). Même s'il est renvoyé à chaque requête, il ne sera réellement utilisé que lorsque l'utilisateur fait explicitement une requête de rafraîchissement. Si un pirate a déjà réussi à subtiliser le token xsrf, il n'aura pas le cookie de rafraîchissement et donc sera incapable de récupérer un JWT valide.

 

2.5°/ Encore plus de sécurité ?

Enfin, il est également possible d'ajouter d'autres précautions, plus exotiques :

  • stocker le token xsrf dans sessionStorage : dès que la navigateur est fermé, sessionStorage est supprimé, ce qui implique que l'utilisateur doit de nouveau se loguer.
  • générer le nom de la clé associée au xsrf token au niveau du serveur pour l'envoyer au navigateur : le pirate qui veut récupérer le xsrf token ne peut pas connaître à l'avance le nom de la clé et doit donc faire une analyse complète du code JS.

 

3°/ mise en place avec vuejs.

On suppose que le serveur est une API de type REST, avec une route /login. La méthode POST est utilisée pour envoyé un objet json contenant le login et le mot de passe fournis par l'utilisateur.

3.1°/ contraintes liées à l'utilisation des API

Les navigateurs modernes sont très restrictifs avec les requêtes asynchrones. En effet, quand les pages actuellement consultées dans le navigateur proviennent d'un site A, et qu'une de ces pages fait une requête asynchrone vers un site B, le navigateur peut refuser d'en charger le contenu. C'est le mécanisme CORS (Cross Origin Resource Sharing) intégré au navigateur qui va autoriser ou non le contenu en fonction des entêtes fournies par B. Attention : une simple différence de n° de port est significative pour CORS. Par exemple, quand on test en local un site A fourni par localhost:8080 et que l'API REST est accessible par localhost:4567, alors CORS considère que ce sont 2 origines différentes.

Par défaut, si le site B ne fait rien pour envoyer les bonnes entêtes dans les réponses aux requêtes du client, le navigateur bloquera le contenu, y compris les cookies qui ne seront pas sauvegardés dans la mémoire du navigateur.

Par exemple, dans un serveur écrit en node + express, on peut utiliser le module cors afin de positionner correctement les entêtes :

const express = require('express');
const cors = require("cors");

const app = express();
var corsOptions = {
  origin: [ /.*$/ ], // ATTENTION : toute origine est acceptée = trou de sécurité potentiel
  credentials: true,
  allowedHeaders: "x-xsrf-token, Origin, Content-Type, Accept",
};
app.use(cors(corsOptions));
...

 

Explications :

  • origin permet de sélectionner quels sont les origines acceptées pour que CORS ne bloque par la réponse. Dans le cas présent, une requête asynchrone venant de n'importe quelle origine sera valide. Le serveur traitera la requête, renverra le résultat avec une entête signalant au navigateur que ce résultat est valide.
  • credentials positionné à true permet au serveur d'indiquer au navigateur que les cookies envoyés sont valides et doivent être stockés.
  • allowedHeaders permet au serveur de spécifier quelles entêtes il accepte. Dans le cas présent, x-xsrf-token est l'entête que le navigateur utilisera pour envoyer le token xsrf. Ce n'est pas une entête définie par HTTP. On peut donc utiliser n'importe quel autre nom.

 

3.2°/ l'application SPA

Le code de démonstration n'utilise pas toutes les astuces destinées à compliquer la tâche à un pirate. Pour résumer, il utilise :

  • un cookie pour le JWT
  • le localStorage pour stocker les tokens xsrf et de rafraîchissement.

 

3.2.1°/ configuration de vue

Afin de faciliter le passage d'une version de développement à une version de production, il est utile de définir deux fichiers donnant l'URL de l'API, en mode dev. ou bien prod. Ces 2 fichiers sont à mettre à la racine du projet. Exemple :

fichier .env.development

VUE_APP_API_ENDPOINT=https://localhost:4567/authapi

 

fichier .env.production

VUE_APP_API_ENDPOINT=https://myapi.domain.fr:443/authapi

 

La variable VUE_APP_API_ENDPOINT peut ensuite être utilisée dans le reste du code, grâce à process.env.

Pour simplifier encore, il est possible de créer un fichier général de configuration de l'application que l'on importe dans la plupart des autres fichiers. Par exemple :

fichier config.js

export default {
  urlAPI: process.env.VUE_APP_API_ENDPOINT
}

 

On peut ensuite utiliser cette variable comme base pour les requêtes d'axios. Par exemple, dans le fichier axios.service.js :

import axios from 'axios'
import Config from '../commons/config'
...
// create a special axiosAgent agent that works with the apidemo API
const axiosAgent = axios.create({
    baseURL: Config.urlAPI,
    withCredentials : true,
});

 

3.2.2°/ les services

Le plus simple et fonctionnel consiste à créer différents fichiers représentant les "services" fonctionnels. Par exemple :

  • axios.service.js : contient le code qui va créer l'agent axios comme dans les TDs précédent, et qui utilise un intercepteur pour envoyer le token xsrf dans toutes les requêtes sous forme d'une entête. Le fichier contient également les fonctions permettant de faire des requêtes get, post, patch, etc, ainsi que la fonction qui gère les erreurs. Toutes ces fonctions sont modifiées plus ou moins, pour permettre de gérer le rafraîchissement du jwt.
  • auth.service.js : contient le code qui va faire la requête axios pour se loguer et pour rafraichir le jwt.
  • user.service.js : contient le code permettant d'enregistrer un nouvel utilisateur, ou bien d'obtenir ses informations.

Comme dit précédemment, le jwt étant stocké dans un cookie, il sera automatiquement envoyé à chaque requête, pourvu que le navigateur soit configuré pour cela. Par défaut, les cookies ne seront pas envoyés à des sites d'une autre origine. Lors de l'envoi d'un requête il faut donc dire explicitement de les envoyer. On peut configurer l'agent axios pour que ce soit fait automatiquement. Par exemple, dans axios.service.js, on écrit :

import axios from 'axios'
import https from 'https'  // pour faire des requêtes en https.

const myaxios = axios.create({
    httpsAgent: new https.Agent({rejectUnauthorized: false}),
    withCredentials: true, // envoi auto. des cookies à d'autres origines
});

 

Quant au token xsrf, comme il est possible de créer artificiellement des entêtes aux requêtes HTTP, il suffit d'en ajouter une dont la valeur est le token xsrf, et d'envoyer cette entête à chaque requête axios.

Pour cela, le plus simple est d'utiliser les "intercepteurs" axios. Ils permettent de définir des fonctions qui seront appelées automatiquement dans certaines situations, notamment quand on veut envoyer une requête. Par exemple :

 

myaxios.interceptors.request.use(
    config => {
        return {
            ...config,
            headers: {
                ...AuthService.authHeader()
            },
        };
    },
    error => Promise.reject(error)
);

 La méthode authHeader() renvoie simplement un objet du style { x-xsrf-token : "..." }, où la valeur provient du localStorage, si elle existe. Sinon, on ne renvoie rien.

Grâce à cet intercepteur, on ajoute l'entête x-xsrf-token à toutes les requêtes vers le serveur, à partir du moment où l'authentification a été réussie et que l'on a reçu une valeur pour le token xsrf.

 

3.2.3°/ le store

Le store vuex va permettre de conserver les informations de connexion, notamment les token xsrf, et si l'utilisateur est logué ou non. Cette information est généralement essentielle pour faire de l'affichage conditionnel de contenu.

Pour cela, il convient de créer un store modulaire, avec un module pour gérer l'authentification, et d'autres pour gérer les données applicatives. Pour l'authentification, on peut créer un fichier auth.js, avec :

  • un state contenant l'état d'authentification (logué ou non, token xsrf, ...)
  • 3 mutations pour mettre à jour cet état : log réussi, log raté, logout. 
  • 2 actions :
    • login, qui appelle le service de login puis selon le résultat la mutation log réussi ou log raté
    • logout, qui appelle la mutation logout.

3.2.4°/ Le router

Les routes du front-end sont définies comme dans le TD 1, avec notamment un mécanisme qui empêche de suivre des routes qui nécessitent une authentification réussie.

 

3.2.5°/ Le composant d'enregistrement

Ce composant fournit une interface permettant de créer un nouvel utilisateur. Il sert uniquement à montrer comment utiliser l'API recaptcha fournie par google, au travers d'un module npm nommé vue-recaptcha.

Pour résumer, l'utilisation de ce module nécessite de créer une paire de clé sur le site de google qui gère recaptcha : https://www.google.com/recaptcha/

Avec un compte google, il est possible de créer cette paire de clé. L'une de ces clés doit être utilisée dans le front-end et l'autre dans le back-end. Il n'y a effectivement pas de possibilité de faire avec uniquement le front. En pratique, quand on valide le captcha, il faut ensuite envoyer au back-end le résultat de cette validation. Ce dernier fait ensuite appel à l'API google recaptcha pour vérifier si la validation est correcte ou non.

Dans le cas présent, le formulaire d'enregistrement fait appel à une route du back-end, en envoyant un login, mot de passe et nom de héro, plus de résultat de la validation du captcha. Ensuite, le back-end interroge l'API google et si tout est bon, il peut ensuite vérifier les informations fournies avant de créer le nouvel utilisateur.

 

3.3°/ Démonstration

NB : l'API est réglée pour que le JWT expire au bout de 30 secondes.

  • Utiliser le menu tirroir pour essayer d'afficher le profil utilisateur courant : la route est ne peut pas être suivie car il faut au préalable être logué.
  • Utiliser le bouton login pour afficher le composant d'authentification : login = toto, mot de passe = azer. Après cela, le profil peut être affiché.
  • De même pour obtenir la liste de tous les utilisateurs.

Remarque :

  • La console affiche un warning, à propos d'un champ login qui ne peut être trouvé. C'est un problème classique lorsqu'un composant fait appel à un service de récupération des données asynchrone lorsqu'il est monté.
  • Dans le cas présent, UserEdit définit sa fonction mounted() pour aller chercher sur l'API les informations de l'utilisateur actuellement logué.
  • Or, cette requête permet de mettre à jour un objet du store qui à l'origine vaut null et qui après la requête, contient entre autre le login de l'utilisateur.
  • Quand UserEdit est monté, il accède à cette variable, qui vaut null, d'où le warning.
  • Dès que la requête est complétée, la valeur de l'objet dans le store change, donc vuejs rafraîchit tous les composants affichant des champs de cet objet.
  • Pour éviter ce warning, il faudrait écrire le composant en tenant compte du fait que l'objet dans le store vaut null, par exemple en modifiant la première balise comme suivant : <v-card v-if="user !== null">

 

  • Utiliser le menu pour revenir à l'écran d'accueil.
  • Attendre 40 secondes, puis réafficher le profil : l'application affiche un dialogue d'erreur et revient au composant de login.
  • Pour ces différentes requêtes, l'onglet réseau de l'inspecteur permet de voir le cookie JWT ainsi que les entêtes contenant les token xsrf.

 

 4°/ Bonus : installer le back-end.

Le back-end repose sur une BdD mongodb v5. Il faut donc installer ce sgbd en premier lieu.

  • Le code du back-end peut être téléchargé [ ici ].
  • Une fois l'archive décompactée, un répertoire authapi est créé.
  • Aller dans ce répertoire, et exécuter : npm install
  • Une fois l'installation terminée : npm run serve