Ce TP sera l'occasion de comprendre le fonctionnement de l'authentification à l'aide de JWT avec rafraîchissement et leur mise en place dans une application Web.
Nous profiterons de vos nouvelles connaissances des services workers, pour encapsuler la gestion des tockens dans un service worker afin de simplifier leur gestion et sécurisé l'application.
Vous en profiterez pour approfondir votre connaissance de Redux Toolkit et plus particulièrement découvrir son module Query.
L'API Tasks servira de support pour ce TP.
Les notes des TP seront obtenues à partir de vos dépôts Git, vous devrez donc prendre grand soin de la qualité de ces dépôts et de leurs « commits ».
De manière similaire, les descriptions de vos « commits » devront être claires et informatives, afin de permettre l'évaluation de la progression de votre travail.
Vous allez poursuivre sur la base de code du TP 2 : Ajout de notifications
L'application que vous allez réaliser ne fournie des fonctionnalités qu'aux utilisateurs authentifiés. Nous allons donc mettre en place la sécurité afin de nous assurer que l'utilisateur est authentifié.
Vous commencerez par découvrir le module RTK Query de Redux Toolkit.
Puis vous mettrez en place une nouvelle tranche de données pour l'API de gestion de tâches, que vous pourrez ajouter à votre store global.
Vous définirez ensuite un nouvel « endpoint » permettant d'obtenir les données de l'utilisateur authentifié auprès de l'API.
Vous devriez maintenant être capable d'utiliser le hook créé par RTK Query pour invoquer l'« endpoint » depuis le composant Main
et ainsi vous assurez de l'identité de l'utilisateur.
Pour l'instant, vous vérifierez dans la console de développement du navigateur que le résultat de la requête est bien une erreur HTTP 401 Unauthorized
. En effet, puisque vous ne transmettez pas de JWT, l'API ne peut pas vous identifier.
Main
.
Vous disposez maintenant de l'information si l'utilisateur est authentifié ou non. Vous allez ajouter un composant permettant à l'utilisateur de s'authentifier, LoginForm
par exemple. Et vous l'afficherez dans le composant Main
en remplacement de son contenu lorsque le « endpoint » de l'utilisateur retourne une erreur.
Le composant LoginForm
affichera un formulaire de connexion, avec deux champs : le login et le mot de passe. Ainsi qu'une case à cochée permettant à l'utilisateur de préciser s'il souhaite rester connecté. Et enfin deux liens permettant à l'utilisateur de s'inscrire ou de réinitialiser son mot de passe.
Maintenant, votre application devrait proposer à l'utilisateur de s'authentifier à l'aide de votre formulaire.
Vous ajouterez ensuite un nouvel « endpoint » permettant d'authentifier un utilisateur auprès de l'API et d'obtenir les jetons d'authentifications (https://iut-rcc-infoapi.univ-reims.fr/tasks/api/auth).
Dans le composant d'authentification, vous ferez en sorte d'obtenir le couple login/mot de passe saisie par l'utilisateur dans une fonction invoquée lors du clic sur le bouton de validation du formulaire. Une solution simple de gestion des composants non-contrôlée est d'utiliser l'événement de soumission pour accéder au formulaire et à ses champs. Vous pourrez ensuite invoquer le « endpoint » d'authentification avec les informations requises.
Vous pourrez contrôler que la requête est bien réalisée lors de la soumission du formulaire et que la réponse contient bien les tokens d'authentification.
Main
.
Nous pourrions stocker les jetons d'authentification dans un magasin de données, nous pourrions même configurer RTK pour qu'il injecte automatiquement le JWT lors des requêtes vers l'API. Mais fort de vos compétences en PWA, vous allez implanter une solution reposant sur un « service worker ».
Cette solution permet, entre autre, de décorréler la gestion des jetons de l'application et donc de simplifier la base de code.
Vous commencerez donc par ajouter un service worker dans le répertoire public
qui ne contiendra pour l'instant qu'un message à afficher dans la console de développement.
Vous créerez ensuite un hook personnalisé permettant d'enregistrer un service worker dans l'application. Ce hook recevra le chemin vers le >service worker en paramètre.
Pour que votre service worker puisse informer l'utilisateur à l'aide de notification, vous ajouterez une fonction onNotification
comme paramètre du hook d'enregistrement du >service worker qui sera invoquée lorsque le service worker devra émettre une notification.
Vous l'utiliserez ensuite pour informer l'utilisateur de l'état du cycle de vie du >service worker.
Vous devriez constater, que le mode strict de React duplique les notifications d'initialisation de votre service worker. Ce comportement n'est pas grave et c'est grâce à lui que React peut réaliser différents contrôles sur votre code. Plus inquiétant, le rafraîchissement des composants et le hot reload du serveur de développement illustre que votre service worker peut être réinscrit au cours de l'utilisation de l'application.
Pour résoudre ce problème, vous allez vous assurer que votre service worker ne sera enregistré qu'une seule fois à l'aide des hooks useEffect
et useState
.
Pour vous assurer que le service worker soit bien enregistrer avant d'afficher le reste de l'application, le hook d'inscription du service worker retournera un booléen identifiant si service worker est bien enregistré. Et le composant Main
, n'affichera rien tant que cette valeur ne passera pas à vraie.
Main
.
Dans votre service worker, vous allez pouvoir créer deux variables qui contiendront vos jetons d'authentification.
Toujours dans votre service worker, vous ajouterez un gestionnaire d'événement sur la requête d'authentification, qui interceptera la réponse pour stocker les jetons d'authentification dans les variables que vous venez de définir.
Un avantage à l'utilisation du service worker pour gérer les jetons d'authentification, est la sécurité. En effet, le code du service worker est executé dans un thread indépendant du thread principal, et l'application n'a donc pas accès aux variables du service worker.
Pour vraiment empêcher l'application d'avoir accès aux jetons d'authentification, vous ferez en sorte que la réponse positive à la requête d'authentification ne contiennent plus les jetons d'authentification.
Afin de permettre de réaliser des notifications depuis le service worker vers l'application, vous enregistrerez un gestionnaire d'événement sur l'événement message
du ServiceWorkerContainer
dans votre hook d'enregistrement du service worker. Ce gestionnaire d'événements produira une notification lors de la reception d'un message du service worker avec comme donnée un propriété type
valant "notification"
. Les données du message pourront aussi contenir le niveau de la notification et le contenu de la notification.
Dans le service worker, lors de la gestion d'un événement de requêtage, vous pourrez émettre un message vers le client à l'aide de la méthode postMessage
. Pour obtenir le client, vous pouvez le retrouver à l'aide de l'identifiant du client clientId
de l'événement et de la liste des clients du context global du service worker. Vous penserez à préciser le type de message, comme étant une notification et vous notifierez l'utilisateur de la réussite ou de l'échec de l'authentification.
Vous avez dù constater que même aprés une authentification réussie, votre application est toujours bloquée sur le formulaire d'authentification. En effet, aucun changement dans la page n'a provoqué de rafraîchissement de l'affichage.
Pour résoudre ce problème, vous allez déclencher automatiquement la requête vers l'utilisateur courant, lors de l'authentification de l'utilisateur.
En vous inspirant de l'exemple sur les multiples requêtes, vous ferez en sorte de modifier le « endpoint » d'authentification afin qu'il provoque automatiquement une requête vers l'utilisateur courant et qu'il retourne le résultat de l'authentification.
Vous pourrez constater dans la console de développement que la requête est bien réalisé, mais comme vous n'avez pas encore géré le JWT, celle-ci échoue.
Dans le service worker, vous intercepterez les requêtes vers les routes de l'API autre que celle d'authentification. Vous pourrez, sans faire la requête, retourner une erreur 401
lorsque le JWT n'est pas initialisé et sinon injecter le JWT automatiquement dans les en-têtes de la requête.
Votre utilisateur devrez maintenant pouvoir se connecter à votre application. Et pour vous développeur, vous n'avez pas à gérer le JWT dans vos requêtes dans l'application.
Vous constaterez dans la console de développement que la requête vers les données de l'utilisateur n'est réalisé qu'une fois, à la suite de l'authentification. En effet, le résultat des requêtes de type « query » est automatiquement mis en cache par RTK et sera fournie directement lors des requêtes suivantes, dans notre cas dans le composant Main
.
Pour visualiser l'état de l'authentification, vous allez ajouter un nouveau « endpoint » permettant d'obtenir une collection des listes de tâches de l'utilisateur (/api/me/task_lists) que vous utiliserez pour faire des requêtes.
Dans le composant Home, vous afficherez le nombre de listes de tâches de l'utilisateur avec un bouton permettant d'actualiser la collection des listes de tâches de l'utilisateur à l'aide de la fonction refetch
du résultat d'un hook de requête.
Si vous êtes suffisamment rapide aprés votre authentification, vous devriez constater dans la console de développement qu'une requête est bien générée lors de chaque clic de souris, rafraîchissant le cache de RTK. Cependant, si vous n'êtes pas suffisamment rapide, vous devriez constater que la requête est bien émise, mais qu'elle obtient un code HTTP 401
.
En effet, le JWT est un jeton représentant l'utilisateur de manière autosuffisante. C'est-à-dire qu'une fois qu'il est émis, il n'est plus possible de le révoquer autrement qu'en attendant qu'il devienne périmé. Il est donc courant de le configuré avec une durée de vie relativement courte. Ici, sa durée de vie est par défaut de 10 minutes, mais pour simplifier le développement, vous avez la possibilité de changer cette durée à l'aide du paramètre de requête ttl
.
Avant de corriger ce problème, vous constaterez que l'application n'a pas détectée que l'utilisateur était déconnecté, elle devrait proposer à l'utilisateur de se reconnecter lorsqu'une requête vers l'API retourne un code HTTP 401
.
Dans notre cas, nous utilisons le résultat, mis en cache, de la requête de l'utilisateur courant pour détecter si l'utilisateur est authentifié. Il faudrait forcer le rafraîchissement de la requête en cas d'erreur HTTP 401
, mais pas dans le cas de la première requête qui provoquerez une boucle infinie.
Il s'agit en fait de la consequence d'une conception simpliste, pour résoudre ce problème simplement, vous allez ajouter une nouvelle tranche de donnée pour l'authentification qui ne comportera qu'une valeur booléenne, fausse par défaut, identifiant si l'utilisateur est connecté. Vous lui ajouterez une action permettant d'initialiser cette valeur.
Vous remplacerez l'utilisation de la requête vers l'utilisateur courant par cette valeur pour identifier si l'utilisateur est connecté dans le composant Main
.
Maintenant, comme c'est votre service worker qui centralise les interactions avec l'API, c'est lui qui est le plus à même de disposer de l'information de connexion. Ces informations intéressent tous les clients du service worker, donc lors de l'enregistrement des jetons d'authentification, vous enverrez un message de type "authentication"
à tous les clients.
Le hook personnalisé d'enregistrement du service worker acceptera en paramètre une fonction onAuthentication
qui initialisera la variable d'authentification à vrai. Cette fonction sera invoquée pour chaque message du service worker de type "authentication"
.
Sur le même principe, vous ajouterez la gestion de la déconnexion lorsque le jeton d'authentification est périmé.
Votre application devrait maintenant proposer à l'utilisateur de se reconnecter à la première requête non-authentifiée.
Home
.
Main
pour utilisé la tranche de données précédente.
Vous allez maintenant régler le problème de la durée du jeton d'authentification.
Pour cela, lors de l'authentification, conjointement au jeton d'authentification, vous recevez aussi un jeton de rafraîchissement. Ce jeton peut être utilisé pour authentifier l'utilisateur pour obtenir de nouveaux jetons d'authentification/rafraîchissement, ce jeton à aussi une durée de vie, mais à la différence d'un JWT, il n'est pas autosuffisant, il est associé à l'utilisateur en base de données. Il est donc possible de le révoquer, voir de configurer le serveur pour qu'un seul jeton de rafraîchissement soit actif à la fois.
La durée de vie du jeton de rafraîchissement peut donc être plus longue que celle du JWT, et il est donc possible de rafraîchir automatiquement le jeton d'authentification lorsqu'il est périmé sans action de l'utilisateur.
Évidemment, une période de vie très courte du JWT entraîne un plus gros trafique de rafraîchissement, il peut donc être intéressant de prendre le temps de configurer les durées de vie de vos jetons.
Pour notre problème, vous commencerez par détecter les requêtes vers l'API retournant un code HTTP 401
. Dans ce cas, si vous disposez d'un jeton de rafraîchissement, vous réaliserez une requête de rafraîchissement des jetons d'authentification. En cas de succès, vous mettrez à jour les jetons et vous pourrez refaire la requête initiale vers l'API. Et en cas d'échec, vous initialiserez les jetons à null
.
Pour visualiser plus facilement le rafraîchissement des jetons, vous ferez en sorte que le services worker émette une notification lors du rafraîchissement des jetons.
Vous allez ajouter un bouton de gestion de l'utilisateur UserButton
, permettant d'indiquer à l'utilisateur qu'il est authentifié. Le composant n'affichera rien tant que l'application ne dispose pas des données de l'utilisateur et lorsque les données sont disponibles, dans un premier temps, il se contentera d'afficher un bouton avec l'image de l'avatar de l'utilisateur.
Vous devriez vite constater que l'image de l'avatar ne fonctionne pas, en effet, si vous vérifiez dans l'onglet réseau de la console de développement, la requête devrait obtenir un code de retour HTTP 401
, bien que la requête vers les données de l'utilisateur fonctionne.
Cette erreur provient du mode de fonctionnement des navigateurs, qui réalise les requêtes pour obtenir les images en mode « no-cors ». Ce type de requête n'est pas sujet aux CORS, ce qui simplifie les requêtes vers des serveurs tiers. Mais entre autres choses, elles n'autorisent pas la modification des en-têtes, dans notre cas, l'ajout de l'authentification.
Pour résoudre ce problème, dans votre services worker, vous modifierez les options des requêtes vers l'API pour les passées en mode « cors » avec les « credentials » pour la même origine uniquement (« same-origin »).
Dans le cadre du développement, il est probable que vous utilisiez l'option de rechargement automatique du services worker lors du rechargement de la page, fournit par les outils de développement de votre navigateur dans l'onglet « Application ».
Cette option recharge le services worker lors de chaque rechargement de la page. Sans cette option, dans le mode de fonctionnement classique du services worker, celui-ci n'est rechargé que lorsqu'il d'une mise à jour.
Si vous désactivez l'option, vous devriez constater que le rafraîchissement de la page lorsque l'utilisateur est connecté propose à l'utilisateur de se connecter bien que la requête des données de l'utilisateur soit valide. C'est parce que votre utilisateur est non connecté par défaut dans votre application, mais que votre services worker qui n'a pas été rechargé dispose toujours des jetons d'authentification.
Pour résoudre ce problème, vous allez associer le succès de la requête vers les données de l'utilisateur avec l'authentification de l'utilisateur dans la page en ajoutant un « reducer » supplémentaire.
UserButton
.
Vous allez permettre à l'utilisateur de se déconnecter de l'application. Lors de l'utilisation d'un simple JWT, il suffit de supprimer le jeton pour que l'authentification soit close, mais avec l'usage des jetons de rafraîchissement, une requête doit être envoyée au serveur pour le révoqué et ainsi réduire la fenêtre d'exposition à une faille de sécurité à la durée de vie du JWT.
Vous commencerez par ajouter un nouvel « endpoint » permettant de déconnecter l'utilisateur de l'API (https://iut-rcc-infoapi.univ-reims.fr/tasks/api/logout). Et vous ferez en sorte de pouvoir invoquer ce « endpoint » lors du clic de la souris sur le bouton de l'utilisateur.
Dans l'état, lors du clic de la souris sur le bouton de l'utilisateur, vous révoquez le jeton de rafraîchissement auprès de l'API, mais vous restez authentifié tant que le JWT reste valide. Pour réellement déconnecter l'utilisateur, vous allez intercepter la requête de déconnexion dans le services worker, pour émettre deux messages lorsque la requête a été fructueuse : un message de déconnexion et un message de notification de déconnexion.
Vous constaterez que l'utilisateur est bien déconnecté, mais que le bouton de l'utilisateur reste visible (si vous avez utilisé les informations de l'utilisateur connecté comme critère d'affichage). En effet, bien que votre utilisateur soit déconnecté, le cache de RTK Query comporte toujours les données de l'utilisateur connécté. Pour faire simple, vous allez invalider automatiquement l'ensemble du cache de RTK Query lors des requêtes de déconnexion, de manière similaire à l'obtention automatique des données de l'utilisateur lors de la connexion.
La gestion des jetons d'authentification est presque terminée, mais nous avons laissé de côté la gestion de la case à cocher « se souvenir de moi » du formulaire de connexion.
Comme nous souhaitons conserver la gestion des jetons dans le services worker, vous allez devoir transmettre l'information de la case à cocher depuis le formulaire jusqu'au services worker. Plutôt que d'utiliser le système de message que nous avons employé pour les notifications, vous allez transmettre l'information sous forme d'un paramètre d'URL lors de l'authentification.
Dans le composant LoginForm
, lors du déclenchement de l'action réalisant la requête d'authentification, vous ajouterez l'information de la case à cocher.
Dans le « endpoint » d'authentification, vous ajouterez l'information de mémoriser l'utilisateur à la requête sous la forme d'un paramètre d'URL.
Vous devriez maintenant être capable d'intercepter cette valeur dans votre services worker. Mais avant de vous en préoccuper, nous allons contourner un leger problème, nous n'avons pas accès au localStorage
depuis le services worker. En effet, l'objectif est de pouvoir stoquer le jeton de rafraîchissement de manière persistante entre différentes séances de navigation et c'est normalement le role du localStorage
.
Pour contourner ce problème, vous allez utiliser le cache du services worker. Le principe sera de créer un cache spécifique à l'authentification et d'y stocker un document JSON virtuel contenant un object qui recevra les données à stocker. Pour faciliter l'interaction avec le cache, vous créerez 3 fonctions permettant d'intéragir avec ces données :
sync function getCachedItem(itemName)
retournera la valeur de la propriété itemName
de l'objet stocké dans le JSON.
sync function setCachedItem(itemName, valueName)
ajoutera ou mettra à jour la valeur de a propriété itemName
de l'objet stocké dans le JSON.
sync function removeCachedItem(itemName)
supprimera la propriété itemName
de l'objet stocké dans le JSON.
Vous êtes maintenant prêt à stocker le jeton de rafraîchissement de manière pérsistante. Dans votre code, vous devez stocker le jeton dans 2 cas :
Maintenant que le jeton de rafraîchissement est stocké dans le cache, vous devriez être capable de l'utilisé lors du rafraîchissement des jetons en cas d'erreur HTTP 401 Unauthorized
.
Vous penserez à nettoyer le cache du jeton lors de la déconnexion et/ou des erreurs de requêtes, lors du rafraîchissement des jetons par exemple.