Authentification JWT avec react
Catégories
JavaScriptDans cet article, nous allons mettre en place une authentification JWT (Json Web Token) avec React
. Le JWT est un mode d'authentification sécurisé, qui a la particularité de pouvoir inclure des informations dans son jeton. Ces informations sont régulièrement mises à jour au cours de la session, car le token a un délai d'expiration très court, de quelques dizaines de secondes à plusieurs minutes en fonction des besoins. Il est à chaque fois régénéré à partir d'un refresh_token
, qui lui expire après plusieurs jours, semaines voire mois. Grâce au changement fréquent de token, on limite aussi les risques en cas d'interception. Le refresh token en revanche est beaucoup plus sensible, on peut songer à ajouter des vérifications supplémentaires au moment où celui-ci est utilisé (IP par exemple).
Comment fonctionne un token JWT ? Un token JWT contient des données encodées et se compose de trois parties : le header
, le payload
et la signature
.
- Le header contient des informations sur la nature du token.
- Le payload contient les données utiles, appelés claims, certains sont réservés à un usage bien spécifque comme
iat
qui est le timestamp d'émission du token ousub
qui référence l'utilisateur. Vous pouvez retrouver ici la liste complète de ces claims réservés. Il est possible d'ajouter autant de claims qu'on le souhaite, veillez cependant à ne pas inclure d'information sensible car celles-ci sont facilement lisibles par n'importe qui. - La signature permet simplement de certifier le contenu du token, si la moindre information venait à être modifiée, la vérification échouerai.
Voici comment se présente un token:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Voici sa représentation décodée:
// header
{
"alg": "HS256",
"typ": "JWT"
}
// PAYLOAD
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
// Signature
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
your-256-bit-secret
)
Installation
Installation d'un projet react
Installez React
avec Vitejs
, avec TypeScript de préférence.
npm create vite@latest
Installation des prérequis
React Router Dom permet d'avoir du routing sur notre application React.
Zod permet de déclarer et de vérifier des schémas.
React Hook form est un outil qui facilite l'utilisation des formulaires avec React.
Axios est un HTTP client pour nodejs.
npm install react-router-dom
npm install zod
npm install react-hook-form
npm install axios
Mise en place d'un contexte react
Un contexte est un composant qui permet de partager des données à travers l'arborescence de composants sans devoir utiliser les props
de React. Il permet de stocker les tokens dans le localStorage
pour vérifier si l'utilisateur est bien authentifié.
import {createContext, ReactNode, useContext, useEffect, useMemo, useState} from "react";
import axios from "axios";
import {BASE_URL} from "../../config/axios.ts";
interface AuthContext {
token: string | null;
refreshToken: string | null;
setToken: (newToken: string | null) => void;
setRefreshToken: (newRefreshToken: string | null) => void;
setAllTokens: (token: string, refreshToken: string) => void;
login: (data: {token: string, refresh_token: string } ) => void;
logout: () => void;
}
export enum AuthEnum {
TOKEN = 'token',
REFRESH_TOKEN = 'refresh_token'
}
export const AuthContext = createContext<AuthContext | null>(null)
export const AuthProvider = ({children}: { children: ReactNode }) => {
const [token, setToken] = useState<string | null>(localStorage.getItem(AuthEnum.TOKEN));
const [refreshToken, setRefreshToken] = useState<string | null>(localStorage.getItem(AuthEnum.REFRESH_TOKEN));
const login = async (data: { token: string, refresh_token: string }) => {
setAllTokens(data.token, data.refresh_token)
}
const logout = async () => {
let response = await axios.post(BASE_URL + "/auth/logout", {refresh_token: refreshToken})
if (response.status === 200) {
setToken(null)
setRefreshToken(null)
localStorage.clear()
}
}
const setAllTokens = (token: string, refreshToken: string) => {
setToken(token)
setRefreshToken(refreshToken)
localStorage.setItem(AuthEnum.TOKEN, token);
localStorage.setItem(AuthEnum.REFRESH_TOKEN, refreshToken);
}
useEffect(() => {
if (token && refreshToken) {
localStorage.setItem(AuthEnum.TOKEN, token);
localStorage.setItem(AuthEnum.REFRESH_TOKEN, refreshToken);
}
}, [token, refreshToken]);
const contextValue = useMemo(
() => ({
token,
refreshToken,
setToken,
setRefreshToken,
setAllTokens,
login,
logout
}),
[token, refreshToken]
);
return (
<AuthContext.Provider value={contextValue}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => {
const context = useContext(AuthContext)
if (context === null) {
throw new Error('useAuth must be used within a AuthProvider')
}
return context
}
Pour que le contexte soit fonctionnel, la configuration du routeur est obligatoire.
import React from 'react'
import ReactDOM from 'react-dom/client'
import './assets/style/app.css'
import {AuthProvider} from "./context/modules/AuthContext.tsx";
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<AuthProvider> <-- appel du contexte
</AuthProvider>
</React.StrictMode>
)
Mise en place de la page de login
import {SubmitHandler, useForm} from "react-hook-form";
import {zodResolver} from "@hookform/resolvers/zod";
import {z} from "zod";
import {useNavigate} from "react-router-dom";
import {useAuth} from "../../context/modules/AuthContext.tsx";
import {BASE_URL} from "../../config/axios.ts";
// Check schema for object
const schema = z.object({
email: z.string().email(),
password: z.string().min(8)
})
export const loginSchemaType = z.object({
token: z.string(),
refresh_token: z.string()
})
type FormFields = z.infer<typeof schema>
export default function Login() {
const {login} = useAuth()
const nav = useNavigate()
const {
register,
handleSubmit,
formState: {errors}
} = useForm<FormFields>({
resolver: zodResolver(schema)
})
const onSubmit: SubmitHandler<FormFields> = async (data: {email: string, password: string}) => {
try {
const response = await fetch(BASE_URL + '/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: data.email,
password: data.password
})
});
const json = await response.json();
login(loginSchemaType.parse(json));
nav("/");
} catch (error) {
console.log('Error:', error);
}
}
return <div className="flex min-h-full flex-col justify-center px-6 py-12 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
<img src="/logo.png" alt="Logo" height="100" width="175" className="mx-auto"/>
<h2 className="mt-5 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">
Connectez-vous à votre compte
</h2>
</div>
<div className="mt-5 sm:mx-auto sm:w-full sm:max-w-sm">
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="email">Email</label>
<input
{...register('email')}
type="text"
placeholder="john.doe@exemple.com"
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
/>
{errors.email &&
<span>{errors.email.message}</span>
}
</div>
<div className="mt-3">
<label htmlFor="password">Mot de passe</label>
<input
{...register('password')}
type="password"
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
/>
{errors.password &&
<span>{errors.password.message}</span>
}
</div>
<div className='mt-3'>
<button type="submit"
className="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
Se connecter
</button>
</div>
</form>
</div>
</div>
}
Mise en place des routes protégées:
Les routes protégées vont rediriger vers la page de login si l'utilisateur n'est pas authentifié.
import {Navigate, Outlet} from "react-router-dom";
import {useAuth} from "../modules/AuthContext.tsx";
export function ProtectedRouteProvider() {
const {token} = useAuth()
if (!token) {
return <Navigate replace to="/login"/>
}
return <>
<Outlet/>
</>
}
Voici un exemple d'implémentationo du ProtectedRouteProvider
:
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<ProtectedRouteProvider/>}>
<Route path="/" element={<App/>} />
</Route>
<Route path="/login" element={<Login/>}/>
</Routes>
</BrowserRouter>
</AuthProvider>
</React.StrictMode>
)
Mise en place du rafraichissement du token lors du une erreur 401
Pour cela axios
répond parfaitement à notre besoin. Nous allons utiliser certainses de ses fonctionnalités comme les variables globales et intercepteur de requêtes.
Lorsqu'une requête échoue avec un code 401, axios va envoyer une demande de rafraichissement avec notre refresh_token.
Il va ensuite relancer la requête originale avec ce token fraichement obtenu.
import axios from "axios";
import {useAuth, AuthEnum} from "../context/modules/AuthContext.tsx";
export const BASE_URL = "http://localhost:8080";
export const useAxios = () => {
const {setAllTokens, logout} = useAuth()
const token = localStorage.getItem(AuthEnum.TOKEN)
const axiosInstance = axios.create({
baseURL: BASE_URL,
headers: {
Authorization: `Bearer ${token}`
}
})
axiosInstance.defaults.headers.post["Content-Type"] = "application/json";
axiosInstance.interceptors.request.use(
(config) => {
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error)
)
axiosInstance.interceptors.response.use(
(res) => res,
async (error) => {
const baseReq = error.config
if (error.response && error.response.status === 401 && !baseReq._retry) {
baseReq._retry = true
try {
const response = await axios.post(BASE_URL + "/auth/refresh", {
refresh_token: localStorage.getItem(AuthEnum.REFRESH_TOKEN)
})
if (response.data.token == null) {
logout()
return Promise.reject(error)
}
setAllTokens(response.data.token, response.data.refresh_token)
baseReq.headers.Authorization = `Bearer ${response.data.token}`
return axios(baseReq)
} catch (e) {
return Promise.reject(error)
}
}
return Promise.reject(error)
}
)
return axiosInstance
}