Tutoriel React — Chapitre 23

Authentification : login/logout, routes protégées, stockage du token

Chapitre 23 — Authentification : login/logout, routes protégées, stockage du token

L’authentification est un passage obligé dans la plupart des applications : espace membre, back‑office, panier, profil utilisateur, etc. Dans ce chapitre, on met en place une base simple et propre : un formulaire de login, un token stocké (version débutant), un état “connecté / déconnecté” centralisé, et des routes protégées avec React Router.

Objectif : comprendre (pas apprendre par cœur) Niveau : débutant absolu Pratique : mini-exercice à la fin

Plan du chapitre

1) Notions : authentification vs autorisation

Authentification : “Qui es‑tu ?” (ex : tu te connectes avec un email + mot de passe).
Autorisation : “As‑tu le droit ?” (ex : accès admin, accès à une page privée, etc.).

Dans ce chapitre, on met en place l’authentification côté React, et une autorisation simple : certaines routes seront accessibles uniquement si l’utilisateur est connecté.

2) Le scénario qu’on va implémenter

On veut :

  • Une page Login avec email + mot de passe
  • Un token reçu en réponse (simulé ou venant d’un backend)
  • Un état global : user / token / isAuthenticated
  • Une page Profil accessible seulement si connecté
  • Un bouton Logout

React est le client. Le backend (ou le mock) est responsable de vérifier les identifiants et de renvoyer un token.

3) API : endpoint /login (mock ou backend)

Dans la vraie vie, votre backend expose souvent un endpoint de type : POST /login qui renvoie un JSON.

Exemple de réponse attendue

{
  "token": "xxxxx.yyyyy.zzzzz",
  "user": {
    "id": 1,
    "email": "gandalf@mail.com",
    "name": "Gandalf"
  }
}

Pour un tutoriel, vous pouvez aussi “mock” l’API (simuler une réponse) le temps d’apprendre la mécanique. L’important ici est la structure : appel → réponse → stockage → navigation.

4) Login : formulaire contrôlé + validations

On crée un composant LoginPage avec un formulaire. “Contrôlé” signifie : les valeurs des inputs sont stockées dans le state.

import { useState } from "react";
import { useAuth } from "../contexts/AuthContext.jsx";

export default function LoginPage() {
  const { login } = useAuth();
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(false);

  async function handleSubmit(e) {
    e.preventDefault();

    // validations très simples (débutant)
    if (!email.trim() || !password.trim()) {
      setError("Email et mot de passe sont requis.");
      return;
    }

    setError(null);
    setLoading(true);

    try {
      await login({ email, password });
    } catch (err) {
      setError(err.message || "Connexion impossible.");
    } finally {
      setLoading(false);
    }
  }

  return (
    <div>
      <h2>Connexion</h2>

      {error && <p>Erreur : {error}</p>}

      <form onSubmit={handleSubmit}>
        <label>
          Email
          <input
            type="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
          />
        </label>

        <label>
          Mot de passe
          <input
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
          />
        </label>

        <button type="submit" disabled={loading}>
          {loading ? "Connexion..." : "Se connecter"}
        </button>
      </form>
    </div>
  );
}

Le composant appelle login() fourni par un contexte d’authentification. C’est lui qui fera l’appel API et stockera le token.

5) Stocker le token : version débutant (localStorage)

Pour débuter, on stocke le token dans localStorage afin de rester connecté après un refresh. Attention : c’est pratique, mais pas parfait niveau sécurité (on en parle plus loin).

localStorage.setItem("token", token);
const saved = localStorage.getItem("token");
localStorage.removeItem("token");

6) Centraliser l’état : AuthContext + useAuth()

On crée un contexte pour éviter de passer le token en props partout. Le contexte fournit : user, token, isAuthenticated, login(), logout().

Fichier : src/contexts/AuthContext.jsx

import { createContext, useContext, useMemo, useState } from "react";

const AuthContext = createContext(null);

