---
title: "Sessions web (cookie)"
description: "Les pages HTML servies par Hydrogen partagent les mêmes lignes user_session que l'API — seul le transport change : Authorization: Bearer <token> pour l'API…"
---

Les pages HTML servies par Hydrogen partagent les **mêmes lignes `user_session`** que l'API — seul le **transport** change : `Authorization: Bearer <token>` pour l'API, **cookie** pour le web. Pratiquement, ça veut dire :

- Un même utilisateur peut être connecté simultanément côté site et côté app mobile, chaque session apparaîtra dans `GET /api/auth/sessions`.
- La révocation d'une session web depuis l'app mobile (et inversement) fonctionne sans code spécial.
- Le throttle anti-bruteforce est partagé : 5 échecs sur `/login` ou `POST /api/auth/login` ferment l'IP/email côté API ET côté web.

## Cookies émis

| Cookie                | HttpOnly | Secure (prod) | SameSite | Path | Rôle |
|-----------------------|----------|---------------|----------|------|------|
| `hydrogen_session`*   | oui      | oui           | Lax*     | `/`  | Porte le bearer token de session ; sliding `Max-Age` aligné sur `user_session.expires_at` (30 jours). |
| `csrf`*               | non      | oui           | Strict   | `/`  | Token CSRF (64 hex chars) du double-submit cookie. Lu par le JS pour être renvoyé. |

\* noms et politiques configurables : `WEB_SESSION_COOKIE_NAME`, `WEB_COOKIE_SECURE`, `WEB_COOKIE_SAMESITE`, `WEB_COOKIE_DOMAIN`, `CSRF_COOKIE_NAME`.

## Pipeline middleware

```
request → WebSession → Locale → Csrf → TwigGlobals → action
```

- **WebSession** : lit le cookie de session, appelle `SessionService::authenticate()`, réémet le cookie avec le nouveau `expires_at` (sliding). Sur cookie invalide : passe en anonyme **et** efface le cookie.
- **Locale** : choisit la locale (settings utilisateur si connecté, sinon `Accept-Language`).
- **Csrf** : sur `POST`/`PUT`/`PATCH`/`DELETE`, exige soit un champ `_csrf` dans le corps, soit l'en-tête `X-CSRF-Token`. Comparaison `hash_equals`. Rejet `403` plain text en cas de désaccord ou de cookie absent/malformé.
- **TwigGlobals** : publie `viewer` (objet `User` ou `null`), `csrf_token` (string), `locale` dans toutes les templates Twig.

## Variables d'environnement

| Variable                   | Défaut             | Effet                                                                                                  |
|----------------------------|--------------------|--------------------------------------------------------------------------------------------------------|
| `WEB_SESSION_COOKIE_NAME`  | `hydrogen_session` | Nom du cookie de session.                                                                              |
| `WEB_COOKIE_SECURE`        | `false`            | `true` en prod (HTTPS). Concerne les deux cookies (session + CSRF).                                    |
| `WEB_COOKIE_SAMESITE`      | `Lax`              | Politique SameSite du cookie de session. `Strict` si pas besoin de GET cross-site authentifié.         |
| `WEB_COOKIE_DOMAIN`        | *(vide)*           | Attribut `Domain` explicite. Vide = host-only (recommandé).                                            |
| `CSRF_COOKIE_NAME`         | `csrf`             | Nom du cookie CSRF.                                                                                    |

## Bandeau de consentement cookies

