Aller au contenu
English

Choix de session dans le navigateur

  • Leçon 8
  • 50 min
  • Module 3 : Sessions et intégration à l'application

Ce que vous saurez faire

Permettez aux gens de se connecter au service de soutien dans un navigateur, sans lui donner un jeton qu’un script pourrait voler. L’API garde les jetons sur le serveur et donne au navigateur seulement un témoin de session. Protégez ensuite ce témoin contre les requêtes lancées par d’autres sites.

Avant de commencerCe qu’il faut savoir et avoir sous la mainEnviron 50 minutes

Ce que vous devez savoir

Ce qu’il vous faut sur votre ordinateur

  • Le projet après le module 2, dans ~/projects/ticket-api : python -m pytest affiche 181 passed (181 tests réussis).
  • PostgreSQL et Azurite en marche, avec les données des deux boutiques chargées, et le fournisseur d’exercice configuré avec python -m idp init.
  • Le dépôt des exercices à côté du projet, dans ~/projects/nextia-learning-labs. Exécutez-y git pull pour obtenir l’instantané end-of-m03.
  • Un navigateur. Les étapes utilisent les outils de développement de Chrome; Firefox et Safari offrent les mêmes panneaux sous d’autres noms.

Vous n’avez besoin ni de compte ni d’argent. Testé sur macOS (Apple silicon) avec Python 3.12 et Chromium 156; les commandes Windows sont fournies, mais non testées.

À un festival de musique, vous montrez votre billet une seule fois, à l’entrée. Le personnel le vérifie et fixe un bracelet à votre bras. Pendant le reste de la fin de semaine, vous montrez le bracelet au bar et devant la scène. Personne ne vous redemande le billet.

Certaines personnes font autrement. Elles gardent le billet papier dans une poche arrière et le montrent partout. Un voleur qui le prend peut l’utiliser à une autre entrée, car le billet fonctionne pour quiconque le possède. Le bracelet est plus sûr : il est fixé à votre bras, et personne ne peut l’enlever sans le couper.

Le bracelet a un point faible. Il fonctionne partout où va votre bras. Un inconnu peut tirer votre bras au-dessus du bar et commander une boisson. Le barman voit un bracelet valide et ajoute la boisson à votre facture. Un bar prudent demande donc aussi un mot de passe que le personnel vous a donné à l’entrée. L’inconnu qui tire votre bras ne le connaît pas.

Une preuve qu’on ne peut pas prendre, son point faible et un mot de passe : voilà les trois parties de cette leçon.

À la fin du module 2, scripts.login a connecté Sam et affiché un jeton d’accès. Un programme en ligne de commande peut garder ce jeton en mémoire et l’envoyer dans l’en-tête Authorization. Le service de soutien a maintenant besoin d’une page dans un navigateur. Où cette page garde-t-elle les jetons?

Une réponse courante est le stockage local du navigateur (localStorage), un petit espace que le JavaScript de chaque site peut lire et modifier. La page y lit le jeton d’accès avant chaque requête. Elle y garde aussi le jeton de renouvellement pour obtenir de nouveaux jetons sans nouvelle connexion.

Cela fonctionne jusqu’à ce que le JavaScript de quelqu’un d’autre s’exécute dans la page. C’est le script intersite (XSS) : du texte provenant d’un attaquant arrive dans la page sous forme de code. Par exemple, un client écrit <script>…</script> dans le message d’un billet de soutien, et une page affiche le message sans l’échapper. Le script s’exécute alors avec les mêmes droits que le code de la page. Il peut lire tout ce que la page peut lire.

Voici un cas construit qui montre ce que cela signifie. L’auteur a connecté Sam avec scripts.login et placé son jeton d’accès dans localStorage sur la page de soutien dans Chromium. Puis l’auteur a exécuté une ligne, comme le ferait un script injecté :

localStorage.getItem("access_token") // 732 characters: Sam's access token

Ensuite, l’auteur a utilisé la chaîne volée depuis un terminal, comme le ferait un attaquant depuis un autre ordinateur :

curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer <the 732 characters>" http://127.0.0.1:8000/v1/me
200

L’API a répondu comme prévu : le jeton est valide, donc l’appelant est Sam. L’API ne peut pas savoir que le jeton a quitté le navigateur. C’est un billet papier, et il fonctionne pour quiconque le possède. Le jeton d’accès dure 10 minutes, mais le jeton de renouvellement dans le même stockage crée de nouveaux jetons pendant 8 h.