export function AuthProvider({ children }) {
  const [token, setToken] = useState(() => localStorage.getItem("token") || "");
  const [user, setUser] = useState(() => {
    const raw = localStorage.getItem("user");
    return raw ? JSON.parse(raw) : null;
  });

  const isAuthenticated = !!token;

  async function login({ email, password }) {
    // Version tutoriel (mock) : remplacez par un vrai appel API ensuite.
    if (email === "gandalf@mail.com" && password === "12345") {
      const fakeToken = "demo-token-123";
      const fakeUser = { id: 1, email, name: "Gandalf" };

      setToken(fakeToken);
      setUser(fakeUser);
      localStorage.setItem("token", fakeToken);
      localStorage.setItem("user", JSON.stringify(fakeUser));
      return;
    }

    throw new Error("Identifiants incorrects.");
  }

  function logout() {
    setToken("");
    setUser(null);
    localStorage.removeItem("token");
    localStorage.removeItem("user");
  }

  const value = useMemo(() => ({
    token,
    user,
    isAuthenticated,
    login,
    logout
  }), [token, user, isAuthenticated]);

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

export function useAuth() {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error("useAuth doit être utilisé dans un AuthProvider.");
  return ctx;
}

Le mock sert à pratiquer le “flow”. Ensuite, remplacez login() par un appel via services/ (chapitre 22) vers un vrai backend.

7) Brancher un vrai backend : services/ + auth.service.js

Jusqu’ici, on a utilisé un mock pour apprendre la mécanique. Maintenant, on fait la version “projet” en s’appuyant sur la structure vue au chapitre 22 : un dossier services/ qui contient des fonctions d’accès API réutilisables.

Structure recommandée

src/
  services/
    apiClient.js
    auth.service.js
  contexts/
    AuthContext.jsx
  routes/
    ProtectedRoute.jsx

1) Un client API réutilisable : services/apiClient.js

Objectif : centraliser fetch, gérer les erreurs et ajouter automatiquement le token si présent.

const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:3000";

function getToken() {
  return localStorage.getItem("token") || "";
}

async function request(path, { method = "GET", body } = {}) {
  const headers = { "Content-Type": "application/json" };
  const token = getToken();
  if (token) headers.Authorization = `Bearer ${token}`;

  const res = await fetch(`${API_BASE_URL}${path}`, {
    method,
    headers,
    body: body ? JSON.stringify(body) : undefined,
  });

  // Gestion d’erreur standardisée
  if (!res.ok) {
    let message = "Erreur réseau.";
    try {
      const data = await res.json();
      message = data?.message || message;
    } catch {}
    const err = new Error(message);
    err.status = res.status;
    throw err;
  }

  // Certaines routes peuvent renvoyer 204 No Content
  if (res.status === 204) return null;
  return res.json();
}

export const apiClient = {
  get: (path) => request(path),
  post: (path, body) => request(path, { method: "POST", body }),
};

2) Le service d’auth : services/auth.service.js

import { apiClient } from "./apiClient.js";

export async function loginService({ email, password }) {
  // Backend attendu : POST /login
  return apiClient.post("/login", { email, password });
}

export async function meService() {
  // Backend attendu : GET /me (retourne l’utilisateur courant)
  return apiClient.get("/me");
}

Si votre backend n’utilise pas /login et /me, adaptez simplement les routes. Le principe (service dédié + client API) reste identique.

8) Recharger l’utilisateur au démarrage : endpoint /me

Problème classique : vous rechargez la page, le token est dans localStorage, mais vous n’avez plus l’objet user en mémoire (state). Solution : au démarrage, si un token existe, appeler /me.

Mise à jour de AuthContext (version backend)

import { createContext, useContext, useEffect, useMemo, useState } from "react";
import { loginService, meService } from "../services/auth.service.js";

const AuthContext = createContext(null);

