Compare commits
No commits in common. "ffd2d6b2b6bb7eaea6c1c296f7bd51bb01ccce41" and "aef6174b5bc2e99f9a1e77bb425142c2d94aabe3" have entirely different histories.
ffd2d6b2b6
...
aef6174b5b
4 changed files with 3 additions and 89 deletions
|
|
@ -1,28 +0,0 @@
|
||||||
# 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).
|
|
||||||
|
|
@ -17,7 +17,6 @@ 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 |
|
| [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 |
|
| [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 |
|
| [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
|
## Modele de fiche
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,12 +48,11 @@ final class SessionManager
|
||||||
|
|
||||||
// lifetime=0 : cookie de session ; les bornes idle 4h / absolue 10h sont
|
// lifetime=0 : cookie de session ; les bornes idle 4h / absolue 10h sont
|
||||||
// appliquees applicativement par SessionGuard (RG-6), pas par le cookie.
|
// appliquees applicativement par SessionGuard (RG-6), pas par le cookie.
|
||||||
// secure (conditionnel HTTPS, cf. cookieSecure)+httponly+SameSite=Strict :
|
// secure+httponly+SameSite=Strict : back-office, aucune entree cross-site.
|
||||||
// back-office, aucune entree cross-site.
|
|
||||||
session_set_cookie_params([
|
session_set_cookie_params([
|
||||||
'lifetime' => 0,
|
'lifetime' => 0,
|
||||||
'path' => '/',
|
'path' => '/',
|
||||||
'secure' => $this->cookieSecure(),
|
'secure' => true,
|
||||||
'httponly' => true,
|
'httponly' => true,
|
||||||
'samesite' => 'Strict',
|
'samesite' => 'Strict',
|
||||||
]);
|
]);
|
||||||
|
|
@ -148,7 +147,7 @@ final class SessionManager
|
||||||
setcookie($name, '', [
|
setcookie($name, '', [
|
||||||
'expires' => time() - 3600,
|
'expires' => time() - 3600,
|
||||||
'path' => '/',
|
'path' => '/',
|
||||||
'secure' => $this->cookieSecure(),
|
'secure' => true,
|
||||||
'httponly' => true,
|
'httponly' => true,
|
||||||
'samesite' => 'Strict',
|
'samesite' => 'Strict',
|
||||||
]);
|
]);
|
||||||
|
|
@ -160,28 +159,6 @@ 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
|
public function id(): string
|
||||||
{
|
{
|
||||||
if ($this->testMode) {
|
if ($this->testMode) {
|
||||||
|
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
// 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/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Loading…
Add table
Reference in a new issue