L’idée : le serveur garde les jetons, le navigateur garde un bracelet

Section intitulée « L’idée : le serveur garde les jetons, le navigateur garde un bracelet »

La solution consiste à garder les jetons complètement hors du navigateur. L’API de billets effectue elle-même la connexion, comme serveur de sa propre page. Elle échange le code contre les jetons, les garde sur le serveur et donne au navigateur seulement un identifiant de session aléatoire dans un témoin.

Un témoin (cookie) est une petite valeur qu’un site demande au navigateur de garder. Le navigateur la renvoie avec chaque requête à ce site, sans JavaScript. Une session est l’enregistrement d’une connexion sur le serveur : qui s’est connecté, quand et jusqu’à quand. Le témoin désigne la session. Cette architecture porte un nom : un backend for frontend (BFF), un serveur qui gère la sécurité de sa propre interface.

L’attribut HttpOnly du témoin joue le rôle du bracelet. Il dit au navigateur : envoyez ce témoin avec les requêtes, mais ne le montrez jamais à JavaScript. Un script injecté qui lit document.cookie reçoit une chaîne vide. Il n’y a aucun jeton à envoyer ailleurs, car la page n’en a jamais eu.

Deux conceptions côte à côte. A, jetons gardés dans le navigateur : la page garde un jeton d'accès (10 minutes) et un jeton de renouvellement (8 h) dans localStorage. Un script injecté lit tout ce qui s'y trouve avec localStorage.getItem et obtient le jeton de 732 caractères. Il envoie le jeton à l'ordinateur de l'attaquant, où curl avec Authorization : Bearer obtient 200, usr-sam. B, jetons gardés par l'API (backend for frontend) : la page du navigateur possède seulement le témoin ticket_session, HttpOnly, SameSite=Lax. Le script injecté ne peut pas lire le témoin : document.cookie est vide, donc il n'y a rien à envoyer ailleurs. Le navigateur ajoute le témoin à chaque requête vers ticket-api, qui garde le SHA-256 du témoin et le jeton de renouvellement chiffré dans sa table sessions. Une flèche pointillée du script injecté vers ticket-api indique qu'il peut encore envoyer des requêtes pendant que la page est ouverte.
L'endroit où se trouvent les jetons détermine ce qu'un script injecté peut voler. En A, il prend un jeton qui fonctionne partout pendant des heures. En B, il ne trouve rien à prendre, mais il peut encore agir dans la page ouverte.
Description textuelle du diagramme

Dans la conception A, le jeton se trouve là où n’importe quel script de la page peut le lire. Dans la conception B, le navigateur détient seulement un témoin que JavaScript ne peut pas lire. Regardez maintenant la flèche pointillée en B. Un script injecté peut encore appeler l’API depuis la page ouverte, car le navigateur ajoute le témoin. Ainsi, HttpOnly limite les dommages à la page ouverte; il ne rend pas le XSS sans danger. Vous devez toujours échapper chaque valeur que vous affichez dans une page.

Prenez une session fictive. La valeur du témoin est une chaîne aléatoire, par exemple larkfield-practice-cookie-0001 (une vraie valeur contient 43 caractères aléatoires). L’API ne stocke pas cette valeur. Elle stocke son SHA-256, le même type d’empreinte que dans PKCE :

Qui garde quoi Valeur
Le navigateur : témoin ticket_session larkfield-practice-cookie-0001
L’API : sessions.session_sha256 4915b043c9618e3f1de7985beec1ad89f6dba7dc527a4fb816f423b6d03bc527
L’API : sessions.refresh_token_encrypted octets que seule SESSION_KEY peut déchiffrer
Un script dans la page : document.cookie ""

Pourquoi l’empreinte? Une copie de la table sessions, provenant par exemple d’une sauvegarde, donne 64 caractères hexadécimaux par session. On ne peut pas les reconvertir en témoin. L’API calcule le SHA-256 du témoin de chaque requête et cherche cette valeur.

Le point faible : le navigateur envoie le témoin tout seul

Section intitulée « Le point faible : le navigateur envoie le témoin tout seul »

