React Token Auth


Problemm


L'autorisation est l'un des premiers problèmes rencontrés par les développeurs lors du démarrage d'un nouveau projet. Et l'un des types d'autorisation les plus courants (d'après mon expérience) est l'autorisation basée sur les jetons (généralement en utilisant JWT).


De mon point de vue, cet article ressemble à "ce que je voulais lire il y a deux semaines". Mon objectif était d'écrire du code minimaliste et réutilisable avec une interface claire et simple. J'avais les prochaines exigences pour ma mise en œuvre de la gestion d'authentification:


  • Les jetons doivent être stockés dans le stockage local
  • Les jetons doivent être restaurés lors du rechargement de la page
  • Le jeton d'accès doit être transmis dans les requêtes réseau
  • Après l'expiration, le jeton d'accès doit être mis à jour par un jeton d'actualisation si le dernier est présenté
  • Les composants React doivent avoir accès aux informations d'authentification pour rendre l'interface utilisateur appropriée
  • La solution doit être faite avec du React pur (sans Redux, thunk, etc.)

Pour moi, l'une des questions les plus difficiles a été:


  • Comment synchroniser l'état des composants React et les données de stockage local?
  • Comment obtenir le jeton à l'intérieur de la récupération sans le passer à travers l'arborescence d'éléments complète (surtout si nous voulons utiliser cette récupération dans les actions de thunk plus tard par exemple)

Mais résolvons les problèmes étape par étape. Premièrement, nous allons créer un token provider de jetons pour stocker les jetons et offrir la possibilité d'écouter les changements. Après cela, nous allons créer un auth provider , en fait entourer le token provider pour créer des crochets pour les composants React, récupérer des stéroïdes et quelques méthodes supplémentaires. Et à la fin, nous verrons comment utiliser cette solution dans le projet.


Je veux juste npm install ... et aller en production


J'ai déjà rassemblé le paquet qui contient tout ce qui est décrit ci-dessous (et un peu plus). Il vous suffit de l'installer par la commande:


 npm install react-token-auth 

Et suivez des exemples dans le référentiel GitHub react-token-auth .


Solution


Avant de résoudre le problème, je ferai l'hypothèse que nous avons un backend qui retourne un objet avec des jetons d'accès et de rafraîchissement. Chaque jeton a un format JWT . Un tel objet peut ressembler à:


 { "accessToken": "...", "refreshToken": "..." } 

En fait, la structure de l'objet jetons n'est pas critique pour nous. Dans le cas le plus simple, il peut s'agir d'une chaîne avec un jeton d'accès infini. Mais nous voulons voir comment gérer une situation lorsque nous avons deux jetons, l'un d'eux peut expirer et le second peut être utilisé pour mettre à jour le premier.


Jwt


Si vous ne savez pas quel est le jeton JWT, la meilleure option est d'aller sur jwt.io et de voir comment cela fonctionne. Maintenant, il est important que le jeton JWT contienne des informations codées (au format Base64 ) sur l'utilisateur qui permettent de l'authentifier sur le serveur.


Habituellement, le jeton JWT contient 3 parties divisées par des points et ressemble à:


eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjIsImV4cCI6MTUxNjIzOTAyMn0.yOZC0rjfSopcpJ-d3BWE8-BkoLR_SCqPdJpq8Wn-1Mc


Si nous décodons la partie centrale ( eyJu...Mn0 ) de ce jeton, nous obtiendrons le prochain JSON:


 { "name": "John Doe", "iat": 1516239022, "exp": 1516239022 } 

Avec ces informations, nous pourrons obtenir la date d'expiration du token.


Fournisseur de jetons


Comme je l'ai mentionné précédemment, notre première étape consiste à créer le fournisseur de jetons. Le fournisseur de jetons travaillera directement avec le stockage local et toutes les modifications de jetons que nous effectuerons à travers lui. Cela nous permettra d'écouter les changements de n'importe où et d'aviser immédiatement les auditeurs des changements (mais à ce sujet un peu plus tard). L'interface du fournisseur aura les méthodes suivantes:


  • getToken() pour obtenir le jeton actuel (il sera utilisé lors de la récupération)
  • setToken() pour définir le jeton après la connexion, la déconnexion ou l'enregistrement
  • isLoggedIn() pour vérifier si l'utilisateur est connecté
  • subscribe() pour donner au fournisseur une fonction qui devrait être appelée après tout changement de jeton
  • unsubscribe() pour supprimer l'abonné

La fonction createTokenProvider() créera une instance du fournisseur de jetons avec l'interface décrite:


 const createTokenProvider = () => { /* Implementation */ return { getToken, isLoggedIn, setToken, subscribe, unsubscribe, }; }; 

