Open menu

Préambule

  • Le code de démonstration repose sur des paquets non standards. C'est pourquoi les archives disponible ci-dessous incluent toute l'arborescence du projet, excepté le répertoire node_modules.

 

  • 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 : node authapi
  • L'Api est lancée en https sur le port 12345, en utilisant des certificats SSL auto-signés. Cela va provoquer une erreur lorsque que le navigateur voudra y accéder une première fois

 

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

 

ATTENTION :

  • L'Api est lancée en https sur le port 12345, en utilisant des certificats SSL auto-signés. Cela va provoquer une erreur lorsque que le navigateur voudra y accéder, à cause des mesures de sécurité par défaut.
  • Pour contourner le problème :
    • ouvrir le navigateur et dans l'URL, taper : https://localhost:12345/authapi
    • Un message d'erreur apparaît.
    • Il y a normalement également un bouton/lien permettant de continuer tout de même vers ce site. Cliquer dessus.
    • L'accès à l'API devrait maintenant être autorisé au niveau du navigateur et l'API devrait renvoyer un JSON du type {error: 1, data: 'route d'accès invalide}

 


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

XSS = Cross Site Scripting

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.
  • séparer le code JS de l'application en morceaux afin qu'un pirate n'ai pas accès à l'entièreté du code dès la page d'accueil. Cela évite par exemple qu'il puisse trouver quelles sont les clés de stockage des différents token.

 

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. C'est le rôle du paramètre de configuration withCredentials. Dans l'exemple juste au-dessus, ce dernier est à true, ce qui signifie que les informations  d'authentification tels que les cookie seront automatiquement transmis.

 

 

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 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, entre autres :

  • un state contenant les informations d'authentification : token xsrf, compte/droit utilisateur, token de rafraîchissement (si mis en place sur l'API), ...
  • 2 actions :
    • login, qui appelle le service de login puis qui stocke le résultat dans le state
    • logout, qui appelle remet à zero le state.

3.2.4°/ Le router

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

3.3°/ Démonstration

NB : l'API est réglée pour que le JWT expire au bout de 20 secondes, et 40 secondes pour le token de rafraîchissement

  • 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 = maddog, mot de passe = azer. Après cela, le profil peut être affiché.
  • En revanche, cet utilisateur n'est pas admin. Il ne peut donc pas obtenir la liste de tous les utilisateurs.
  • Se déloguer, puis loguer avec admin/azer. Après cela, la liste de tous les utilisateurs est accessible. 
  • Utiliser le menu pour revenir à l'écran d'accueil.
  • Attendre 30 secondes, puis réafficher le profil : dans la console, on voit qu'il y a eu une erreur puis un rafraichissement du JWT.
  • Attendre encore 15 secondes. 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.