Le navigateur ajoute le témoin à chaque requête vers l’API, peu importe qui a lancé la requête. C’est le point faible du bracelet. Une page d’un autre site peut contenir un formulaire qui envoie POST http://127.0.0.1:8000/auth/logout dès qu’elle s’ouvre. Si le navigateur ajoutait le témoin de Sam, l’API déconnecterait Sam, et Sam ne saurait pas pourquoi. Avec les routes de billets du module 4, le même procédé pourrait ajouter un message au nom de Sam.

Cette attaque est la falsification de requête intersite (CSRF) : un autre site fait envoyer à votre navigateur une requête que votre témoin rend valide. L’attaquant ne voit jamais le témoin. Il profite seulement de l’habitude du navigateur de l’envoyer.

Deux verrous la bloquent, un dans le navigateur et un dans l’API :

  1. SameSite=Lax sur le témoin. Le navigateur n’envoie pas le témoin avec un POST ou un fetch lancé par un autre site. Il l’envoie quand même quand vous suivez un lien d’un autre site vers l’API, car c’est une visite normale. Un GET ne doit donc jamais rien changer.
  2. Un jeton CSRF, le mot de passe. À la connexion, l’API crée une deuxième valeur aléatoire pour la session. La page la lit depuis GET /auth/session et l’envoie dans l’en-tête X-CSRF-Token avec chaque requête qui change quelque chose. Un autre site ne peut pas obtenir cette valeur, car le navigateur empêche un site de lire les réponses d’un autre. Il ne peut donc pas envoyer l’en-tête.

L’auteur a testé les quatre cas dans Chromium avec l’API d’exercice. L’autre site était une page construite sur http://localhost:8099 :

Quatre chemins de requête et leurs résultats réels. Un : le formulaire d'un autre site envoie POST /auth/logout. Avec SameSite=Lax, le navigateur n'envoie aucun témoin avec un POST lancé par un autre site. L'API répond 401 not_signed_in, et Sam reste connecté. Deux : le lien d'un autre site ouvre GET /auth/session. Le navigateur envoie le témoin avec un lien qui ouvre la page au premier niveau. La réponse est 200 avec signed_in : true; un GET ne fait que lire. Trois : une requête avec le témoin et sans X-CSRF-Token, construite avec curl. L'API trouve un témoin valide et aucun jeton CSRF, et répond 403 csrf_failed. Quatre : la page /app/ elle-même envoie POST /auth/logout avec X-CSRF-Token. Le témoin est valide et le jeton correspond à la session, donc la réponse est 200 signed_out : true.
Les deux verrous, testés. SameSite=Lax bloque le témoin sur le POST d'un autre site; le jeton CSRF bloque toute requête qui possède le témoin, mais pas le mot de passe.
Description textuelle du diagramme

Les deux premières lignes montrent la décision du navigateur : cette requête reçoit-elle le témoin? Les deux dernières montrent la décision de l’API : le jeton CSRF est-il là aussi? La troisième ligne simule un navigateur qui a quand même envoyé le témoin, par exemple un vieux navigateur qui ignore SameSite. Le jeton CSRF le bloque quand même. Un fetch depuis l’autre site a aussi échoué (Failed to fetch, échec de la récupération), car l’API n’autorise aucun autre site dans ses paramètres CORS.

Kwame, l’architecte, pose encore ses trois questions : qui fait l’appel, que peut cette personne faire, dans quelle organisation? Pour un navigateur, il en ajoute une quatrième : « Où conserve-t-on la preuve de la première réponse? » La réponse d’Amira est la conception présentée plus haut. L’API sert une petite page à /app/, à la même adresse que l’API. La page et l’API sont donc sur le même site. Les jetons ne quittent jamais le serveur.

Tomás demande : « Pourquoi ne pas garder le jeton dans une variable JavaScript? » Amira répond : « Un script injecté s’exécute dans la même mémoire. Il peut donc aussi lire la variable. Avec le témoin, il n’y a aucun jeton dans le navigateur. »

Exercice guidé : ajouter la connexion dans le navigateur

Section intitulée « Exercice guidé : ajouter la connexion dans le navigateur »

