Skip to content

🔐 Comprendre l'authentification frontend (JWT SPA ↔ API)

La SPA (#128, V2) ajoute un vrai parcours de connexion : inscription, login, appels authentifiés, déconnexion. Ce guide explique comment un jeton JWT circule entre le navigateur et l'API, où il est stocké côté client, et pourquoi le préflight CORS intervient sur certaines requêtes et pas d'autres. Le pourquoi des choix (stockage, écriture publique) est dans l'addendum V2 de l'ADR 028.


1. Le parcours login, étape par étape

sequenceDiagram
    participant U as Navigateur (app.devopsyouss.com)
    participant A as API (api.devopsyouss.com)

    U->>A: POST /login (form-urlencoded : username, password)
    A->>A: verifie le hash bcrypt du mot de passe
    A-->>U: 200 { access_token, token_type: "bearer" }
    U->>U: stocke le token (etat React + sessionStorage)

    Note over U,A: Requetes suivantes, tant que le token est valide

    U->>A: POST /posts/ (Authorization: Bearer <token>)
    A->>A: decode le JWT, verifie la signature + expiration
    A-->>U: 201 Post cree

Le token est un JWT signé (voir app/oauth2.py) : l'API n'a pas besoin de garder une session en mémoire ou en base pour le valider, elle vérifie juste la signature et l'expiration à chaque requête. C'est pour ça qu'il n'y a pas d'endpoint /logout côté API : « se déconnecter » ne veut rien dire côté serveur pour un JWT stateless, ça veut seulement dire « le client oublie le token ».


2. Où vit le token côté navigateur : 3 options, 1 choix

Stockage Survit à un F5 Survit à la fermeture de l'onglet Exposé au JS (donc à une faille XSS) Change le back
État React (mémoire) oui non
sessionStorage oui non
localStorage oui non
Cookie httpOnly non oui (Set-Cookie, CSRF)

Le cookie httpOnly est la référence en sécurité (le JS ne peut pas le lire, donc un XSS ne peut pas voler le token) — mais il change le contrat de l'API : elle doit répondre avec un header Set-Cookie, et il faut ajouter une protection CSRF (le cookie part automatiquement à chaque requête, y compris depuis un site tiers). C'était hors de la promesse « V2 = React uniquement, zéro changement back ».

Choix retenu : état React + sessionStorage. L'état React est la source de vérité pour l'affichage (le composant se re-rend quand auth change), sessionStorage est une copie de secours lue une seule fois au montage (loadSession() dans frontend/src/auth.js) pour ne pas perdre la session à un simple F5. localStorage aurait fait survivre le token à la fermeture du navigateur sans bénéfice pour une démo portfolio — juste plus de surface d'exposition en cas de XSS.

Ce n'est pas le niveau de sécurité qu'on choisirait pour une app avec des données sensibles en prod (paiement, santé...). Ici c'est un compromis assumé et documenté pour un projet de démonstration DevOps, où le contenu React est volontairement secondaire (cf. ADR 028).


3. Pourquoi certaines requêtes déclenchent un préflight CORS et d'autres non

Le navigateur envoie une requête OPTIONS de préflight avant la vraie requête seulement si elle sort d'une liste de requêtes dites « simples ». Concrètement, pour ce projet :

  • POST /login (Content-Type: application/x-www-form-urlencoded) : pas de préflight, c'est un type de contenu considéré comme simple par la spec CORS.
  • POST /users/, POST /posts/, POST /votes/ (Content-Type: application/json, et pour les deux derniers un header Authorization) : préflight OPTIONS d'abord, car JSON + header custom sortent de la liste des requêtes simples.
  • GET /posts/{id} (#128 MR3) : un GET sans body est normalement une requête simple, mais le header Authorization: Bearer <token> qu'on doit y ajouter suffit à lui seul à déclencher le préflight — c'est le header, pas la méthode ni le corps, qui décide.
sequenceDiagram
    participant U as Navigateur (app.)
    participant A as API (api.)

    Note over U,A: Requete JSON ou avec header Authorization
    U->>A: OPTIONS /posts/ (Origin: https://app.devopsyouss.com)
    A-->>U: 200 (Access-Control-Allow-Origin: https://app., allow-methods, allow-headers)
    U->>A: POST /posts/ (la vraie requete)
    A-->>U: 201

Le CORSMiddleware de FastAPI (app/main.py) répond à ces deux étapes : c'est lui qui décide, sur le préflight, si l'origine https://app.devopsyouss.com est autorisée (allow_origins) et quelles méthodes/headers le sont (allow_methods=["*"], allow_headers=["*"], posé en #110). Une origine hors liste ne reçoit aucun header Access-Control-Allow-Origin → le navigateur bloque la réponse côté client, même si l'API a bien traité la requête côté serveur.


4. Ce que MR1 (#128) couvre — et ce qu'elle ne couvre pas encore

MR1 pose uniquement la brique JWT : frontend/src/api.js (login()/register()), frontend/src/auth.js (lecture/écriture sessionStorage), frontend/src/AuthForms.jsx (formulaire connexion/inscription). Une fois connecté, l'utilisateur voit son email dans l'en-tête et peut se déconnecter — mais aucun appel authentifié n'est encore fait (pas de création de post, pas de vote). Ça arrive en MR2, qui consomme le auth.token posé ici pour ajouter le header Authorization: Bearer <token> aux requêtes d'écriture.


5. MR2 : écriture, et le cas du bouton vote sans « j'ai voté » côté API

MR2 ajoute deux appels authentifiés (frontend/src/api.js) qui réutilisent le même header Authorization: Bearer <token> :

  • createPost(token, { title, content })POST /posts/. La réponse contient déjà owner (résolu par la relation SQLAlchemy Post.owner), donc pas besoin de re-fetch la liste pour afficher le nouveau post : App.jsx l'ajoute directement en tête du feed.
  • castVote(token, postId, dir)POST /votes/ (dir: 1 = ajoute, dir: 0 = retire).

Le problème : aucun endpoint ne dit au client si l'utilisateur courant a déjà voté sur un post donné. PostOut (le format de /posts/public et /posts/) ne contient qu'un total (votes: int), pas un flag par utilisateur. Sans cette information, un bouton « voter » naïf redemanderait toujours dir=1, même sur un post déjà voté.

La solution retenue exploite les codes d'erreur de l'API elle-même comme source de vérité :

sequenceDiagram
    participant U as Navigateur
    participant A as API

    U->>A: POST /votes/ { post_id, dir: 1 }
    alt premier vote
        A-->>U: 201 - ajoute le vote
        Note over U: votedPostIds.add(id), compteur +1
    else vote deja existant
        A-->>U: 409 Conflict
        Note over U: on sait maintenant qu'un vote existe -> votedPostIds.add(id), compteur inchange
    end

Le clic suivant envoie dir: 0 (retrait). Un 201 confirme la suppression (compteur -1, votedPostIds.delete(id)) ; un 404 signifie qu'il n'y avait rien à retirer (l'état local était faux, on corrige sans toucher au compteur). Le tableau votedPostIds (un Set d'ids en état React) n'est donc pas une source de vérité indépendante : c'est un cache que les réponses HTTP corrigent en continu.

Limite assumée : cet état est local à l'onglet, pas persisté (contrairement au JWT, pas dans sessionStorage). Un F5 réaffiche tous les boutons comme « non voté », même pour des posts déjà votés par l'utilisateur — le premier clic suivant renverra alors un 409 que le client interprète correctement, sans casser le compteur. Le compteur affiché, lui, vient toujours du serveur et reste exact à tout moment.