fix(auth): cookie de session Secure conditionnel au HTTPS (ADR-0010)

`secure => true` etait en dur : un cookie Secure n'etant renvoye qu'en HTTPS, la
session ne tenait pas en HTTP (dev / standalone local / E2E) et le login admin
echouait ("Session expiree" -> CSRF KO). Revele par le parcours E2E admin.

SessionManager::cookieSecure() derive le flag du schema : X-Forwarded-Proto=https
(pose par Traefik en prod, fiable car l'app n'est joignable que par le proxy sur le
reseau interne), sinon var HTTPS, sinon port 443. Applique a la pose ET a l'expiration
du cookie. Prod inchange (toujours HTTPS -> Secure). httponly + SameSite=Strict
restent inconditionnels. Detail : docs/adr/0010.

Verifie : PHPStan L6 OK, 263 tests unit OK, E2E admin (login/garde/logout) vert.
This commit is contained in:
Imugiii 2026-06-17 15:05:40 +00:00
parent aef6174b5b
commit e5aba9599e
3 changed files with 55 additions and 3 deletions

View file

@ -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).

View file

@ -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

View file

@ -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) {