Le code se trouve dans l’instantané end-of-m03. Copiez-le, puis lisez les parties importantes ci-dessous.

  1. Dans ~/projects/ticket-api, avec l’environnement virtuel actif, copiez le code de session, la migration et les tests :

    Fenêtre de terminal
    S=../nextia-learning-labs/C23/snapshots/end-of-m03
    mkdir -p ticket_api/static
    for f in migrations/015_sessions.sql ticket_api/sessions.py ticket_api/login.py \
    ticket_api/static/app.html ticket_api/auth.py ticket_api/config.py \
    ticket_api/errors.py ticket_api/main.py ticket_api/repository.py \
    ticket_api/security.py tests/test_sessions.py tests/conftest.py \
    contract/openapi.json .env.example; do cp "$S/$f" "$f"; done

    Vous avez copié 9 fichiers modifiés et 5 nouveaux fichiers. Le nouveau code ne demande aucun nouveau paquet : le chiffrement vient du paquet cryptography, que PyJWT a déjà installé.

  2. Ajoutez quatre lignes à .env. Ouvrez le fichier dans votre éditeur et ajoutez-les à la fin :

    Fenêtre de terminal
    OIDC_CLIENT_ID=help-desk-web
    OIDC_REDIRECT_URI=http://127.0.0.1:8000/auth/callback
    POST_LOGOUT_REDIRECT_URI=http://127.0.0.1:8000/app/
    SESSION_COOKIE_SECURE=false

    Les trois premières décrivent l’inscription de l’API chez le fournisseur; Configuration du fournisseur explique chacune d’elles. La dernière ligne sert seulement sur votre ordinateur. Le cas d’échec ci-dessous montre pourquoi. OIDC_CLIENT_SECRET et SESSION_KEY sont déjà dans .env : python -m idp init les a écrites au module 2.

  3. Appliquez la nouvelle migration. Elle ajoute deux tables, login_requests et sessions :

    Fenêtre de terminal
    python -m ticket_api.migrate
    applied 015_sessions
    Database is up to date (15 migrations).
  4. Exécutez les tests :

    Fenêtre de terminal
    python -m pytest
    193 passed, 1 deselected

    Les 12 nouveaux tests dans tests/test_sessions.py jouent le rôle du navigateur et suivent chaque redirection manuellement.

  5. Démarrez le fournisseur dans un terminal et l’API dans un deuxième, avec l’environnement virtuel actif dans les deux. Si le fournisseur fonctionne encore depuis le module 2, gardez-le. Si l’API fonctionne, arrêtez-la avec Ctrl+C et redémarrez-la pour qu’elle lise les nouvelles lignes de .env :

    Fenêtre de terminal
    python -m idp
    Fenêtre de terminal
    fastapi dev
  6. Ouvrez http://127.0.0.1:8000/app/ et choisissez Sign in (se connecter). La page du fournisseur nomme l’application « Help desk (web) » (service de soutien web) et indique ses portées. Choisissez Sam. Vous revenez à /app/ :

    La page d'exercice Help desk après la connexion. Une note grise indique que la page ne voit jamais de jeton : le navigateur garde seulement un témoin de session HttpOnly. Le JavaScript de cette page ne peut pas le lire. La ligne d'état indique : Signed in as usr-sam (connecté comme usr-sam). La session se termine au plus tard le 10 octobre 2026 à 14 h 32 min 48 s, ou après 30 minutes sans requête. Deux boutons : Switch account (changer de compte) et Sign out (se déconnecter). Sous le titre Who am I? (qui suis-je?), la réponse de /v1/me : user_id usr-sam, kind user, name Sam, client_id help-desk-web, les portées members:manage, tickets:read et tickets:write, et une adhésion : larkfield, Larkfield, staff.
    La page du service de soutien après la connexion de Sam, dans Chromium. La page a appelé /v1/me avec seulement le témoin; elle n'a aucun jeton à envoyer. L’image est en anglais.
  7. Ouvrez les outils de développement (F12 sur Windows, Cmd+Option+I sur macOS). Choisissez Application, puis Cookies (témoins) et http://127.0.0.1:8000. Trouvez ticket_session. La colonne HttpOnly est cochée, et SameSite indique Lax.

  8. Dans la Console des mêmes outils, tapez document.cookie et appuyez sur Entrée. La réponse est '', une chaîne vide. Tapez ensuite await (await fetch('/v1/me')).json(). Vous obtenez la réponse de Sam : la page peut utiliser la session, mais ne peut pas la voir.

Le navigateur cache les redirections, tandis que curl les montre. Gardez les deux programmes actifs et utilisez un fichier de témoins : curl y garde les témoins, comme un navigateur. Chaque commande affiche une valeur intermédiaire.

