From 68a2690b98a1827af5d0d9ec97ad4c9faba8f937 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Wed, 17 Jun 2026 17:07:35 +0200 Subject: [PATCH] test(e2e): parcours admin Playwright + fix cookie Secure conditionnel (ADR-0010) (#46) --- .../0010-cookie-secure-conditionnel-https.md | 28 +++++++++++++++ docs/adr/README.md | 1 + src/app/Auth/SessionManager.php | 29 ++++++++++++++-- tests/e2e/admin.spec.js | 34 +++++++++++++++++++ 4 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 docs/adr/0010-cookie-secure-conditionnel-https.md create mode 100644 tests/e2e/admin.spec.js 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) { diff --git a/tests/e2e/admin.spec.js b/tests/e2e/admin.spec.js new file mode 100644 index 0000000..7546974 --- /dev/null +++ b/tests/e2e/admin.spec.js @@ -0,0 +1,34 @@ +// Parcours E2E admin : garde de session -> connexion -> dashboard -> deconnexion. +// L'admin seede n'a PAS de PIN (pin_hash NULL) -> pas d'action sensible testable ici. +// URLs absolues sur admin.wakdo.test (le vhost admin ; baseURL = kiosk pour la borne). +const { test, expect } = require('@playwright/test'); + +const ADMIN = 'http://admin.wakdo.test'; +// Identifiants DEV seedes (db/seeds/0001) ; a changer en prod. +const EMAIL = 'admin@wakdo.local'; +const PASSWORD = 'WakdoAdmin2026!'; + +test('parcours admin : garde -> login -> dashboard -> logout', async ({ page }) => { + + await test.step('la garde de session redirige vers /login', async () => { + await page.goto(`${ADMIN}/admin/dashboard`); + await expect(page).toHaveURL(/\/login/); + await expect(page.locator('#email')).toBeVisible(); + }); + + await test.step('connexion admin', async () => { + await page.fill('#email', EMAIL); + await page.fill('#password', PASSWORD); + // Le jeton _csrf cache est soumis avec le formulaire (comme un vrai navigateur). + await page.locator('form[action="/login"] button[type="submit"]').click(); + // role.default_route de l'admin = /admin/dashboard + await expect(page).toHaveURL(/\/admin\/dashboard/); + await expect(page.locator('#userMenuBtn')).toBeVisible(); + }); + + await test.step('deconnexion', async () => { + await page.locator('#userMenuBtn').click(); + await page.locator('form[action="/logout"] button[type="submit"]').click(); + await expect(page).toHaveURL(/\/login/); + }); +});