Authentifier avec des cookie sur React

Voir la vidéo
Description Sommaire

Je vous propose aujourd'hui de découvrir comment mettre en place un système d'authentification au sein d'une application React. Notre objectif va être la création d'un hook useAuth() qui nous permettra de récupérer l'état d'authentification de l'utilisateur ?

Vive les cookies !

Lorsque l'on parle d'authentification il est souvent mentionné l'utilisation d'access token ou de token JWT. Même si ces techniques sont viables dans certaines situations, elles sont, dans la plupart des cas, plus complexes que nécessaire.

  • JWT, permet d'authentifier l'utilisateur de manière stateless, ce qui permet aux APIs de valider l'identité de l'utilisateur sans devoir contacter le serveur qui contient les identités (si côté serveur vous récupérez l'utilisateur a chaque requête, vous utilisez mal le token JWT).
  • Access Token / Auth Token, vont surtout être utiles en dehors du contexte du navigateur (application mobile / desktop) qui ne gèrent pas la logique des cookies.

Vu que les navigateurs supportent les cookies autant en profiter, surtout que cela offre plusieurs avantages :

  • Les cookies httpOnly sont sécurisés par défaut et du code JavaScript malicieux n'y aura pas accès (ce qui réduit les vecteurs d'attaque pour le vol de token).
  • Le navigateur gère leur invalidation nativement
  • Le serveur peut, lors d'une réponse, redéfinir le cookie pour rafraîchir les informations si nécessaire.

Le grand méchant CORS

En général l'application et l'API ne se situent pas sur le même domaine, et nos requêtes sont des requêtes cross-origin pour lesquelles le partage de ressources est limité par les politiques CORS du navigateur. Il est possible d'utiliser les cookies, mais cela nécessite une bonne compréhension de la situation et de la configuration à effectuer.

Côté front il faudra demander, lors d'une requête fetch, à inclure les identifiants.

fetch('https://domain.ltd', {
  credentials: 'include',
  // ...
})

Ensuite, côté serveur, il faudra répondre correctement en renvoyant les bonnes en-têtes.

  • Access-Control-Allow-Credentials devra être à true.
  • Access-Control-Allow-Origin devra accepter spécifiquement le domaine de notre application front (ne doit pas être un joker *).
  • Access-Control-Allow-Methods devra contenir les méthodes que l'on souhaite utiliser.

Enfin, côté cookie, il faudra avoir les bons attributs :

  • Si vos deux applications (api & front) sont sur le même domaine principal (eTLD+1), il n'y a rien de particulier à faire le cookie peut même avoir un SameSite: Strict.
  • Si vos deux applications ne partagent pas de domaine principal le cookie devra alors avoir un SameSite: none et être Secure pour que le navigateur accepte d'honorer les en-tête liées aux cookies.

Pour en apprendre plus sur le fonctionnement de SameSite je vous renvois sur les spécifications et pour mieux comprendre la politique des navigateurs l'article web.dev.

Côté React

Maintenant que la configuration serveur est faite on est prêt à s'attaquer à la partie React. On commence d'abord à réfléchir à ce que l'on veut obtenir (le fonctionnement du hook) avant d'attaquer le code. Pour le dérouler de notre application il nous faut savoir l'état d'authentification de l'utilisateur.

const {
    status,       // Contient le status de l'authentification
    authenticate, // Demande le status d'authentification
    login,        // Tente de connecter l'utilisateur
    logout,       // Déconnecte l'utilisateur
} = useAuth()

Dans le cas d'une authentification par cookie httpOnly on ne peut pas initialement déterminer si l'utilisateur est authentifié ou non sans contacter le serveur. On se retrouve donc avec trois états possible.

export enum AuthStatus {
  Unknown,
  Authenticated,
  Guest,
}
  • Unknown, avant de contacter le serveur on ne connait pas encore l'état d'authentification de l'utilisateur, on ne sait pas s'il est connecté ou non.
  • Authenticated, l'utilisateur est bien authentifié.
  • Guest, le serveur nous a répondu est l'utilisateur n'est pas authentifié.

Pour stocker les informations de l'utilisateur nous allons avoir besoin d'un store qui sera partagé par l'ensemble des composants. Pour cela on peut utiliser un contexte ou se reposer sur une librairie pour centraliser les choses comme jotai ou zustand.

