diff --git a/docs/adr/0010-cookie-secure-conditionnel-https.md b/docs/adr/0010-cookie-secure-conditionnel-https.md new file mode 100644 index 0000000..8502797 --- /dev/null +++ b/docs/adr/0010-cookie-secure-conditionnel-https.md @@ -0,0 +1,28 @@ +# ADR-0010 — Cookie de session Secure conditionnel au HTTPS + +- Statut : Accepte +- Date : 2026-06-17 + +## Contexte +Le cookie de session du back-office etait pose avec `secure => true` en dur +(security-by-design). Or un cookie `Secure` n'est emis/renvoye par le navigateur que +sur HTTPS : en HTTP (dev, stack standalone locale, E2E sans TLS) la session ne tenait +pas d'une requete a l'autre, donc le login admin echouait ("Session expiree" au POST, +le jeton CSRF ne pouvant matcher une session perdue). Revele par le parcours E2E admin. +En prod le souci n'apparait pas : Traefik termine le TLS. + +## Decision +`secure` devient **conditionnel au schema** : vrai si la requete est HTTPS, faux sinon. +Detection (`SessionManager::cookieSecure()`) : `X-Forwarded-Proto: https` (pose par +Traefik en prod) en priorite, sinon la variable serveur `HTTPS`, sinon le port 443. +Applique aux deux points (pose du cookie + expiration au logout). + +## Consequences +- (+) Le back-office est utilisable en **HTTP local** (dev, standalone, E2E) ; prod + **inchange** (derriere Traefik -> `X-Forwarded-Proto=https` -> `Secure` reste pose). +- (+) Comportement standard (les frameworks derivent `Secure` du schema). +- Confiance en `X-Forwarded-Proto` : sure ici car l'app n'est joignable que par le + reverse proxy sur le reseau interne (aucun acces client direct). +- (-) Un deploiement en **HTTP nu** (sans proxy TLS) n'aurait pas `Secure` — mais servir + l'authentification en HTTP nu est de toute facon a proscrire (independant de ce flag). +- `httponly` et `SameSite=Strict` restent inconditionnels. Revele par [E2E admin](../domaines/auth.md). diff --git a/docs/adr/README.md b/docs/adr/README.md index cab4e3d..384b7e8 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -17,6 +17,7 @@ une decision revisee donne une nouvelle fiche qui *supersede* l'ancienne (statut | [0007](0007-rgpd-anonymisation-tombstone.md) | Effacement RGPD par anonymisation (tombstone), pas DELETE | Accepte | | [0008](0008-makefile-vers-compose-migrate.md) | Du Makefile a `docker compose up` (service wakdo-migrate) | Accepte | | [0009](0009-compose-standalone-et-prod-gitignore.md) | docker-compose.yml standalone + docker-compose.prod.yml gitignore | Accepte | +| [0010](0010-cookie-secure-conditionnel-https.md) | Cookie de session Secure conditionnel au HTTPS | Accepte | ## Modele de fiche diff --git a/src/app/Auth/SessionManager.php b/src/app/Auth/SessionManager.php index 9ad2f9c..17dbdc9 100644 --- a/src/app/Auth/SessionManager.php +++ b/src/app/Auth/SessionManager.php @@ -48,11 +48,12 @@ final class SessionManager // lifetime=0 : cookie de session ; les bornes idle 4h / absolue 10h sont // appliquees applicativement par SessionGuard (RG-6), pas par le cookie. - // secure+httponly+SameSite=Strict : back-office, aucune entree cross-site. + // secure (conditionnel HTTPS, cf. cookieSecure)+httponly+SameSite=Strict : + // back-office, aucune entree cross-site. session_set_cookie_params([ 'lifetime' => 0, 'path' => '/', - 'secure' => true, + 'secure' => $this->cookieSecure(), 'httponly' => true, 'samesite' => 'Strict', ]); @@ -147,7 +148,7 @@ final class SessionManager setcookie($name, '', [ 'expires' => time() - 3600, 'path' => '/', - 'secure' => true, + 'secure' => $this->cookieSecure(), 'httponly' => true, 'samesite' => 'Strict', ]); @@ -159,6 +160,28 @@ final class SessionManager } } + /** + * Le cookie de session est marque Secure UNIQUEMENT sur une connexion HTTPS. + * En HTTP (dev / standalone local) un cookie Secure serait rejete par le + * navigateur et casserait la session. En prod, Traefik termine le TLS et + * transmet X-Forwarded-Proto=https ; l'app n'etant joignable que par ce proxy + * sur le reseau interne, cet en-tete est fiable ici. + */ + private function cookieSecure(): bool + { + $forwarded = $_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''; + if (is_string($forwarded) && strtolower($forwarded) === 'https') { + return true; + } + + $https = $_SERVER['HTTPS'] ?? ''; + if (is_string($https) && $https !== '' && strtolower($https) !== 'off') { + return true; + } + + return ((int) ($_SERVER['SERVER_PORT'] ?? 0)) === 443; + } + public function id(): string { if ($this->testMode) {