Tout le code suivant doit se trouver dans la fonction createTokenProvider.


Commençons par créer une variable pour stocker les jetons et restaurer les données du stockage local (pour être sûr que la session ne sera pas perdue après le rechargement de la page):


 let _token: { accessToken: string, refreshToken: string } = JSON.parse(localStorage.getItem('REACT_TOKEN_AUTH') || '') || null; 

Nous devons maintenant créer des fonctions supplémentaires pour travailler avec les jetons JWT. À l'heure actuelle, le jeton JWT ressemble à une chaîne magique, mais ce n'est pas grave de l'analyser et d'essayer d'extraire la date d'expiration. La fonction getExpirationDate() prendra un jeton JWT comme paramètre et renverra l'horodatage de la date d'expiration en cas de succès (ou null en cas d'échec):


 const getExpirationDate = (jwtToken?: string): number | null => { if (!jwtToken) { return null; } const jwt = JSON.parse(atob(jwtToken.split('.')[1])); // multiply by 1000 to convert seconds into milliseconds return jwt && jwt.exp && jwt.exp * 1000 || null; }; 

Et une autre fonction util isExpired() pour vérifier si l'horodatage a expiré. Cette fonction renvoie true si l'horodatage d'expiration présenté et s'il est inférieur à Date.now() .


 const isExpired = (exp?: number) => { if (!exp) { return false; } return Date.now() > exp; }; 

Il est temps de créer la première fonction de l'interface du fournisseur de jetons. La fonction getToken() devrait retourner un jeton et le mettre à jour si cela est nécessaire. Cette fonction doit être async car elle peut demander au réseau de mettre à jour le jeton.


En utilisant les fonctions créées précédemment, nous pouvons vérifier si les jetons d'accès ont expiré ou non ( isExpired(getExpirationDate(_token.accessToken)) ). Et dans le premier cas pour faire une demande de mise à jour du jeton. Après cela, nous pouvons enregistrer des jetons (avec la fonction setToken() pas encore implémentée). Et enfin, nous pouvons retourner le jeton d'accès:


 const getToken = async () => { if (!_token) { return null; } if (isExpired(getExpirationDate(_token.accessToken))) { const updatedToken = await fetch('/update-token', { method: 'POST', body: _token.refreshToken }) .then(r => r.json()); setToken(updatedToken); } return _token && _token.accessToken; }; 

La fonction isLoggedIn() sera simple: elle renverra true si _tokens n'est pas null et ne vérifiera pas l'expiration du jeton d'accès (dans ce cas, nous ne connaîtrons pas le jeton d'accès d'expiration jusqu'à ce que nous obtenions un échec lors de l'obtention du jeton, mais généralement c'est suffisant , et gardons la fonction isLoggedIn synchrone):


 const isLoggedIn = () => { return !!_token; }; 

Je pense que c'est le bon moment pour créer des fonctionnalités de gestion des observateurs. Nous allons implémenter quelque chose de similaire au modèle Observer , et tout d'abord, créerons un tableau pour stocker tous nos observateurs. Nous nous attendons à ce que chaque élément de ce tableau soit la fonction que nous devrions appeler après chaque changement de jetons:


 let observers: Array<(isLogged: boolean) => void> = []; 

Nous pouvons maintenant créer des méthodes subscribe() et unsubscribe() . Le premier ajoutera un nouvel observateur au tableau créé un peu plus tôt, le second supprimera l'observateur de la liste.


 const subscribe = (observer: (isLogged: boolean) => void) => { observers.push(observer); }; const unsubscribe = (observer: (isLogged: boolean) => void) => { observers = observers.filter(_observer => _observer !== observer); }; 

Vous pouvez déjà voir depuis l'interface des fonctions subscribe() et unsubscribe() que nous enverrons aux observateurs uniquement si l'utilisateur est connecté. Mais en général, vous pouvez envoyer tout ce que vous voulez (le token entier, le délai d'expiration, etc ...). Mais pour nos besoins, il suffira d'envoyer un drapeau booléen.


Créons une petite fonction util notify() qui prendra ce drapeau et enverra à tous les observateurs:


 const notify = () => { const isLogged = isLoggedIn(); observers.forEach(observer => observer(isLogged)); }; 

Et la dernière mais non la moindre fonction que nous devons implémenter est le setToken() . Le but de cette fonction est d'enregistrer des jetons dans le stockage local (ou de nettoyer le stockage local si le jeton est vide) et d'informer les observateurs des modifications. Donc, je vois le but, je vais au but.


 const setToken = (token: typeof _token) => { if (token) { localStorage.setItem('REACT_TOKEN_AUTH', JSON.stringify(token)); } else { localStorage.removeItem('REACT_TOKEN_AUTH'); } _token = token; notify(); }; 

Soyez sûr, si vous en êtes arrivé à ce point dans l'article et l'avez trouvé utile, vous m'avez déjà rendu plus heureux. Ici, nous terminons avec le fournisseur de jetons. Vous pouvez regarder votre code, jouer avec lui et vérifier qu'il fonctionne. Dans la partie suivante, nous allons créer des fonctionnalités plus abstraites qui seront déjà utiles dans n'importe quelle application React.


Fournisseur d'authentification


Créons une nouvelle classe d'objets que nous appellerons en tant que fournisseur Auth. L'interface contiendra 4 méthodes: hook useAuth() pour obtenir un nouvel état du composant React, authFetch() pour faire des requêtes au réseau avec le token réel et login() , les méthodes setToken() logout() qui proxy les appels à la méthode setToken() du fournisseur de jetons (dans ce cas, nous n'aurons qu'un seul point d'entrée pour l'ensemble des fonctionnalités créées, et le reste du code n'aura pas à connaître l'existence du fournisseur de jetons). Comme précédemment, nous partirons du créateur de la fonction:


 export const createAuthProvider = () => { /* Implementation */ return { useAuth, authFetch, login, logout } }; 

Tout d'abord, si nous voulons utiliser un fournisseur de jetons, nous devons en créer une instance:


 const tokenProvider = createTokenProvider(); 

Les méthodes login() et logout() transmettent simplement le jeton au fournisseur de jetons. J'ai séparé ces méthodes uniquement pour une signification explicite (le fait de passer un jeton vide / nul supprime les données du stockage local):


 const login: typeof tokenProvider.setToken = (newTokens) => { tokenProvider.setToken(newTokens); }; const logout = () => { tokenProvider.setToken(null); }; 

L'étape suivante est la fonction de récupération. Selon mon idée, cette fonction devrait avoir exactement la même interface que la récupération d'origine et retourner le même format mais devrait injecter un jeton d'accès à chaque demande.


La fonction d'extraction doit prendre deux arguments: demander des informations (généralement une URL) et demander init (un objet avec méthode, corps. En-têtes et ainsi de suite); et renvoie la promesse de la réponse:


 const authFetch = async (input: RequestInfo, init?: RequestInit): Promise<Response> => { const token = await tokenProvider.getToken(); init = init || {}; init.headers = { ...init.headers, Authorization: `Bearer ${token}`, }; return fetch(input, init); }; 

À l'intérieur de la fonction, nous avons fait deux choses: pris un jeton du fournisseur de jetons par instruction await tokenProvider.getToken(); ( getToken contient déjà la logique de mise à jour du jeton après expiration) et d'injection de ce jeton dans l'en-tête d' Authorization par la ligne Authorization: 'Bearer ${token}' . Après cela, nous retournons simplement fetch avec des arguments mis à jour.


Ainsi, nous pouvons déjà utiliser le fournisseur d'authentification pour enregistrer les jetons et les utiliser depuis la récupération. Le dernier problème est que nous ne pouvons pas réagir aux changements de jetons de nos composants. Il est temps de le résoudre.


Comme je l'ai déjà dit, nous allons créer un hook useAuth() qui fournira des informations au composant est l'utilisateur connecté ou non. Pour ce faire, nous utiliserons hook useState() pour conserver ces informations. Il est utile car toute modification de cet état entraînera un nouveau rendu des composants qui utilisent ce hook.


Et nous avons déjà tout préparé pour pouvoir écouter les changements dans le stockage local. Une manière courante d'écouter les modifications du système avec des hooks consiste à utiliser le hook useEffect() . Ce hook prend deux arguments: la fonction et la liste des dépendances. La fonction sera déclenchée après le premier appel de useEffect puis relancée après toute modification dans la liste des dépendances. Dans cette fonction, nous pouvons commencer à écouter les changements dans le stockage local. Mais ce qui est important, nous pouvons revenir de cette fonction ... nouvelle fonction et, cette nouvelle fonction sera déclenchée soit avant de relancer la première soit après le démontage du composant. Dans la nouvelle fonction, nous pouvons arrêter d'écouter les changements et React garantit que cette fonction sera déclenchée (du moins si aucune exception ne se produit pendant ce processus). Cela semble un peu compliqué, mais regardez le code:


 const useAuth = () => { const [isLogged, setIsLogged] = useState(tokenProvider.isLoggedIn()); useEffect(() => { const listener = (newIsLogged: boolean) => { setIsLogged(newIsLogged); }; tokenProvider.subscribe(listener); return () => { tokenProvider.unsubscribe(listener); }; }, []); return [isLogged] as [typeof isLogged]; }; 

Et c'est tout. Nous venons de créer un stockage d'authentification de jeton compact et réutilisable avec une API claire. Dans la partie suivante, nous examinerons quelques exemples d'utilisation.


Utilisation


Pour commencer à utiliser ce que nous avons implémenté ci-dessus, nous devons créer une instance du fournisseur d'authentification. Cela nous donnera accès aux fonctions useAuth() , authFetch() , login() , logout() liées au même jeton dans le stockage local (en général, rien ne vous empêche de créer différentes instances de fournisseur d'authentification pour différents jetons, mais vous devrez paramétrer la clé que vous utilisez pour stocker les données dans le stockage local):


 export const {useAuth, authFetch, login, logout} = createAuthProvider(); 

Formulaire de connexion


Nous pouvons maintenant commencer à utiliser les fonctions que nous avons obtenues. Commençons par le composant du formulaire de connexion. Ce composant doit fournir des entrées pour les informations d'identification de l'utilisateur et l'enregistrer dans l'état interne. Lors de la soumission, nous devons envoyer une demande avec les informations d'identification pour obtenir des jetons et ici, nous pouvons utiliser la fonction login() pour stocker les jetons reçus:


 const LoginComponent = () => { const [credentials, setCredentials] = useState({ name: '', password: '' }); const onChange = ({target: {name, value}}: ChangeEvent<HTMLInputElement>) => { setCredentials({...credentials, [name]: value}) }; const onSubmit = (event?: React.FormEvent) => { if (event) { event.preventDefault(); } fetch('/login', { method: 'POST', body: JSON.stringify(credentials) }) .then(r => r.json()) .then(token => login(token)) }; return <form onSubmit={onSubmit}> <input name="name" value={credentials.name} onChange={onChange}/> <input name="password" value={credentials.password} onChange={onChange}/> </form> }; 

Et c'est tout, c'est tout ce dont nous avons besoin pour stocker le jeton. Après cela, lorsqu'un jeton est reçu, nous n'aurons pas besoin de déployer des efforts supplémentaires pour l'amener à récupérer ou dans des composants, car il est déjà implémenté dans le fournisseur d'authentification.


Le formulaire d'inscription est similaire, il n'y a que des différences dans le nombre et les noms des champs de saisie, donc je vais l'omettre ici.


Routeur


De plus, nous pouvons implémenter le routage à l'aide du fournisseur d'authentification. Supposons que nous avons deux packs de routes: un pour l'utilisateur enregistré et un pour non enregistré. Pour les séparer, nous devons vérifier si nous avons un jeton dans le stockage local ou non, et ici nous pouvons utiliser le crochet useAuth() :


 export const Router = () => { const [logged] = useAuth(); return <BrowserRouter> <Switch> {!logged && <> <Route path="/register" component={Register}/> <Route path="/login" component={Login}/> <Redirect to="/login"/> </>} {logged && <> <Route path="/dashboard" component={Dashboard} exact/> <Redirect to="/dashboard"/> </>} </Switch> </BrowserRouter>; }; 

Et la bonne chose qu'il sera restitué après toute modification du stockage local, en raison de useAuth a un abonnement à ces modifications.


Récupérer les demandes


Et puis nous pouvons obtenir des données protégées par le jeton en utilisant authFetch . Il a la même interface que fetch, donc si vous utilisez déjà fetch dans le code, vous pouvez simplement le remplacer par authFetch :


 const Dashboard = () => { const [posts, setPosts] = useState([]); useEffect(() => { authFetch('/posts') .then(r => r.json()) .then(_posts => setPosts(_posts)) }, []); return <div> {posts.map(post => <div key={post.id}> {post.message} </div>)} </div> }; 

Résumé


Nous l'avons fait. Ce fut un voyage intéressant, mais il a aussi la fin (peut-être même heureux).


Nous avons commencé par comprendre les problèmes de stockage des jetons d'autorisation. Ensuite, nous avons implémenté une solution et finalement regardé les exemples de la façon dont elle pourrait être utilisée dans l'application React.


Comme je l'ai déjà dit, vous pouvez trouver mon implémentation sur GitHub dans la bibliothèque. Il résout un problème un peu plus générique et ne fait pas d'hypothèses sur la structure de l'objet avec des jetons ou sur la façon de mettre à jour le jeton, vous devrez donc fournir des arguments supplémentaires. Mais l'idée de la solution est la même et le référentiel contient également des instructions sur la façon de l'utiliser.


Ici, je peux dire merci pour la lecture de l'article et j'espère qu'il vous a été utile.

Source: https://habr.com/ru/post/fr485764/


All Articles