import { create } from "zustand";
import { combine } from "zustand/middleware";
import { Account } from "./types.ts";

export const useAccountStore = create(
  combine(
    {
      account: undefined as undefined | null | Account,
    },
    (set) => ({
      setAccount: (account: Account | null) => set({ account }),
    })
  )
);

Et on pourra utiliser ce store dans notre hook et créer les différentes méthodes permettant de gérer les opérations de bases.

import { Account } from "../types.ts";
import { useAccountStore } from "../store.ts";
import { useCallback } from "react";
import { apiFetch } from "../utils/api.ts";

export function useAuth() {
  const { account, setAccount } = useAccountStore();
  let status;
  switch (account) {
    case null:
      status = AuthStatus.Guest;
      break;
    case undefined:
      status = AuthStatus.Unknown;
      break;
    default:
      status = AuthStatus.Authenticated;
      break;
  }

  const authenticate = useCallback(() => {
    apiFetch<Account>("/me")
      .then(setAccount)
      .catch(() => setAccount(null));
  }, []);

  const login = useCallback((username: string, password: string) => {
    apiFetch<Account>("/login", { json: { username, password } }).then(
      setAccount
    );
  }, []);

  const logout = useCallback(() => {
    apiFetch<Account>("/logout", { method: "DELETE" }).then(setAccount);
  }, []);

  return {
    status,
    authenticate,
    login,
    logout,
  };
}

Maintenant, au chargement de l'application, il nous faut savoir si l'utilisateur est authentifié ou non. Sur notre composant racine (qui n'est jamais remonté) on peut lancer l'authentification grâce à la méthode authenticate.

function App() {
  const { status, authenticate } = useAuth();

  useEffect(() => authenticate(), [])

  if (status === AuthStatus.Unknown) {
    return <Loader />;
  }

  if (status === AuthStatus.Guest) {
    return <Login />
  }

  return <Dashboard />
}

Et voilà ! Vous pouvez aussi vous créer un hook pour récupérer les informations de l'utilisateur qui ne pourra être utilisé qu'à l'intérieur de composant authentifiés.

import { useAuth } from "./useAuth.ts";

export function useAccount() {
  const { account } = useAccountStore();

  if (!account) {
    throw new Error("User is not authenticated");
  }

  return {
    account,
  };
}

Ce hook peut intégrer des méthodes supplémentaires en fonction de vos besoins. Mais vous pouvez aussi vous créer des hooks plus spécifiques en fonction de vos besoins.

Améliorer le chargement initial

Le principal problème de notre approche et des cookies httpOnly est que l'application doit commencer par contacter le serveur pour connaitre le niveau d'authentification avant de faire quoi que ce soit. Cela ajoute un délai supplémentaire à l'affichage de l'application. On peut remédier à ce problème en étant "optimiste" sur l'état d'authentification de l'utilisateur.

On commence par persister l'utilisateur dans le localStorage (dans le cas de zustand il nous suffit d'ajouter le middleware persist). On change aussi la valeur par défaut de account pour mettre null (en l'absence de localStorage, on considère que l'utilisateur n'est pas connecté).

import { create } from "zustand";
import { combine, persist } from "zustand/middleware";
import { Account } from "./types.ts";

export const useAccountStore = create(
  persist(
    combine(
      {
        account: null as undefined | null | Account,
      },
      (set) => ({
        setAccount: (account: Account | null) => set({ account }),
      })
    ),
    { name: "account" }
  )
);

Maintenant, quand l'utilisateur se connecte, en plus du cookie, on va ajouter les informations au localStorage. Si l'utilisateur recharge l'application, ses informations seront récupérées depuis le stockage et on le considérera connecté par défaut (au lieu de l'état Unknown).

En revanche, une désynchronisation peut avoir lieu si le cookie expire par exemple et que les informations restent dans le localStorage.

  • L'application considère l'utilisateur tel qu'un utilisateur connecté (à cause du localStorage)
  • L'API renverra des erreurs, car de son côté, l'utilisateur n'est plus connecté.

Dans cette situation, on peut se brancher sur les retours de l'API pour détruire l'état d'authentification en cas de retour non autorisé.

const r = await fetch(/* ... */*)
if (!r.ok && r.status === 401) {  
    localStorage.removeItem("account");  
    window.location.reload();
}
Publié
Technologies utilisées
Auteur :
Grafikart
Partager