Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

Chapitre 1227 minIntermédiaire

Authentification en React : connexion, routes protégées, jetons et cookies

Par Josselyn C., prof de BTS SIO. Mis à jour le .

À la fin de ce chapitre, tu sauras

  • dire ce que le front protège et ce que seule l'API protège ;
  • choisir où garder la session : cookie HttpOnly, mémoire ou localStorage ;
  • écrire un contexte d'authentification et une page de connexion ;
  • protéger des routes selon la connexion et le rôle ;
  • réagir proprement à une session expirée.

4 exercices corrigés1 schéma

Dans une application React, l'authentification se partage entre deux acteurs. L'API vérifie l'identité et les droits à chaque requête : c'est elle qui protège les données. Le front gère l'expérience : un formulaire de connexion, un contexte qui sait qui est connecté, des routes protégées qui renvoient vers la connexion, et une réaction propre quand la session expire. Ce chapitre construit cette partie front pour AppliFrais, et compare les façons de garder la session dans le navigateur : cookie HttpOnly, jeton en mémoire ou localStorage.

Ce que le front protège, et ce qu'il ne protège pas

Tout le code d'une application React est téléchargé dans le navigateur de l'utilisateur. Il peut le lire, le modifier dans les outils de développement, ou se passer complètement du front et appeler l'API avec curl. Par conséquent :

  • masquer un bouton « Valider la fiche » à un visiteur est un confort d'affichage, pas une sécurité ;
  • une « route protégée » côté React empêche d'afficher un écran, pas d'accéder aux données ;
  • la seule vraie barrière est l'API, qui doit vérifier, pour chaque requête, que l'utilisateur est authentifié et qu'il a le droit d'agir sur cette ressource (une fiche qui lui appartient). L'oubli de ce second contrôle est la première faille des API, le contrôle d'accès défaillant (BOLA et OWASP A01).

Les notions d'authentification (facteurs, sessions, JWT, OAuth) sont expliquées dans le cours Authentification et habilitations ; ce chapitre se place du côté de l'application React.

Où garder la session dans le navigateur

Après la connexion, le navigateur doit présenter une preuve d'identité à chaque requête. Trois emplacements possibles :