export function AuthProvider({ children }) {
  const [token, setToken] = useState(() => localStorage.getItem("token") || "");
  const [user, setUser] = useState(null);
  const [loadingAuth, setLoadingAuth] = useState(true);

  const isAuthenticated = !!token;

  async function login({ email, password }) {
    const { token: newToken, user: newUser } = await loginService({ email, password });

    setToken(newToken);
    setUser(newUser);
    localStorage.setItem("token", newToken);
  }

  function logout() {
    setToken("");
    setUser(null);
    localStorage.removeItem("token");
  }

  useEffect(() => {
    async function bootstrap() {
      // Si pas de token, rien à recharger
      if (!token) {
        setLoadingAuth(false);
        return;
      }

      try {
        const me = await meService();
        setUser(me);
      } catch (err) {
        // Si token invalide/expiré, on force logout
        if (err.status === 401) logout();
      } finally {
        setLoadingAuth(false);
      }
    }

    bootstrap();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const value = useMemo(() => ({
    token,
    user,
    isAuthenticated,
    loadingAuth,
    login,
    logout,
  }), [token, user, isAuthenticated, loadingAuth]);

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

export function useAuth() {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error("useAuth doit être utilisé dans un AuthProvider.");
  return ctx;
}

Note : si vous souhaitez que la requête /me se relance quand token change, vous pouvez mettre [token] en dépendance, mais pour un flux débutant, le bootstrap “au montage” suffit.

9) Gestion des erreurs 401/403 : quoi faire côté React ?

  • 401 Unauthorized : l’utilisateur n’est pas authentifié (token manquant/expiré). → rediriger vers login.
  • 403 Forbidden : l’utilisateur est authentifié mais n’a pas les droits (rôle). → afficher “accès refusé”.

Réflexe simple

Si une requête renvoie 401, votre app doit considérer que la session n’est plus valide : logout + redirection vers /login.

try {
  const data = await apiClient.get("/private-data");
  // ...
} catch (err) {
  if (err.status === 401) {
    // logout + redirection (au choix)
  }
}

10) Routes protégées (React Router)

Une route protégée redirige vers /login si l’utilisateur n’est pas connecté. Pour ça, on crée un composant ProtectedRoute.

Fichier : src/routes/ProtectedRoute.jsx

import { Navigate, Outlet } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext.jsx";

export default function ProtectedRoute() {
  const { isAuthenticated } = useAuth();

  if (!isAuthenticated) {
    return <Navigate to="/login" replace />;
  }

  return <Outlet />;
}

Exemple de routes

import { Routes, Route } from "react-router-dom";
import LoginPage from "./pages/LoginPage.jsx";
import ProfilePage from "./pages/ProfilePage.jsx";
import ProtectedRoute from "./routes/ProtectedRoute.jsx";

export default function AppRoutes() {
  return (
    <Routes>
      <Route path="/login" element={<LoginPage />} />

      <Route element={<ProtectedRoute />}>
        <Route path="/profile" element={<ProfilePage />} />
      </Route>
    </Routes>
  );
}

11) Logout (déconnexion) + nettoyage

import { useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext.jsx";

export default function LogoutButton() {
  const { logout } = useAuth();
  const navigate = useNavigate();

  function handleLogout() {
    logout();
    navigate("/login");
  }

  return <button onClick={handleLogout}>Se déconnecter</button>;
}

12) Sécurité : bonnes pratiques (sans paniquer)

Débutant : localStorage est pratique, mais un XSS peut potentiellement le lire. En production, beaucoup d’apps préfèrent des cookies HTTPOnly (côté serveur).

  • Apprendre la mécanique d’abord, optimiser la sécurité ensuite.
  • Ne jamais injecter du HTML non fiable (risque XSS).
  • Ne stockez pas d’informations sensibles “en clair” côté client.

13) Résumé

  • Login : formulaire contrôlé → appel login()
  • Stockage token (débutant) : localStorage
  • Auth global : AuthContext + useAuth()
  • Routes privées : ProtectedRoute
  • Logout : nettoyage token + user

14) Exercice pratique

  1. Créer une page DashboardPage protégée (/dashboard).
  2. Sur le dashboard, afficher user.name si connecté.
  3. Créer une NavBar : afficher “Login” si déconnecté, “Logout” si connecté.
  4. Bonus : remplacer le mock par un vrai loginService() via services/auth.service.js (chapitre 22).
  5. Bonus 2 : ajouter un appel /me au démarrage et gérer le cas 401 en forçant un logout.

Conseil : commencez par le mock, puis branchez le backend une fois le flow maîtrisé.