Le partial [partials/cookie-consent.twig](../templates/partials/cookie-consent.twig) s'inclut à la fin du `<body>` de toute page web qui doit afficher le bandeau (`home.twig`, `auth/login.twig`, etc.). Il s'appuie sur la lib [orestbida/cookieconsent v3](https://github.com/orestbida/cookieconsent) chargée depuis jsdelivr (CSS + UMD pinned au tag `v3.0.1`).

**Catégories** :

| Slug         | Décochable ? | Couvre                                                                                  |
|--------------|--------------|------------------------------------------------------------------------------------------|
| `necessary`  | non          | `hydrogen_session`, `csrf`, le cookie de mémorisation du consentement lui-même.          |
| `analytics`  | oui          | Google Analytics 4 (`_ga`, `_ga_*`) — chargé UNIQUEMENT après acceptation explicite.     |

> Légalement (RGPD/CNIL), les cookies strictement nécessaires ne requièrent PAS de consentement. La banner reste affichée pour information sur la catégorie `necessary` ET pour recueillir le consentement obligatoire de la catégorie `analytics`.

**Google Analytics 4** : pilote via `GOOGLE_ANALYTICS_ID` (ex. `G-XXXXXXX`). Vide ⇒ aucun snippet GA n'est rendu. Sinon, les deux balises `<script>` (chargement de `gtag.js` + appel `gtag('config', ...)`) sont émises avec `type="text/plain"` et `data-category="analytics"` ; le browser ne les exécute PAS au parsing. CookieConsent v3 surveille ces nœuds et ne réécrit leur `type` en `text/javascript` qu'à partir de l'acceptation explicite de la catégorie — refus / pas de décision = GA ne se charge jamais, aucun cookie posé, aucun hit envoyé. `anonymize_ip: true` est forcé.

**Localisation** : les libellés sont stockés dans `resources/lang/<locale>/cookies.php` (catalogue racine partagé entre toutes les pages — pas sous `pages.*` puisque la banner traverse toutes les pages). Sections : `consent.*`, `preferences.*`, `categories.<slug>.*`.

**Lien de réouverture** : tout élément doté de l'attribut `data-cc="show-preferencesModal"` rouvre la modale (à utiliser dans un futur footer ou une page Confidentialité).

**Réémission** : la clé `revision` (entier) côté Twig est à incrémenter dès qu'une catégorie est ajoutée ou retirée — la banner sera alors réaffichée aux visiteurs qui avaient déjà répondu.

**Ajouter un script soumis au consentement** : le jour où un outil d'analytics est intégré, sa balise `<script>` doit porter `type="text/plain"` + `data-category="analytics"` (convention CookieConsent v3). Le script ne s'exécute alors qu'à partir de l'acceptation explicite.

---

## Protection contre les redirections ouvertes

Tous les endpoints qui consomment un paramètre `return` (`?return=...` ou champ `return` dans le corps) le filtrent via `ReturnUrlSanitizer::pick()`. Sont **acceptés uniquement** :
- les chemins relatifs commençant par `/` (ex : `/profile`, `/media/abc?from=home`).

Sont **rejetés** :
- les URL absolues (`http://...`).
- les chemins protocol-relative (`//evil.com/...`).
- les valeurs vides ou non-string.

En cas de rejet, fallback sur le default fourni (typiquement `/`).

---

## `GET /login`

Affiche le formulaire de connexion (Twig). Si l'utilisateur est déjà authentifié, redirige immédiatement vers `?return=` (sanitisé) ou `/`.

- **Auth** : aucune (mais redirige si déjà connecté)
- **Action** : [ShowLoginAction](../src/Http/Action/Web/Auth/ShowLoginAction.php)
- **Query params** :
  - `error` (optionnel) — code d'erreur affiché par la template (`missing_fields`, `invalid_credentials`, `banned`, `email_not_confirmed`, `throttled`, `unknown`).
  - `return` (optionnel) — chemin où rediriger après login (sanitisé).
- **Réponse** : `200 OK` (HTML) ou `302` si déjà connecté.

---

## `POST /login`

Soumission du formulaire de connexion web. Mêmes règles métier que `POST /api/auth/login` (throttle, ban, email confirmé). Succès : pose le cookie de session, redirige vers `?return=` ou `/`. Échec : redirige vers `/login?error=<code>&return=<return>`.

- **Auth** : aucune
- **CSRF** : requis (champ `_csrf` ou en-tête `X-CSRF-Token`)
- **Action** : [SubmitLoginAction](../src/Http/Action/Web/Auth/SubmitLoginAction.php)
- **Corps (form-urlencoded)** :
  - `email` (string, requis)
  - `password` (string, requis)
  - `_csrf` (string, requis)
  - `return` (string, optionnel)
- **Réponse `302`** :
  - Succès : `Location: <return>` + `Set-Cookie: hydrogen_session=...`
  - Échec : `Location: /login?error=<code>[&return=<return>]`
- **Codes d'erreur** (paramètre `error` du redirect) :
  - `missing_fields` — email ou password vide.
  - `invalid_credentials` — combinaison invalide.
  - `banned` — compte banni.
  - `email_not_confirmed` — email non confirmé.
  - `throttled` — trop de tentatives.
  - `unknown` — erreur inattendue (devrait être inatteignable).
- **Réponse `403`** : `CSRF token missing or invalid.` (en cas d'échec du middleware CSRF).

---

## `POST /logout`

Révoque la session web courante (supprime la ligne `user_session`) ET efface le cookie. Idempotent — un visiteur anonyme reçoit aussi un `302 /`. Les autres sessions du même utilisateur (autres appareils, app mobile, API) ne sont **pas** affectées : utilisez `POST /api/auth/logout-all` pour ça.

- **Auth** : facultative (l'action ne casse pas si la session est déjà absente)
- **CSRF** : requis
- **Action** : [LogoutAction (web)](../src/Http/Action/Web/Auth/LogoutAction.php)
- **Corps (form-urlencoded)** :
  - `_csrf` (string, requis)
  - `return` (string, optionnel)
- **Réponse `302`** : `Location: <return>` (default `/`) + `Set-Cookie: hydrogen_session=; Max-Age=0`.

---

## `POST /auth/oauth/google`, `POST /auth/oauth/apple`, `POST /auth/oauth/facebook`

Pendants **web** des endpoints OAuth de l'API (`POST /api/auth/oauth/*`). Le navigateur a obtenu un token via le SDK JS du fournisseur (Google Identity Services / Sign in with Apple JS / Facebook JS SDK), puis le poste à l'un de ces trois endpoints. La logique métier est partagée avec l'API (`OAuthLoginService`) — seul le **transport** change : pas de JSON envelope, on ouvre une session web (cookie) et on redirige.

Le formulaire HTML est rendu par `templates/auth/login.twig` ; les boutons fournisseurs ne s'affichent que si l'env correspondante est non vide :

| Provider | Env requise(s)                                                   | Bouton si vide ? |
|----------|------------------------------------------------------------------|------------------|
| Google   | `GOOGLE_OAUTH_CLIENT_ID`                                         | masqué           |
| Apple    | `APPLE_OAUTH_WEB_CLIENT_ID` + `APPLE_OAUTH_WEB_REDIRECT_URI`     | masqué           |
| Facebook | `FACEBOOK_APP_ID`                                                | masqué           |

- **Auth** : aucune
- **CSRF** : requis (champ `_csrf` posé par le formulaire caché correspondant)
- **Actions** :
  - Google → [WebGoogleOAuthAction](../src/Http/Action/Web/Auth/OAuth/WebGoogleOAuthAction.php)
  - Apple → [WebAppleOAuthAction](../src/Http/Action/Web/Auth/OAuth/WebAppleOAuthAction.php)
  - Facebook → [WebFacebookOAuthAction](../src/Http/Action/Web/Auth/OAuth/WebFacebookOAuthAction.php)
- **Corps (form-urlencoded)** :
  - Google : `idToken` (string, requis) + `_csrf` + `return?`
  - Apple : `idToken` (string, requis) + `_csrf` + `return?`
  - Facebook (classic) : `flow=classic` + `accessToken` + `_csrf` + `return?`
  - Facebook (limited) : `flow=limited` + `idToken` + `_csrf` + `return?`
- **Réponse `302`** :
  - Succès : `Location: <return>` + `Set-Cookie: hydrogen_session=...`
  - Échec : `Location: /login?error=<code>[&return=<return>]`
- **Codes d'erreur** (paramètre `error` du redirect, mappés sur `pages.login.error.*`) :
  - `oauth_missing_token` — body sans `idToken`/`accessToken`/`flow` valide.
  - `oauth_token_invalid` — vérification JWKS/Graph KO.
  - `oauth_link_refused` — un compte existe déjà pour cet email mais l'auto-link a été refusé (règles C3 pour Google ; toujours refusé pour Apple/Facebook — E.a strict).
  - `oauth_email_missing` — Facebook uniquement (D.a) : l'utilisateur a refusé la permission `email`.
  - `banned` — compte banni.

> Les règles d'auto-link, de placeholder username et de bannissement sont strictement identiques à celles des endpoints API correspondants — voir `POST /api/auth/oauth/google`, `apple`, `facebook` plus bas.

---
