|
@@ -17,6 +17,15 @@ namespace App\Auth;
|
|
|
*
|
|
*
|
|
|
* `regenerateId()` MUST be called after any auth-state change (login
|
|
* `regenerateId()` MUST be called after any auth-state change (login
|
|
|
* success, logout) to defeat session fixation.
|
|
* success, logout) to defeat session fixation.
|
|
|
|
|
+ *
|
|
|
|
|
+ * SEC_REVIEW F8: `regenerateId()` and `clear()` previously short-circuited
|
|
|
|
|
+ * silently when `headers_sent()` was true. That left the pre-auth cookie
|
|
|
|
|
+ * intact post-login — classic session fixation. Both methods now
|
|
|
|
|
+ * fail-closed under HTTP (throw `\RuntimeException`, surfaced by Slim as
|
|
|
|
|
+ * a 500 so the operator notices the upstream output bug) and use a manual
|
|
|
|
|
+ * id-rotation path under CLI/test (`$cliFallback === true`) where
|
|
|
|
|
+ * `headers_sent()` is structurally always true and there's no real cookie
|
|
|
|
|
+ * to send.
|
|
|
*/
|
|
*/
|
|
|
class SessionManager
|
|
class SessionManager
|
|
|
{
|
|
{
|
|
@@ -29,11 +38,20 @@ class SessionManager
|
|
|
private const KEY_NEXT = '_next';
|
|
private const KEY_NEXT = '_next';
|
|
|
private const KEY_OIDC = '_oidc';
|
|
private const KEY_OIDC = '_oidc';
|
|
|
|
|
|
|
|
|
|
+ private readonly bool $cliFallback;
|
|
|
|
|
+
|
|
|
|
|
+ /** @var \Closure(): bool */
|
|
|
|
|
+ private \Closure $headersSentFn;
|
|
|
|
|
+
|
|
|
public function __construct(
|
|
public function __construct(
|
|
|
private readonly bool $secureCookie = true,
|
|
private readonly bool $secureCookie = true,
|
|
|
private readonly int $idleSeconds = 28800,
|
|
private readonly int $idleSeconds = 28800,
|
|
|
private readonly int $absoluteSeconds = 86400,
|
|
private readonly int $absoluteSeconds = 86400,
|
|
|
|
|
+ ?bool $cliFallback = null,
|
|
|
|
|
+ ?\Closure $headersSentFn = null,
|
|
|
) {
|
|
) {
|
|
|
|
|
+ $this->cliFallback = $cliFallback ?? (PHP_SAPI === 'cli');
|
|
|
|
|
+ $this->headersSentFn = $headersSentFn ?? static fn (): bool => headers_sent();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
public function startSession(): void
|
|
public function startSession(): void
|
|
@@ -41,7 +59,7 @@ class SessionManager
|
|
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
- if (headers_sent()) {
|
|
|
|
|
|
|
+ if (($this->headersSentFn)()) {
|
|
|
// Tests run without HTTP — fall back to a manual session id so
|
|
// Tests run without HTTP — fall back to a manual session id so
|
|
|
// SessionManager remains usable in CLI tests.
|
|
// SessionManager remains usable in CLI tests.
|
|
|
if (session_id() === '') {
|
|
if (session_id() === '') {
|
|
@@ -69,9 +87,17 @@ class SessionManager
|
|
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
- if (!headers_sent()) {
|
|
|
|
|
|
|
+ if (!($this->headersSentFn)()) {
|
|
|
session_regenerate_id(true);
|
|
session_regenerate_id(true);
|
|
|
|
|
+
|
|
|
|
|
+ return;
|
|
|
}
|
|
}
|
|
|
|
|
+ if (!$this->cliFallback) {
|
|
|
|
|
+ throw new \RuntimeException(
|
|
|
|
|
+ 'cannot regenerate session id: response headers already sent',
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ $this->rotateIdUnderCli();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
public function setUser(UserContext $user): void
|
|
public function setUser(UserContext $user): void
|
|
@@ -101,9 +127,20 @@ class SessionManager
|
|
|
public function clear(): void
|
|
public function clear(): void
|
|
|
{
|
|
{
|
|
|
$_SESSION = [];
|
|
$_SESSION = [];
|
|
|
- if (session_status() === PHP_SESSION_ACTIVE && !headers_sent()) {
|
|
|
|
|
|
|
+ if (session_status() !== PHP_SESSION_ACTIVE) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!($this->headersSentFn)()) {
|
|
|
session_regenerate_id(true);
|
|
session_regenerate_id(true);
|
|
|
|
|
+
|
|
|
|
|
+ return;
|
|
|
}
|
|
}
|
|
|
|
|
+ if (!$this->cliFallback) {
|
|
|
|
|
+ throw new \RuntimeException(
|
|
|
|
|
+ 'cannot regenerate session id on clear(): response headers already sent',
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ $this->rotateIdUnderCli();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
public function flash(string $type, string $message): void
|
|
public function flash(string $type, string $message): void
|
|
@@ -181,6 +218,22 @@ class SessionManager
|
|
|
return $out;
|
|
return $out;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * CLI/test rotation: PHP can't send Set-Cookie because there is no
|
|
|
|
|
+ * real HTTP response, but the in-process session id is still rotated
|
|
|
|
|
+ * so test assertions about "session was renewed" hold. The current
|
|
|
|
|
+ * `$_SESSION` contents are preserved so flash/auth state survives
|
|
|
|
|
+ * the rotation (matching `session_regenerate_id(true)` semantics).
|
|
|
|
|
+ */
|
|
|
|
|
+ private function rotateIdUnderCli(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $data = $_SESSION;
|
|
|
|
|
+ @session_write_close();
|
|
|
|
|
+ session_id(bin2hex(random_bytes(16)));
|
|
|
|
|
+ @session_start();
|
|
|
|
|
+ $_SESSION = $data;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* Drop the session if it's been idle past `idleSeconds` or older than
|
|
* Drop the session if it's been idle past `idleSeconds` or older than
|
|
|
* `absoluteSeconds` since auth. SPEC §M08.2.
|
|
* `absoluteSeconds` since auth. SPEC §M08.2.
|