mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-09-25 01:53:03 +02:00
fix(admin): stop logging out active long-form work; raise idle timeout to 4h
The admin idle timeout (30 min) was refreshed only by navigations and HTMX requests. During long encoding sessions on an open form there are none, so an actively-typing admin was logged out mid-work after ~30-45 min. Add an activity-driven keepalive: - /admin/session-keepalive.php: 204 when authenticated (refreshes admin_last_activity via AdminAuth::isAuthenticated()), 401 otherwise. - admin-session-keepalive.js: marks activity only on real user input (pointer/keyboard/input/scroll/wheel/touch/focus) and pings at most once per 5 min while the tab is visible. A genuinely idle tab never pings, so the idle timeout still applies. Raise the idle window 30 min -> 4 h: for a single-/few-admin back-office whose main workflow is data entry, 30 min still kicked admins who stepped away mid-form. With the keepalive in place, 4 h means "no interaction at all", not "no navigation". Absolute timeout stays 12 h. Also fix session ID rotation, which never fired: it used `$absolute % IDLE_TIMEOUT_SECONDS === 0`, i.e. required a request to land exactly on a multiple of the interval relative to login time. Replaced with an explicit admin_last_rotation timestamp and a ROTATION_INTERVAL_SECONDS (30 min) constant decoupled from the idle timeout, so raising the idle window does not widen the fixation/replay window. Refactor AdminAuth::enforceSessionTimeout() to return bool instead of redirecting/exiting, so the keepalive endpoint can report 401 cleanly rather than letting fetch follow a redirect to the login page. Smoke test (just smoke-session-keepalive) covers activity refresh, 2 h idle accepted, rotation firing, idle rejection+destruction, and unauthenticated rejection. Docs updated.
This commit is contained in:
@@ -54,6 +54,15 @@
|
|||||||
- [x] #rewrite-cc2r-checkbox-label Rewrite CC2r checkbox label to 'J'adhère au Collective Commitment to Reuse (CC2r)' (italic, both Libre and Interne branches)
|
- [x] #rewrite-cc2r-checkbox-label Rewrite CC2r checkbox label to 'J'adhère au Collective Commitment to Reuse (CC2r)' (italic, both Libre and Interne branches)
|
||||||
- [x] #fix-admin-contenus-page [!high] Fix admin contenus page slowdown: langues/mots-clés fragments ship 2.4MB HTML (737 tag rows + 217 lang rows, ~1960 inline SVG icons + per-row CSRF forms). Implement htmx infinite-scroll 'load more' (paged fragments via limit/offset) for both tables.
|
- [x] #fix-admin-contenus-page [!high] Fix admin contenus page slowdown: langues/mots-clés fragments ship 2.4MB HTML (737 tag rows + 217 lang rows, ~1960 inline SVG icons + per-row CSRF forms). Implement htmx infinite-scroll 'load more' (paged fragments via limit/offset) for both tables.
|
||||||
- [x] #reduce-contenus-initial-page [!high] Reduce contenus initial page size from 100 to 25 rows per table (payload ~160KB total vs ~534KB)
|
- [x] #reduce-contenus-initial-page [!high] Reduce contenus initial page size from 100 to 25 rows per table (payload ~160KB total vs ~534KB)
|
||||||
|
- [x] #add-admin-session-keepalive Add admin session keepalive endpoint /admin/session-keepalive.php (204, gated, refreshes admin_last_activity)
|
||||||
|
- [x] #add-admin-session-keepalive-js-activity-driven-throttled Add admin-session-keepalive.js: activity-driven throttled ping (5min), visibility-aware, wired into admin-entry
|
||||||
|
- [x] #update-docs-security-md-to-document Update docs/security.md to document activity-driven keepalive vs navigation-only timeout
|
||||||
|
- [x] #add-test-for-session Add test for session keepalive endpoint auth gating + activity refresh
|
||||||
|
- [x] #raise-admin-idle-timeout Raise admin idle timeout 30min -> 4h for long-form encoding; keep absolute 12h
|
||||||
|
- [x] #fix-never-firing-session-id Fix never-firing session ID rotation (modulo on absolute age); use admin_last_rotation + interval
|
||||||
|
- [x] #decouple-rotation-interval-30min Decouple rotation interval (30min) from idle timeout constant
|
||||||
|
- [x] #update-docs-security-md-deploy-server-sh Update docs/security.md + deploy-server.sh comment for new idle value
|
||||||
|
- [x] #extend-smoke-test-for Extend smoke test for rotation and 4h idle boundary
|
||||||
|
|
||||||
## Deferred / Blocked
|
## Deferred / Blocked
|
||||||
- [ ] #just-setup-backs-a [!medium] just setup backs a stale setup-dev.sh (clones php-live-reload, legacy admin/data/ dirs) — needs rewrite or removal
|
- [ ] #just-setup-backs-a [!medium] just setup backs a stale setup-dev.sh (clones php-live-reload, legacy admin/data/ dirs) — needs rewrite or removal
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ the XAMXAM TFE database.
|
|||||||
| `media.php` | Admin file viewer — opens files of 'Interdit' (access_type_id=3) theses; session-gated (`AdminAuth::requireLogin`), delegates to `MediaController::handle(adminBypass: true)` |
|
| `media.php` | Admin file viewer — opens files of 'Interdit' (access_type_id=3) theses; session-gated (`AdminAuth::requireLogin`), delegates to `MediaController::handle(adminBypass: true)` |
|
||||||
| `media-viewer.php` | HTML wrapper that opens a thesis file with a reliable tab title (original file name); embeds the file via `media.php` in a full-viewport iframe |
|
| `media-viewer.php` | HTML wrapper that opens a thesis file with a reliable tab title (original file name); embeds the file via `media.php` in a full-viewport iframe |
|
||||||
| `account.php` | Admin account / password |
|
| `account.php` | Admin account / password |
|
||||||
|
| `session-keepalive.php` | Lightweight `204`/`401` liveness ping that refreshes `admin_last_activity` while an open admin page is being actively used (see `docs/security.md`) |
|
||||||
| `login.php` | Login (session) |
|
| `login.php` | Login (session) |
|
||||||
| `import.php` | Redirects to `/admin/` (CSV import is inline in `index.php`) |
|
| `import.php` | Redirects to `/admin/` (CSV import is inline in `index.php`) |
|
||||||
| `status.php`, `markdown-cheatsheet-fragment.php`, `*fragment.php` | HTMX fragments / helpers |
|
| `status.php`, `markdown-cheatsheet-fragment.php`, `*fragment.php` | HTMX fragments / helpers |
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin session keepalive — lightweight liveness ping.
|
||||||
|
*
|
||||||
|
* Called periodically by admin-session-keepalive.js while an admin page stays
|
||||||
|
* open and the admin is genuinely interacting with it (typing/clicking/scrolling).
|
||||||
|
*
|
||||||
|
* Without this, `AdminAuth::enforceSessionTimeout()` only refreshed
|
||||||
|
* `admin_last_activity` on navigations and HTMX requests. An admin spending
|
||||||
|
* >IDLE_TIMEOUT_SECONDS entering data in an open form (a long encoding session)
|
||||||
|
* issued no requests and was logged out mid-work.
|
||||||
|
*
|
||||||
|
* This endpoint calls `isAuthenticated()`, which runs the same timeout
|
||||||
|
* enforcement and, on success, refreshes the activity timestamp. A truly idle
|
||||||
|
* tab never pings (the client only pings after real interaction), so the idle
|
||||||
|
* timeout still applies.
|
||||||
|
*
|
||||||
|
* Responses:
|
||||||
|
* 204 — authenticated, activity refreshed (or no password configured: dev).
|
||||||
|
* 401 — not authenticated: the client should redirect to the login page.
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/../../bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../../src/AdminAuth.php';
|
||||||
|
|
||||||
|
if (!AdminAuth::isAuthenticated()) {
|
||||||
|
http_response_code(401);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
http_response_code(204);
|
||||||
|
exit;
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
import "./beforeunload-guard.js";
|
import "./beforeunload-guard.js";
|
||||||
import "./clipboard.js";
|
import "./clipboard.js";
|
||||||
import "./smtp-error-focus.js";
|
import "./smtp-error-focus.js";
|
||||||
|
import "./admin-session-keepalive.js";
|
||||||
|
|
||||||
// HTMX-powered features (htmx is a global from vendor script)
|
// HTMX-powered features (htmx is a global from vendor script)
|
||||||
import "./htmx-global-setup.js";
|
import "./htmx-global-setup.js";
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* admin-session-keepalive.js — keep an actively-used admin page from timing out.
|
||||||
|
*
|
||||||
|
* The admin session has a server-side idle timeout (AdminAuth). That timestamp
|
||||||
|
* is only refreshed by requests the browser makes: full navigations and HTMX
|
||||||
|
* calls. A long data-entry session on an open form generates no such requests,
|
||||||
|
* so an actively-typing admin was logged out mid-work.
|
||||||
|
*
|
||||||
|
* This module pings /admin/session-keepalive.php while the admin is genuinely
|
||||||
|
* interacting with the page. Design constraints:
|
||||||
|
* - Only real user interaction marks the session as "active" (pointer,
|
||||||
|
* keyboard, input, scroll, wheel, touch, focus). A tab left open alone
|
||||||
|
* keeps no activity and still times out — the idle semantics are preserved.
|
||||||
|
* - Pings are throttled (PING_INTERVAL) and only sent when the tab is visible.
|
||||||
|
* - HTMX requests already refresh the session server-side, so they also mark
|
||||||
|
* activity to avoid a redundant ping shortly after.
|
||||||
|
* - A 401 response means the session is gone: reload so the server redirects
|
||||||
|
* to the login page.
|
||||||
|
*/
|
||||||
|
(() => {
|
||||||
|
const ENDPOINT = "/admin/session-keepalive.php";
|
||||||
|
const CHECK_INTERVAL_MS = 60 * 1000; // poll liveness every minute
|
||||||
|
const PING_INTERVAL_MS = 5 * 60 * 1000; // send at most one ping per 5 min
|
||||||
|
|
||||||
|
let activeSinceLastPing = false;
|
||||||
|
let lastPingAt = 0;
|
||||||
|
let inFlight = false;
|
||||||
|
|
||||||
|
const markActive = () => {
|
||||||
|
activeSinceLastPing = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const events = [
|
||||||
|
"pointerdown",
|
||||||
|
"keydown",
|
||||||
|
"input",
|
||||||
|
"scroll",
|
||||||
|
"wheel",
|
||||||
|
"touchstart",
|
||||||
|
"focusin",
|
||||||
|
];
|
||||||
|
for (const type of events) {
|
||||||
|
// `passive` for high-frequency events where we never call preventDefault.
|
||||||
|
document.addEventListener(type, markActive, {
|
||||||
|
passive: true,
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTMX requests refresh the session server-side; treat them as activity too.
|
||||||
|
document.body?.addEventListener("htmx:afterRequest", markActive);
|
||||||
|
|
||||||
|
const ping = async () => {
|
||||||
|
if (inFlight || document.visibilityState !== "visible") return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (!activeSinceLastPing || now - lastPingAt < PING_INTERVAL_MS) return;
|
||||||
|
|
||||||
|
inFlight = true;
|
||||||
|
lastPingAt = now;
|
||||||
|
activeSinceLastPing = false;
|
||||||
|
try {
|
||||||
|
const res = await fetch(ENDPOINT, {
|
||||||
|
method: "GET",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "X-Requested-With": "XMLHttpRequest" },
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
// Session expired despite the ping — let the server redirect.
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Transient network error: leave lastPingAt advanced so we do not
|
||||||
|
// hammer a flaky connection; the next check retries after the
|
||||||
|
// throttle window.
|
||||||
|
} finally {
|
||||||
|
inFlight = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setInterval(ping, CHECK_INTERVAL_MS);
|
||||||
|
|
||||||
|
// Returning to a tab that had interaction since the last ping refreshes it
|
||||||
|
// promptly rather than waiting out the check interval.
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (document.visibilityState === "visible") ping();
|
||||||
|
});
|
||||||
|
})();
|
||||||
+41
-18
@@ -20,11 +20,20 @@ class AdminAuth
|
|||||||
private const COOLDOWN_MINUTES = 15;
|
private const COOLDOWN_MINUTES = 15;
|
||||||
|
|
||||||
// Session lifetime (server-side): idle timeout and absolute timeout.
|
// Session lifetime (server-side): idle timeout and absolute timeout.
|
||||||
// Aligned with OWASP ASVS / NIST 800-63B guidance for admin panels.
|
// Aligned with OWASP ASVS / NIST 800-63B guidance for admin panels, with the
|
||||||
private const IDLE_TIMEOUT_SECONDS = 1800; // 30 min with no activity
|
// idle window raised for this single-/few-admin back-office whose primary
|
||||||
|
// workflow is long-form data entry ("encoding marathons"). The client-side
|
||||||
|
// keepalive (admin-session-keepalive.js) refreshes activity on real
|
||||||
|
// interaction, so the idle window means "no interaction at all", not "no
|
||||||
|
// navigation"; 4 h tolerates stepping away mid-form without losing state.
|
||||||
|
private const IDLE_TIMEOUT_SECONDS = 14400; // 4 h with no activity
|
||||||
private const ABSOLUTE_TIMEOUT_SECONDS = 43200; // 12 h regardless of activity
|
private const ABSOLUTE_TIMEOUT_SECONDS = 43200; // 12 h regardless of activity
|
||||||
private const COOKIE_LIFETIME_SECONDS = 604800; // 7 d — upper bound only
|
private const COOKIE_LIFETIME_SECONDS = 604800; // 7 d — upper bound only
|
||||||
|
|
||||||
|
// Session ID rotation interval — independent of the idle timeout. Keeps the
|
||||||
|
// fixation/replay window at 30 min even though the idle window is longer.
|
||||||
|
private const ROTATION_INTERVAL_SECONDS = 1800; // 30 min
|
||||||
|
|
||||||
/** Test hook: override the Database connection (e.g. temp DB in smoke tests). */
|
/** Test hook: override the Database connection (e.g. temp DB in smoke tests). */
|
||||||
private static ?Database $dbOverride = null;
|
private static ?Database $dbOverride = null;
|
||||||
|
|
||||||
@@ -73,15 +82,18 @@ class AdminAuth
|
|||||||
*
|
*
|
||||||
* Called on every gated request. If the session has been idle longer than
|
* Called on every gated request. If the session has been idle longer than
|
||||||
* IDLE_TIMEOUT_SECONDS, or has existed longer than ABSOLUTE_TIMEOUT_SECONDS
|
* IDLE_TIMEOUT_SECONDS, or has existed longer than ABSOLUTE_TIMEOUT_SECONDS
|
||||||
* since login, the session is destroyed and the caller is redirected.
|
* since login, the session is destroyed.
|
||||||
*
|
*
|
||||||
* Only acts on authenticated sessions; leaves unauthenticated sessions
|
* Only acts on authenticated sessions; leaves unauthenticated sessions
|
||||||
* (including throttle counters) untouched.
|
* (including throttle counters) untouched.
|
||||||
|
*
|
||||||
|
* @return bool True when the session is still valid, false when it was
|
||||||
|
* expired and destroyed by this call.
|
||||||
*/
|
*/
|
||||||
private static function enforceSessionTimeout(): void
|
private static function enforceSessionTimeout(): bool
|
||||||
{
|
{
|
||||||
if (empty($_SESSION[self::SESSION_KEY])) {
|
if (empty($_SESSION[self::SESSION_KEY])) {
|
||||||
return; // Not authenticated — nothing to time out.
|
return true; // Not authenticated — nothing to time out.
|
||||||
}
|
}
|
||||||
|
|
||||||
$now = time();
|
$now = time();
|
||||||
@@ -93,17 +105,22 @@ class AdminAuth
|
|||||||
|
|
||||||
if ($idle > self::IDLE_TIMEOUT_SECONDS || $absolute > self::ABSOLUTE_TIMEOUT_SECONDS) {
|
if ($idle > self::IDLE_TIMEOUT_SECONDS || $absolute > self::ABSOLUTE_TIMEOUT_SECONDS) {
|
||||||
self::logout();
|
self::logout();
|
||||||
header('Location: ' . self::LOGIN_URL);
|
return false;
|
||||||
exit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rotate the session ID periodically to limit fixation/replay window.
|
// Rotate the session ID periodically to limit fixation/replay window.
|
||||||
if ($absolute > 0 && $absolute >= self::IDLE_TIMEOUT_SECONDS && $absolute % self::IDLE_TIMEOUT_SECONDS === 0) {
|
// Tracked with an explicit timestamp: a request landing exactly on a
|
||||||
|
// multiple of the interval relative to login time (the previous
|
||||||
|
// modulo-on-$absolute approach) essentially never happens.
|
||||||
|
$lastRotation = (int) ($_SESSION['admin_last_rotation'] ?? $loginAt);
|
||||||
|
if ($now - $lastRotation >= self::ROTATION_INTERVAL_SECONDS) {
|
||||||
session_regenerate_id(true);
|
session_regenerate_id(true);
|
||||||
|
$_SESSION['admin_last_rotation'] = $now;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh the activity timestamp on every authenticated request.
|
// Refresh the activity timestamp on every authenticated request.
|
||||||
$_SESSION['admin_last_activity'] = $now;
|
$_SESSION['admin_last_activity'] = $now;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,7 +151,10 @@ class AdminAuth
|
|||||||
public static function requireLogin(): void
|
public static function requireLogin(): void
|
||||||
{
|
{
|
||||||
self::startSession();
|
self::startSession();
|
||||||
self::enforceSessionTimeout();
|
if (!self::enforceSessionTimeout()) {
|
||||||
|
header('Location: ' . self::LOGIN_URL);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
$storedHash = self::getStoredHash();
|
$storedHash = self::getStoredHash();
|
||||||
if ($storedHash === null) {
|
if ($storedHash === null) {
|
||||||
return; // No password configured → dev / cli-server, skip.
|
return; // No password configured → dev / cli-server, skip.
|
||||||
@@ -202,6 +222,7 @@ class AdminAuth
|
|||||||
$_SESSION[self::SESSION_KEY] = true;
|
$_SESSION[self::SESSION_KEY] = true;
|
||||||
$_SESSION['admin_login_at'] = time();
|
$_SESSION['admin_login_at'] = time();
|
||||||
$_SESSION['admin_last_activity'] = time();
|
$_SESSION['admin_last_activity'] = time();
|
||||||
|
$_SESSION['admin_last_rotation'] = time();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,14 +293,14 @@ class AdminAuth
|
|||||||
|
|
||||||
$subject = 'Réinitialisation du mot de passe — XAMXAM';
|
$subject = 'Réinitialisation du mot de passe — XAMXAM';
|
||||||
$body = <<<HTML
|
$body = <<<HTML
|
||||||
<html><body style="font-family:sans-serif;color:#222;max-width:600px;margin:0 auto;padding:24px">
|
<html><body style="font-family:sans-serif;color:#222;max-width:600px;margin:0 auto;padding:24px">
|
||||||
<h1 style="font-size:1.4rem;border-bottom:2px solid #c00;padding-bottom:8px">Réinitialisation du mot de passe</h1>
|
<h1 style="font-size:1.4rem;border-bottom:2px solid #c00;padding-bottom:8px">Réinitialisation du mot de passe</h1>
|
||||||
<p>Une demande de réinitialisation du mot de passe administrateur a été effectuée.</p>
|
<p>Une demande de réinitialisation du mot de passe administrateur a été effectuée.</p>
|
||||||
<p>Cliquez sur le lien ci-dessous pour définir un nouveau mot de passe. Ce lien est à usage unique et expire dans 30 minutes.</p>
|
<p>Cliquez sur le lien ci-dessous pour définir un nouveau mot de passe. Ce lien est à usage unique et expire dans 30 minutes.</p>
|
||||||
<p><a href="{$url}" style="display:inline-block;margin:16px 0;padding:12px 24px;background:#c00;color:#fff;text-decoration:none;border-radius:4px">Définir un nouveau mot de passe</a></p>
|
<p><a href="{$url}" style="display:inline-block;margin:16px 0;padding:12px 24px;background:#c00;color:#fff;text-decoration:none;border-radius:4px">Définir un nouveau mot de passe</a></p>
|
||||||
<p style="font-size:.85rem;color:#666">Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail.</p>
|
<p style="font-size:.85rem;color:#666">Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail.</p>
|
||||||
</body></html>
|
</body></html>
|
||||||
HTML;
|
HTML;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return SmtpRelay::send($db, $to, $subject, $body);
|
return SmtpRelay::send($db, $to, $subject, $body);
|
||||||
@@ -330,7 +351,9 @@ HTML;
|
|||||||
public static function isAuthenticated(): bool
|
public static function isAuthenticated(): bool
|
||||||
{
|
{
|
||||||
self::startSession();
|
self::startSession();
|
||||||
self::enforceSessionTimeout();
|
if (!self::enforceSessionTimeout()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
$storedHash = self::getStoredHash();
|
$storedHash = self::getStoredHash();
|
||||||
if ($storedHash === null) {
|
if ($storedHash === null) {
|
||||||
return true; // No password configured → dev mode.
|
return true; // No password configured → dev mode.
|
||||||
|
|||||||
+20
-4
@@ -18,13 +18,29 @@ Current security posture for XAMXAM.
|
|||||||
`Path=/admin`; regenerated on login.
|
`Path=/admin`; regenerated on login.
|
||||||
- **Session timeouts** (server-side, enforced by `AdminAuth::enforceSessionTimeout()`
|
- **Session timeouts** (server-side, enforced by `AdminAuth::enforceSessionTimeout()`
|
||||||
on every gated request):
|
on every gated request):
|
||||||
- **Idle timeout** — 30 min without activity → session destroyed, redirected
|
- **Idle timeout** — 4 h without activity → session destroyed, redirected
|
||||||
to login.
|
to login. Raised from 30 min because the back-office's primary workflow is
|
||||||
|
long-form data entry; combined with the activity-driven keepalive below, this
|
||||||
|
means "no interaction at all", not "no navigation".
|
||||||
- **Absolute timeout** — 12 h since login → forced re-login, regardless of
|
- **Absolute timeout** — 12 h since login → forced re-login, regardless of
|
||||||
activity.
|
activity.
|
||||||
- Session ID rotated periodically (every 30 min) to limit fixation/replay.
|
- Session ID rotated every 30 min to limit fixation/replay (interval is
|
||||||
|
independent of the idle timeout, so the replay window did not widen when the
|
||||||
|
idle window was raised).
|
||||||
- The session *cookie* `lifetime` is 7 days, but that is only an upper bound
|
- The session *cookie* `lifetime` is 7 days, but that is only an upper bound
|
||||||
on browser retention — the actual session is bounded by the two timeouts above.
|
on browser retention — the actual session is bounded by the two timeouts above.
|
||||||
|
- **Activity-driven keepalive** (`app/public/admin/session-keepalive.php` +
|
||||||
|
`assets/js/app/admin-session-keepalive.js`). `admin_last_activity` is only
|
||||||
|
refreshed by requests the browser makes. A long data-entry session on an open
|
||||||
|
form issues none, so an actively-typing admin used to be logged out after
|
||||||
|
the idle window. The keepalive closes that gap: the client marks activity on real user
|
||||||
|
input (pointer, keyboard, input, scroll, wheel, touch, focus) and pings
|
||||||
|
`/admin/session-keepalive.php` at most once per 5 min while the tab is
|
||||||
|
visible. The endpoint calls `AdminAuth::isAuthenticated()`, which runs the
|
||||||
|
same timeout enforcement and refreshes the timestamp; it returns `204` when
|
||||||
|
authenticated and `401` otherwise (the client then reloads, and the server
|
||||||
|
redirects to login). A tab left open with no interaction never pings, so the
|
||||||
|
idle timeout still applies. Verify with `just smoke-session-keepalive`.
|
||||||
- **PHP-FPM session GC tuning** — see below. The app enforces its own timeouts,
|
- **PHP-FPM session GC tuning** — see below. The app enforces its own timeouts,
|
||||||
so `session.gc_maxlifetime` must be ≥ the 12 h absolute timeout or PHP would
|
so `session.gc_maxlifetime` must be ≥ the 12 h absolute timeout or PHP would
|
||||||
reap active sessions early.
|
reap active sessions early.
|
||||||
@@ -43,7 +59,7 @@ PHP's own session garbage collector must not reap active sessions before the
|
|||||||
|
|
||||||
```ini
|
```ini
|
||||||
; XAMXAM session tuning.
|
; XAMXAM session tuning.
|
||||||
; AdminAuth enforces its own idle/absolute timeouts (30 min / 12 h), so
|
; AdminAuth enforces its own idle/absolute timeouts (4 h / 12 h), so
|
||||||
; gc_maxlifetime must be >= the absolute timeout or PHP would reap active
|
; gc_maxlifetime must be >= the absolute timeout or PHP would reap active
|
||||||
; sessions from under the app.
|
; sessions from under the app.
|
||||||
session.gc_maxlifetime = 43200
|
session.gc_maxlifetime = 43200
|
||||||
|
|||||||
@@ -564,6 +564,11 @@ smoke-password-reset:
|
|||||||
# End-to-end smoke test for the password-reset flow (throwaway DB, no email).
|
# End-to-end smoke test for the password-reset flow (throwaway DB, no email).
|
||||||
@php scripts/smoke-test-password-reset.php
|
@php scripts/smoke-test-password-reset.php
|
||||||
|
|
||||||
|
[group('test')]
|
||||||
|
smoke-session-keepalive:
|
||||||
|
# Smoke test: admin session keepalive refreshes activity but idle sessions still time out.
|
||||||
|
@php scripts/smoke-test-session-keepalive.php
|
||||||
|
|
||||||
[group('test')]
|
[group('test')]
|
||||||
lint-php:
|
lint-php:
|
||||||
# Static analysis (phpstan) + coding standards (php-cs-fixer)
|
# Static analysis (phpstan) + coding standards (php-cs-fixer)
|
||||||
|
|||||||
@@ -99,12 +99,12 @@ ok "Log dir: /var/log/xamxam owned by www-data:xamxam (2775)"
|
|||||||
|
|
||||||
# PHP-FPM session GC must not reap active admin sessions early.
|
# PHP-FPM session GC must not reap active admin sessions early.
|
||||||
# The app enforces its own server-side idle/absolute timeouts in AdminAuth
|
# The app enforces its own server-side idle/absolute timeouts in AdminAuth
|
||||||
# (30 min idle / 12 h absolute), so session.gc_maxlifetime needs to be at
|
# (4 h idle / 12 h absolute), so session.gc_maxlifetime needs to be at
|
||||||
# least the absolute timeout, and GC re-enabled to clean up stale files.
|
# least the absolute timeout, and GC re-enabled to clean up stale files.
|
||||||
PHP_FPM_INI="/etc/php/8.4/fpm/conf.d/zz-xamxam-session.ini"
|
PHP_FPM_INI="/etc/php/8.4/fpm/conf.d/zz-xamxam-session.ini"
|
||||||
cat > "$PHP_FPM_INI" <<'INI'
|
cat > "$PHP_FPM_INI" <<'INI'
|
||||||
; XAMXAM session tuning.
|
; XAMXAM session tuning.
|
||||||
; AdminAuth enforces its own idle/absolute timeouts (30 min / 12 h), so
|
; AdminAuth enforces its own idle/absolute timeouts (4 h / 12 h), so
|
||||||
; gc_maxlifetime must be >= the absolute timeout or PHP would reap active
|
; gc_maxlifetime must be >= the absolute timeout or PHP would reap active
|
||||||
; sessions from under the app.
|
; sessions from under the app.
|
||||||
session.gc_maxlifetime = 43200
|
session.gc_maxlifetime = 43200
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* smoke-test-session-keepalive.php — verifies that the session keepalive path
|
||||||
|
* (AdminAuth::isAuthenticated(), called by /admin/session-keepalive.php)
|
||||||
|
* refreshes the activity timestamp, and that a genuinely idle session is
|
||||||
|
* rejected — i.e. the keepalive does not defeat the idle timeout.
|
||||||
|
*
|
||||||
|
* Runs against a throwaway SQLite DB; never touches the live dev/prod DB.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php scripts/smoke-test-session-keepalive.php
|
||||||
|
*
|
||||||
|
* Exits 0 on success, 1 on any failure.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$root = dirname(__DIR__);
|
||||||
|
|
||||||
|
require_once $root . '/app/bootstrap.php';
|
||||||
|
require_once $root . '/app/src/Database.php';
|
||||||
|
require_once $root . '/app/src/AdminAuth.php';
|
||||||
|
|
||||||
|
$failures = 0;
|
||||||
|
function check(string $label, bool $ok): void
|
||||||
|
{
|
||||||
|
global $failures;
|
||||||
|
echo ($ok ? " ✓ " : " ✗ ") . $label . "\n";
|
||||||
|
if (!$ok) {
|
||||||
|
$failures++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Throwaway DB with a configured password (so the guard is active) ─────────
|
||||||
|
$tmp = tempnam(sys_get_temp_dir(), 'xamxam-keepalive-');
|
||||||
|
unlink($tmp);
|
||||||
|
$tmpDb = $tmp . '.db';
|
||||||
|
|
||||||
|
$pdo = new PDO('sqlite:' . $tmpDb);
|
||||||
|
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||||
|
$pdo->exec(file_get_contents(APP_ROOT . '/storage/schema.sql'));
|
||||||
|
|
||||||
|
$db = new Database($tmpDb);
|
||||||
|
AdminAuth::setDatabase($db);
|
||||||
|
$db->setSetting('admin_password_hash', password_hash('irrelevant-password-123', PASSWORD_BCRYPT));
|
||||||
|
|
||||||
|
// Session must be active before seeding $_SESSION, or session_start() inside
|
||||||
|
// AdminAuth::startSession() would overwrite it from (empty) session storage.
|
||||||
|
// Buffer output so session-setup calls never warn about sent headers after a
|
||||||
|
// session was destroyed and restarted mid-test.
|
||||||
|
ini_set('session.save_path', sys_get_temp_dir());
|
||||||
|
ini_set('session.use_cookies', '0');
|
||||||
|
ob_start();
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
// ── 1. An unauthenticated session is rejected on the first request ────────────
|
||||||
|
$_SESSION = [];
|
||||||
|
check('empty session is NOT authenticated', AdminAuth::isAuthenticated() === false);
|
||||||
|
|
||||||
|
// ── 2. An active session is authenticated and refreshes last activity ────────
|
||||||
|
$_SESSION = [
|
||||||
|
'admin_authenticated' => true,
|
||||||
|
'admin_login_at' => time() - 60,
|
||||||
|
'admin_last_activity' => time() - 60,
|
||||||
|
];
|
||||||
|
$before = $_SESSION['admin_last_activity'];
|
||||||
|
check('active session is authenticated', AdminAuth::isAuthenticated() === true);
|
||||||
|
check('activity timestamp refreshed', $_SESSION['admin_last_activity'] > $before);
|
||||||
|
|
||||||
|
// ── 2b. A session idle within the 4 h window is still accepted ───────────────
|
||||||
|
// Two hours away from an open form (e.g. working in another app) no longer
|
||||||
|
// logs the admin out mid-marathon.
|
||||||
|
$_SESSION = [
|
||||||
|
'admin_authenticated' => true,
|
||||||
|
'admin_login_at' => time() - 7200,
|
||||||
|
'admin_last_activity' => time() - 7200, // 2 h idle < 4 h idle timeout
|
||||||
|
];
|
||||||
|
check('session idle 2 h is still authenticated', AdminAuth::isAuthenticated() === true);
|
||||||
|
|
||||||
|
// ── 2c. Session ID rotation fires on the interval, not on an exact modulo ────
|
||||||
|
// Seed a rotation older than the 30 min interval; a request at an arbitrary
|
||||||
|
// time must still rotate (the old modulo-on-age check never fired).
|
||||||
|
$_SESSION = [
|
||||||
|
'admin_authenticated' => true,
|
||||||
|
'admin_login_at' => time() - 60,
|
||||||
|
'admin_last_activity' => time() - 60,
|
||||||
|
'admin_last_rotation' => time() - 3600, // 1 h since last rotation
|
||||||
|
];
|
||||||
|
$sidBefore = session_id();
|
||||||
|
AdminAuth::isAuthenticated();
|
||||||
|
check('stale rotation triggers session_regenerate_id', session_id() !== $sidBefore);
|
||||||
|
check('rotation timestamp updated', ($_SESSION['admin_last_rotation'] ?? 0) > time() - 60);
|
||||||
|
|
||||||
|
// ── 3. An idle session is rejected and destroyed (no redirect/exit) ──────────
|
||||||
|
// Last: enforceSessionTimeout() destroys the session, so no further auth call
|
||||||
|
// restarts it (which would warn under CLI header constraints).
|
||||||
|
$_SESSION = [
|
||||||
|
'admin_authenticated' => true,
|
||||||
|
'admin_login_at' => time() - 21600,
|
||||||
|
'admin_last_activity' => time() - 21600, // 6 h idle > 4 h idle timeout
|
||||||
|
];
|
||||||
|
check('idle session is NOT authenticated', AdminAuth::isAuthenticated() === false);
|
||||||
|
check('idle session was destroyed', empty($_SESSION['admin_authenticated']));
|
||||||
|
|
||||||
|
// ── Cleanup ─────────────────────────────────────────────────────────────────
|
||||||
|
$db->setSetting('admin_password_hash', '');
|
||||||
|
unlink($tmpDb);
|
||||||
|
ob_end_flush();
|
||||||
|
|
||||||
|
echo "\n";
|
||||||
|
if ($failures === 0) {
|
||||||
|
echo "✅ Session-keepalive smoke test passed.\n";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
echo "❌ {$failures} check(s) failed.\n";
|
||||||
|
exit(1);
|
||||||
Reference in New Issue
Block a user