EmplacementComment le front s'en sertRisque principalVerdict
Cookie HttpOnly, Secure, SameSite posé par l'APIRien à faire : le navigateur l'envoie tout seul avec chaque requête vers l'APILa falsification de requête (CSRF), bloquée par SameSite et un jeton anti-CSRFTrès bon pour un front web seul, si l'API gère des sessions par cookie
Jeton en mémoire (une variable du module d'appels)On l'ajoute à chaque requête dans l'en-tête Authorization: Bearer …Perdu au rechargement de la page ; un script injecté peut s'en servir tant que la page est ouverte, mais ne le trouve dans aucun stockageLe choix d'AppliFrais, dont l'API émet des jetons Bearer
localStorageOn le lit et on l'ajoute dans l'en-tête AuthorizationLisible par tout script de la page : une seule faille XSS (une dépendance compromise, un dangerouslySetInnerHTML) suffit à copier le jeton et à s'en servir depuis n'importe oùÀ éviter

L'API d'AppliFrais, celle du cours Node.js, ne pose pas de cookie : à la connexion, elle renvoie un jeton d'accès JWT valable 15 minutes, et attend l'en-tête Authorization: Bearer … à chaque requête (authentification JWT). C'est un choix de l'API, qui sert aussi l'application mobile, laquelle ne gère pas les cookies comme un navigateur (sessions ou JWT ?). Le front doit donc garder ce jeton quelque part, et les cours Sécurité et Authentification donnent la règle : pas de jeton dans localStorage, mais la mémoire du front. AppliFrais le garde dans une variable du module d'appels du chapitre 7, qu'aucun autre code ne peut lire.

Ce choix a un prix, qu'il faut connaître et savoir expliquer : le jeton disparaît quand l'onglet est rechargé ou fermé, et il expire au bout de 15 minutes. Dans les deux cas, l'utilisateur se reconnecte. Pour l'éviter, l'API ajouterait un jeton de rafraîchissement, envoyé dans un cookie HttpOnly; Secure; SameSite=Strict et échangé au démarrage contre un nouveau jeton d'accès (JWT : fonctionnement et pièges). L'API du cours Node.js ne le fait pas pour le navigateur : c'est une évolution possible de ton projet, pas un prérequis.

Le déroulement d'une session avec un jeton

Front ReactnavigateurAPINode.js, /v11. pas de jeton : page de connexion2. POST /v1/auth/connexion (login, mot de passe)3. vérifie le mot de passe haché200 : { accessToken, expiresIn: 900 }4. le jeton reste en mémoireGET /v1/auth/moi + Authorization: Bearer …200 : le profil, rangé dans le contexte5. GET /v1/fiches + le même en-tête200 : les fiches de cet utilisateur, et seulement les siennes6. déconnexion : le front oubliele jeton et le profil, sans appelautre cas : 15 minutes ont passé7. GET /v1/fiches/12 + le jeton expiré401 : le front oublie le jeton et renvoie vers /connexion
Le jeton d'accès vit en mémoire : il part dans l'en-tête Authorization, et un 401 renvoie vers la connexion

Le front ne lit pas le contenu du jeton : il se contente de le renvoyer. Le profil (nom, prénom, rôle) vient de GET /v1/auth/moi, qui confirme au passage que le jeton fonctionne. Un rechargement de la page efface la mémoire : l'application redémarre sans jeton, sur la page de connexion.

En développement, le proxy de Vite relaie l'en-tête Authorization avec le reste de la requête. En production, que l'API soit servie sur le même domaine que le front (chapitre 15) ou sur un autre, rien ne change pour le jeton : un en-tête n'est pas un cookie, il n'y a ni credentials: 'include' ni cookie tiers à gérer. Sur un autre domaine, l'API doit seulement autoriser l'origine du front avec CORS, comme le fait déjà l'API du cours Node.js (le middleware cors).

Les appels à l'API d'authentification

ts
// src/api/auth.ts
import { api } from './client';
import type { Utilisateur } from '../types';

export type Jeton = { accessToken: string; tokenType: 'Bearer'; expiresIn: number };

export function connexion(login: string, motDePasse: string) {
  return api<Jeton>('/auth/connexion', { method: 'POST', corps: { login, motDePasse } });
}

export function getMoi() {
  return api<Utilisateur>('/auth/moi');
}

Il n'y a pas de fonction de déconnexion : l'API ne garde pas les jetons d'accès qu'elle émet, elle n'a rien à supprimer. Se déconnecter, c'est oublier le jeton côté front (expiration, déconnexion et révocation). Un jeton copié avant la déconnexion resterait donc valable jusqu'à son expiration : c'est la raison de sa durée courte. Supprime maintenant la connexion temporaire ajoutée dans main.tsx au chapitre 7.

Le contexte d'authentification

L'utilisateur connecté sert dans l'en-tête, dans les routes protégées, dans les boutons réservés au comptable : c'est le cas d'usage type d'un Context (chapitre 11). On le découpe en trois fichiers, pour que chaque fichier de composant n'exporte que des composants (ce qu'exige le rechargement à chaud de Vite).

ts
// src/auth/auth-context.ts
import { createContext } from 'react';
import type { Utilisateur } from '../types';

export type AuthValeur = {
  utilisateur: Utilisateur | null;
  seConnecter: (login: string, motDePasse: string) => Promise<void>;
  seDeconnecter: () => void;
};

export const AuthContext = createContext<AuthValeur | null>(null);
tsx
// src/auth/AuthProvider.tsx
import { useEffect, useState, type ReactNode } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { AuthContext } from './auth-context';
import { connexion, getMoi } from '../api/auth';
import { definirJeton, SESSION_EXPIREE } from '../api/client';
import type { Utilisateur } from '../types';

export function AuthProvider({ children }: { children: ReactNode }) {
  const [utilisateur, setUtilisateur] = useState<Utilisateur | null>(null);
  const queryClient = useQueryClient();

  // Une requête a reçu 401 : le jeton a expiré, on oublie le jeton et l'utilisateur.
  useEffect(() => {
    function surExpiration() {
      definirJeton(null);
      queryClient.clear();
      setUtilisateur(null);
    }
    window.addEventListener(SESSION_EXPIREE, surExpiration);
    return () => window.removeEventListener(SESSION_EXPIREE, surExpiration);
  }, [queryClient]);

  async function seConnecter(login: string, motDePasse: string) {
    const { accessToken } = await connexion(login, motDePasse);
    definirJeton(accessToken);
    try {
      setUtilisateur(await getMoi());
    } catch (e) {
      definirJeton(null);   // pas de profil : pas de session à moitié ouverte
      throw e;
    }
  }

  function seDeconnecter() {
    definirJeton(null);    // plus aucune requête ne part avec le jeton
    queryClient.clear();   // oublier les données de l'utilisateur précédent
    setUtilisateur(null);
  }

  return (
    <AuthContext value={{ utilisateur, seConnecter, seDeconnecter }}>
      {children}
    </AuthContext>
  );
}
ts
// src/auth/useAuth.ts
import { use } from 'react';
import { AuthContext } from './auth-context';

export function useAuth() {
  const valeur = use(AuthContext);
  if (valeur === null) {
    throw new Error('useAuth doit être appelé dans un composant placé sous <AuthProvider>.');
  }
  return valeur;
}

Quatre décisions à comprendre :

  1. Pas d'état de chargement au démarrage. Le jeton n'existe qu'en mémoire : au chargement de la page, il n'y en a pas, et l'application sait tout de suite que personne n'est connecté. Avec un cookie de session ou un jeton de rafraîchissement, il faudrait au contraire attendre une réponse de l'API avant de décider (voir la variante par cookie).
  2. Le profil vient de l'API (GET /v1/auth/moi), jamais d'une valeur enregistrée dans le navigateur ni du contenu du jeton : c'est l'API qui sait qui est connecté et avec quel rôle. Si cette lecture échoue, le jeton est oublié aussitôt.
  3. À la déconnexion, queryClient.clear() vide le cache de TanStack Query. Sinon, sur un poste partagé, l'utilisateur suivant verrait un instant les fiches du précédent.
  4. Un 401 reçu sur une requête authentifiée déclenche la déconnexion locale. Pour cela, la fonction api du chapitre 7 émet un événement quand une réponse a le statut 401 alors qu'un jeton a été envoyé :
ts
// src/api/client.ts (ajouts)
export const SESSION_EXPIREE = 'appli-frais:session-expiree';

// dans la fonction api, juste après le fetch :
if (reponse.status === 401 && jeton) {
  // Jeton expiré ou refusé : on prévient l'application (AuthProvider écoute cet événement)
  window.dispatchEvent(new Event(SESSION_EXPIREE));
}

La condition && jeton compte : pendant la connexion, aucun jeton n'est encore envoyé, et un 401 y veut dire « identifiant ou mot de passe incorrect », pas « session expirée ». Le module d'appels ne connaît pas React : il ne peut pas appeler setUtilisateur. Un événement du navigateur relie proprement les deux couches.

AuthProvider utilise useQueryClient : il doit donc être placé sous le QueryClientProvider, et au-dessus du routeur pour que toutes les pages y aient accès :

tsx
// src/main.tsx (extrait)
<StrictMode>
  <QueryClientProvider client={queryClient}>
    <AuthProvider>
      <RouterProvider router={router} />
    </AuthProvider>
  </QueryClientProvider>
</StrictMode>

La page de connexion

tsx
// src/pages/PageConnexion.tsx
import { useActionState } from 'react';
import { Navigate, useLocation, useNavigate } from 'react-router';
import { useAuth } from '../auth/useAuth';
import { ApiError } from '../api/client';
import { useTitrePage } from '../hooks/useTitrePage';

export function PageConnexion() {
  const { utilisateur, seConnecter } = useAuth();
  const navigate = useNavigate();
  const location = useLocation();
  const depuis: string = location.state?.depuis ?? '/';
  useTitrePage('Connexion');

  const [erreur, action, enCours] = useActionState(
    async (_precedente: string | null, formData: FormData) => {
      try {
        await seConnecter(String(formData.get('login')), String(formData.get('motDePasse')));
        navigate(depuis, { replace: true });
        return null;
      } catch (e) {
        if (e instanceof ApiError && e.status === 401) return 'Identifiant ou mot de passe incorrect.';
        if (e instanceof ApiError && e.status === 429) return e.message;   // trop de tentatives
        return 'Connexion impossible pour le moment, réessaie.';
      }
    },
    null,
  );

  if (utilisateur) return <Navigate to={depuis} replace />;

  return (
    <form action={action}>
      <h1>Connexion</h1>
      <p>
        <label htmlFor="login">Identifiant</label>
        <input id="login" name="login" autoComplete="username" required />
      </p>
      <p>
        <label htmlFor="motDePasse">Mot de passe</label>
        <input id="motDePasse" name="motDePasse" type="password" autoComplete="current-password" required />
      </p>
      {erreur && <p role="alert">{erreur}</p>}
      <button type="submit" disabled={enCours}>{enCours ? 'Connexion…' : 'Se connecter'}</button>
    </form>
  );
}
  • depuis : la page, filtre compris, que l'utilisateur voulait voir avant d'être renvoyé ici (transmise par la route protégée). Après la connexion, il y retourne au lieu d'atterrir sur l'accueil.
  • autoComplete="username" et "current-password" permettent au gestionnaire de mots de passe du navigateur de remplir le formulaire.
  • Un message unique pour un identifiant inconnu ou un mot de passe faux : préciser lequel est faux aiderait un attaquant à trouver les identifiants existants. L'API fait de même, avec un seul 401.
  • Le 429 : après dix échecs en quinze minutes depuis la même adresse, l'API refuse les tentatives suivantes (la route de connexion) ; son message, affiché tel quel, dit d'attendre.
  • if (utilisateur) return <Navigate …> : un utilisateur déjà connecté qui ouvre /connexion est renvoyé vers l'application.

La route protégée

Une route protégée est une route sans chemin qui enveloppe d'autres routes. Elle décide d'afficher ses enfants (avec <Outlet />) ou de rediriger :

tsx
// src/auth/RouteProtegee.tsx
import { Navigate, Outlet, useLocation } from 'react-router';
import { useAuth } from './useAuth';
import type { Role } from '../types';

type RouteProtegeeProps = {
  role?: Role;   // sans rôle : il suffit d'être connecté
};

export function RouteProtegee({ role }: RouteProtegeeProps) {
  const { utilisateur } = useAuth();
  const location = useLocation();

  if (utilisateur === null) {
    // On retient la page demandée pour y revenir après la connexion.
    return <Navigate to="/connexion" replace state={{ depuis: location.pathname + location.search }} />;
  }

  if (role && utilisateur.role !== role) {
    return <p role="alert">Cette page est réservée au rôle « {role} ».</p>;
  }

  return <Outlet />;
}

Dans le routeur, on la place entre le Layout et les pages à protéger :

tsx
// src/router.tsx
export const router = createBrowserRouter([
  {
    path: '/',
    element: <Layout />,
    children: [
      { path: 'connexion', element: <PageConnexion /> },
      {
        element: <RouteProtegee />,      // route sans chemin : elle protège ses enfants
        children: [
          { index: true, element: <PageFiches /> },
          { path: 'fiches/:id', element: <PageFiche /> },
        ],
      },
      { path: '*', element: <PageIntrouvable /> },
    ],
  },
]);

Une page réservée aux comptables s'ajouterait dans un autre groupe : { element: <RouteProtegee role="comptable" />, children: [{ path: 'validation', element: <PageValidation /> }] }.

Un visiteur qui ouvre /fiches/12 sans être connecté est redirigé vers /connexion ; après la connexion, il arrive directement sur la fiche 12. C'est aussi ce qui se passe quand il recharge la page ou que son jeton expire : il retrouve la fiche dès qu'il s'est reconnecté. replace évite que la redirection reste dans l'historique : le bouton Précédent ne le renvoie pas en boucle vers la page protégée.

Afficher selon l'utilisateur et le rôle

Le Layout lit le contexte pour afficher le nom et le bouton de déconnexion :

tsx
// src/pages/Layout.tsx (extrait)
const { utilisateur, seDeconnecter } = useAuth();

{utilisateur && (
  <nav aria-label="Navigation principale">
    <NavLink to="/" end>Mes fiches</NavLink>
    <span>{utilisateur.prenom} {utilisateur.nom}</span>
    <button type="button" onClick={seDeconnecter}>Se déconnecter</button>
  </nav>
)}

Et un bouton réservé au comptable se masque selon le rôle :

tsx
{utilisateur?.role === 'comptable' && <BoutonValider ficheId={fiche.id} />}

Encore une fois : ce bouton masqué n'empêche personne d'envoyer POST /v1/fiches/12/validations à la main. L'API doit refuser avec 403 si l'utilisateur n'est pas comptable.

Si ton API sert seulement un front web, sur le même domaine, une session par cookie est une excellente solution : à la connexion, l'API crée la session et pose un cookie HttpOnly; Secure; SameSite=Strict, que le JavaScript ne voit jamais et que le navigateur renvoie tout seul. Le cours Node.js montre la configuration côté serveur avec express-session (sessions ou JWT ?). Côté React, quatre choses changent :

  • definirJeton et l'en-tête Authorization disparaissent du module d'appels ;
  • la connexion renvoie directement le profil, et une route de déconnexion (204) supprime la session sur le serveur : seDeconnecter devient asynchrone et l'appelle ;
  • au démarrage, le front ne sait pas si un cookie valable existe : il demande le profil et attend la réponse. Le contexte a besoin d'un état chargement, à true jusqu'à cette réponse, et la route protégée affiche « Vérification de la session… » pendant ce temps ; sinon, elle renverrait un instant vers la page de connexion un utilisateur bien connecté ;
  • si l'API est sur un autre domaine, chaque fetch doit ajouter credentials: 'include', l'API doit répondre Access-Control-Allow-Credentials: true, et le cookie devient un cookie tiers, que certains navigateurs bloquent.

En échange, la session survit au rechargement de la page, et l'API peut la supprimer immédiatement. Le risque se déplace : un cookie envoyé automatiquement expose à la CSRF, qu'il faut bloquer avec SameSite et un jeton anti-CSRF (faille CSRF et cookies). Aucune solution n'est gratuite ; à l'oral, ce qui compte, c'est de dire laquelle tu as choisie et pourquoi.

Exercices

Exercice 1 sur 4 Vrai ou faux Réussi À revoir

  1. Une route protégée par RouteProtegee empêche un utilisateur non connecté de lire les fiches.
  2. Le jeton d'accès d'AppliFrais, gardé en mémoire, survit au rechargement de la page.
  3. Stocker le JWT dans localStorage est sans risque si l'application n'a pas de faille XSS connue.
  4. Après la déconnexion, il faut vider le cache des données côté front.
  5. Le front doit vérifier le mot de passe avant de l'envoyer à l'API.
Voir la réponseMasquer la réponse de l'exercice 1
  1. Faux. Elle empêche d'afficher l'écran. Les données sont protégées par l'API, qui répond 401 à une requête sans jeton valable.
  2. Faux. Le rechargement efface la mémoire de la page : la variable du module d'appels repart à null et l'utilisateur se reconnecte. Seul un jeton de rafraîchissement, dans un cookie HttpOnly, éviterait cette reconnexion.
  3. Faux. « Pas de faille connue » ne veut pas dire « pas de faille » : une dépendance compromise ou une erreur future suffit, et le jeton volé sert alors depuis n'importe où jusqu'à son expiration.
  4. Vrai. Sinon, l'utilisateur suivant sur le même poste peut voir les données du précédent.
  5. Faux. Le front peut vérifier qu'un champ est rempli, mais la vérification du mot de passe se fait sur le serveur, contre l'empreinte stockée en base (hacher un mot de passe). Le front ne connaît aucun mot de passe.

Tu avais trouvé ?

Exercice 2 sur 4 Recharger la page Réussi À revoir

Lucas est connecté et consulte /fiches/12. Il appuie sur F5. Décris ce qu'il voit, étape par étape. Un camarade propose de ranger le jeton dans localStorage « pour éviter ça » : pourquoi est-ce une mauvaise correction, et que faudrait-il faire à la place ?

Voir la réponseMasquer la réponse de l'exercice 2

Au rechargement, tout le JavaScript repart de zéro : la variable jeton du module d'appels vaut null, utilisateur aussi. RouteProtegee redirige vers /connexion en retenant depuis: '/fiches/12'. Aucune requête vers les fiches ne part, donc aucune erreur 401. Lucas se reconnecte : seConnecter obtient un nouveau jeton, puis le profil, et la page de connexion le renvoie directement sur la fiche 12.

Avec le jeton dans localStorage, la connexion survivrait au rechargement, mais un script injecté par une faille XSS pourrait lire le jeton et l'envoyer ailleurs, pour s'en servir depuis n'importe quelle machine jusqu'à son expiration. La bonne correction est côté API : un jeton de rafraîchissement dans un cookie HttpOnly; Secure; SameSite=Strict, invisible pour le JavaScript, échangé au démarrage contre un nouveau jeton d'accès. Le front aurait alors besoin d'un état de chargement au démarrage, le temps de cet échange.

Tu avais trouvé ?

Exercice 3 sur 4 Ajouter une page réservée Réussi À revoir

Ajoute une page PageValidation à l'adresse /validation, réservée aux comptables, et un lien « Fiches à valider » dans la navigation, visible seulement des comptables. Que doit faire l'API pour que cette restriction soit réelle ?

Voir la réponseMasquer la réponse de l'exercice 3

Dans le routeur, parmi les enfants de Layout :

tsx
{
  element: <RouteProtegee role="comptable" />,
  children: [{ path: 'validation', element: <PageValidation /> }],
},

Dans le Layout :

tsx
{utilisateur?.role === 'comptable' && <NavLink to="/validation">Fiches à valider</NavLink>}

Côté API, chaque route utilisée par cette page doit décider à partir du rôle inscrit dans le jeton, que sa signature empêche de modifier : POST /v1/fiches/{id}/validations répond 403 à un visiteur (exigerRole('comptable')), et GET /v1/fiches?etat=CL ne renvoie à un visiteur que ses propres fiches. Le front ne fait que cacher le lien et l'écran.

Tu avais trouvé ?

Exercice 4 sur 4 Analyser un code d'étudiant Réussi À revoir

Relève les problèmes de sécurité de ce code de connexion et propose une correction.

tsx
async function seConnecter(login: string, motDePasse: string) {
  const reponse = await fetch(`/v1/auth/connexion?login=${login}&mdp=${motDePasse}`);
  const { token, role } = await reponse.json();
  localStorage.setItem('token', token);
  localStorage.setItem('role', role);
}
Voir la réponseMasquer la réponse de l'exercice 4
  1. Les identifiants sont dans l'URL d'une requête GET : ils finissent dans les journaux du serveur et du proxy, dans l'historique du navigateur. Il faut un POST avec les identifiants dans le corps (l'API d'AppliFrais n'accepte d'ailleurs que POST /v1/auth/connexion).
  2. Aucune vérification de reponse.ok : en cas d'échec, token vaut undefined et l'application se croit connectée.
  3. Le jeton est dans localStorage, lisible par tout script de la page.
  4. Le rôle est dans localStorage : l'utilisateur peut le modifier dans les outils du navigateur (role = comptable) et voir les écrans du comptable. Si l'API se fiait au rôle envoyé par le front au lieu de celui du jeton, ce serait une élévation de privilèges.

Correction : utiliser seConnecter du contexte, qui envoie un POST par connexion(login, motDePasse) (une ApiError en cas d'échec), garde le jeton en mémoire avec definirJeton et lit le profil, rôle compris, avec GET /v1/auth/moi. Côté API, ne jamais faire confiance à un rôle envoyé par le client : le rôle se lit dans le jeton signé.

Tu avais trouvé ?

Chapitre terminé !

Fais les 4 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Tester des composants React avec Vitest et Testing LibraryRevoir le sommaire du cours