🔐 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 headerAuthorization) : préflight OPTIONS d'abord, car JSON + header custom sortent de la liste des requêtes simples.GET /posts/{id}(#128 MR3) : unGETsans body est normalement une requête simple, mais le headerAuthorization: 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 SQLAlchemyPost.owner), donc pas besoin de re-fetch la liste pour afficher le nouveau post :App.jsxl'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.