Fenêtre de terminal
TO=$(curl -s -o /dev/null -w '%{redirect_url}' -c jar.txt "http://127.0.0.1:8000/auth/login?return_to=/app/")
echo "$TO"
BACK=$(curl -s -o /dev/null -w '%{redirect_url}' -b jar.txt -c jar.txt -d user=usr-sam "$TO")
echo "$BACK"
curl -s -i -b jar.txt -c jar.txt "$BACK"
curl -s -i -b jar.txt http://127.0.0.1:8000/v1/me

La sortie, avec les longues valeurs aléatoires de la première ligne abrégées (…) et les en-têtes date et server omis :

http://localhost:8400/authorize?response_type=code&client_id=help-desk-web&redirect_uri=http%3A%2F%2F127.0.0.1%3A8000%2Fauth%2Fcallback&scope=openid+profile+email+offline_access+tickets%3Aread+tickets%3Awrite+members%3Amanage&state=-NISkOf_…&nonce=AyxDqtfT…&code_challenge=U-CpPRKr…&code_challenge_method=S256
http://127.0.0.1:8000/auth/callback?code=tpddqllbFgemr2MCH2PyyLqTNGbuH-x5nN0sbpESlE4&state=hlsGqhUFC8usBnSZEoIoYTg1nkXNDBe4
HTTP/1.1 302 Found
location: /app/
set-cookie: ticket_session=p2nTgBlu45F5TGrc7vJBRszoLlKCf0BjfNNhbs_ntvk; HttpOnly; Max-Age=28800; Path=/; SameSite=lax
HTTP/1.1 200 OK
content-type: application/json
cache-control: no-store
{"user_id":"usr-sam","kind":"user","name":"Sam","platform_role":null,"client_id":"help-desk-web","scopes":["members:manage","tickets:read","tickets:write"],"token_expires_at":"2026-10-10T14:31:02.535232Z","memberships":[{"tenant_id":"larkfield","tenant_name":"Larkfield","role":"staff"}]}

La première ligne est l’adresse que l’API a construite, avec state, nonce et le défi PKCE de Flux d’application. La deuxième ligne est la réponse du fournisseur à -d user=usr-sam, le clic sur Sam : seulement un code à usage unique. Le rappel a échangé le code sur le serveur. Le navigateur reçoit donc seulement Set-Cookie. Max-Age=28800 correspond à 8 heures; Secure est absent à cause de SESSION_COOKIE_SECURE=false. La dernière réponse vient de /v1/me, avec le témoin et sans jeton. Pour une session, token_expires_at indique la fin de la session. cache-control: no-store demande au navigateur de ne pas garder une copie de la réponse d’une personne.

Ouvrez jar.txt dans votre éditeur. Il contient deux lignes qui commencent par #HttpOnly_ : une pour 127.0.0.1 (ticket_session) et une pour localhost (idp_session, le témoin du fournisseur). La prochaine leçon montre pourquoi ce deuxième témoin est important.

Testez maintenant le mot de passe convenu. Déconnectez-vous sans lui, puis avec lui :

Fenêtre de terminal
curl -s -X POST -b jar.txt http://127.0.0.1:8000/auth/logout
CSRF=$(curl -s -b jar.txt http://127.0.0.1:8000/auth/session | python -c "import sys, json; print(json.load(sys.stdin)['csrf_token'])")
curl -s -X POST -b jar.txt -c jar.txt -H "X-CSRF-Token: $CSRF" http://127.0.0.1:8000/auth/logout
{"error":{"code":"csrf_failed","message":"This request needs the X-CSRF-Token header of your session.","request_id":"36f61abb4c6a","fields":[]}}
{"signed_out":true,"provider_logout_url":"http://localhost:8400/logout?post_logout_redirect_uri=http%3A%2F%2F127.0.0.1%3A8000%2Fapp%2F"}

La première requête avait un témoin valide, mais aucun mot de passe convenu : 403 csrf_failed (échec de la vérification CSRF). La deuxième avait les deux. À la fin du cours, la même vérification protège chaque route qui modifie un billet. Un nouveau message avec le témoin, mais sans jeton CSRF, reçoit alors 403; avec le jeton, il reçoit 201.

Trois endroits du code copié mettent en œuvre la conception. Ouvrez chacun d’eux.

  • ticket_api/login.py, set_cookie() : httponly=True, secure= vient de SESSION_COOKIE_SECURE, samesite="lax". Un commentaire sur chaque ligne indique ce qu’elle bloque.
  • ticket_api/sessions.py, create() : une nouvelle valeur aléatoire pour chaque connexion; la table garde seulement son empreinte SHA-256. Supposons que l’API réutilise une valeur fournie par le navigateur. Un attaquant pourrait alors placer une valeur et attendre que la victime se connecte (fixation de session).
  • ticket_api/security.py, current_identity() : l’API vérifie un jeton porteur comme au module 2. Sans jeton, l’API utilise le témoin. Toute méthode autre que GET, HEAD ou OPTIONS doit aussi fournir X-CSRF-Token.

Vous pouvez voir le côté serveur dans la base de données. Dans un terminal, dans le dossier du projet :

Fenêtre de terminal
docker compose exec db psql -U tickets -c "SELECT left(session_sha256, 16) AS sha256_start, user_id, refresh_token_encrypted IS NOT NULL AS refresh_kept, end_reason FROM sessions ORDER BY created_at DESC LIMIT 1;"
sha256_start | user_id | refresh_kept | end_reason
------------------+---------+--------------+------------
f1410dbe80b1701d | usr-sam | t | logout
(1 row)

La ligne garde la déconnexion et sa raison, mais pas le témoin.

La connexion commence à /auth/login?return_to=/app/, et le rappel envoie le navigateur à return_to. Un attaquant pourrait envoyer à Sam un lien vers la vraie connexion avec return_to=https://evil.example/. Après une vraie connexion, Sam arriverait sur une page qui ressemble seulement au service de soutien. Cette erreur est une redirection ouverte.

Prédisez où le rappel envoie le navigateur. Puis exécutez encore la séquence curl avec return_to=https://evil.example/ dans la première commande. Lisez la ligne location (destination) du rappel.

Afficher la réponse
location: /app/

safe_return_to() dans login.py accepte seulement un chemin sur ce site. Il doit commencer par /, et non par //, et ne contenir aucun \. Toute autre valeur devient /app/. Le test test_return_to_cannot_send_you_to_another_site le vérifie.

Exécutez aussi la commande de rappel (la troisième) une deuxième fois. La réponse est 400 login_failed (échec de la connexion) : « Cette connexion est inconnue, a déjà été utilisée ou est trop ancienne. Recommencez. » Chaque state fonctionne une seule fois, pendant 10 minutes.

Quand utiliser une session côté serveur, quand l’éviter et ce qu’elle coûte

Section intitulée « Quand utiliser une session côté serveur, quand l’éviter et ce qu’elle coûte »

Utilisez-la quand votre propre site sert les pages et l’API, comme ici : un service de soutien, un outil interne. Chaque jeton reste sur le serveur, et vous contrôlez la déconnexion et l’expiration. Ne l’utilisez pas pour un programme qui n’est pas un navigateur. Le client en ligne de commande et une application mobile gardent eux-mêmes leurs jetons et envoient Authorization: Bearer. L’API accepte les deux.

Les coûts : le serveur garde un état (une ligne par connexion, une lecture et une écriture en base par requête). Chaque route qui change quelque chose demande aussi la vérification CSRF. Une interface sur un autre site demande CORS et SameSite=None, ce qui retire le premier verrou. Cette conception dépasse le cadre de ce cours.

Erreur courante

La connexion fonctionne dans Chrome et échoue dans Safari

Symptôme : vous choisissez Sam sur la page du fournisseur et revenez à /app/, mais la page indique « You are not signed in. » (vous n’êtes pas connecté). Dans Chrome et Firefox, les mêmes étapes fonctionnent.

Cause : la ligne SESSION_COOKIE_SECURE=false manque dans .env, alors l’API utilise sa valeur par défaut, true. Un témoin Secure (sécurisé) peut seulement circuler par HTTPS. Chrome et Firefox font une exception pour 127.0.0.1 et localhost; WebKit, le moteur de Safari, n’en fait pas. L’auteur a fait le test avec SESSION_COOKIE_SECURE=true sur http://127.0.0.1. Chromium 156 et Firefox 157 ont permis la connexion. WebKit 27.2 a répondu {"signed_in":false}.

Diagnostic : ouvrez le rappel dans le panneau Network (réseau) des outils de développement. Son en-tête set-cookie se termine par ; Secure, et le navigateur indique que le témoin est bloqué. python -m scripts.check_identity, de Configuration du fournisseur, affiche PROBLEM cookie Secure=True, redirect URI over HTTP (problème : témoin Secure=True, URI de redirection sur HTTP).

Correction :

  1. Ajoutez SESSION_COOKIE_SECURE=false à .env.
  2. Arrêtez fastapi dev avec Ctrl+C et redémarrez-le pour qu’il lise .env.
  3. Connectez-vous de nouveau.

Sur un serveur, faites l’inverse : servez l’API par HTTPS et gardez la valeur par défaut true. Toute personne sur le même réseau peut lire un témoin de session qui circule par HTTP non chiffré.

Vérifiez votre compréhension

Un script injecté s'exécute dans la page /app/ pendant que Sam est connecté. Que peut-il emporter, et que peut-il encore faire? Comparez avec une page qui garde les jetons dans localStorage.

Afficher la réponse

Avec la session côté serveur, il ne peut rien emporter : le témoin est HttpOnly, donc document.cookie est vide. La page n’a aucun jeton. Mais il peut encore envoyer des requêtes depuis la page ouverte avec le témoin de Sam, jusqu’à sa fermeture ou à la fin de la session. Avec des jetons dans localStorage, le script envoie les jetons d’accès et de renouvellement ailleurs. L’attaquant les utilise ensuite depuis n’importe quel ordinateur. Dans le test construit, curl a reçu 200 comme Sam avec le jeton volé.

Vérifiez votre compréhension

Prédisez : une page d'un autre site envoie un formulaire avec POST /auth/logout à l'API de billets pendant que Sam est connecté. Que répond l'API, et pourquoi? Que répondrait-elle si le navigateur ignorait SameSite?

Afficher la réponse

401 not_signed_in (non connecté), et Sam reste connecté. Le témoin a SameSite=Lax, donc le navigateur ne l’envoie pas avec un POST lancé par un autre site. L’API ne voit donc aucune session. Si le navigateur envoyait quand même le témoin, l’API répondrait 403 csrf_failed, car la requête n’a pas d’en-tête X-CSRF-Token. L’autre site ne peut pas obtenir le jeton CSRF : il se trouve seulement dans la réponse de GET /auth/session. Le navigateur empêche un autre site de lire cette réponse.

Vérifiez votre compréhension

Exercice pratique : montrez que le navigateur a le témoin de session, que le JavaScript de la page ne peut pas le lire et que la base de données ne le contient pas.

Afficher la réponse

Dans les outils de développement, Application → Cookies → http://127.0.0.1:8000 affiche ticket_session avec HttpOnly coché et SameSite à Lax. Dans la Console, document.cookie renvoie '', tandis que fetch('/v1/me') renvoie encore la réponse de Sam. Dans psql, SELECT left(session_sha256, 16), user_id FROM sessions montre une empreinte hexadécimale, pas la valeur du témoin. Le témoin a 43 caractères; session_sha256 a 64 caractères hexadécimaux qui ne permettent pas de retrouver le témoin.

Récapitulatif

  • Les jetons dans le stockage du navigateur sont des billets en papier : un script injecté peut les envoyer ailleurs. Un jeton d’accès volé a obtenu 200 comme Sam depuis curl.
  • Un serveur pour l’interface garde les jetons sur le serveur. Le navigateur reçoit le témoin ticket_session : HttpOnly, SameSite=Lax, 8 heures, Secure sur un serveur. La table garde son empreinte SHA-256.
  • Les témoins circulent automatiquement, donc les changements demandent une protection CSRF : SameSite=Lax et l’en-tête X-CSRF-Token (403 csrf_failed sans lui).
  • return_to accepte seulement un chemin local, et chaque state fonctionne une seule fois.
  • Sur votre ordinateur, SESSION_COOKIE_SECURE=false : le moteur de Safari refuse un témoin Secure sur HTTP non chiffré.

La session démarre maintenant correctement. La prochaine leçon la suit jusqu’à sa fin : expiration, renouvellement, déconnexion, compte désactivé et deuxième personne dans le même navigateur.

Leçon suivante : Cycle de vie d'une session