diff --git a/TODO.md b/TODO.md index ef3b562..41e5401 100644 --- a/TODO.md +++ b/TODO.md @@ -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] #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] #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 - [ ] #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 diff --git a/app/public/admin/README.md b/app/public/admin/README.md index c606751..ca85b4a 100644 --- a/app/public/admin/README.md +++ b/app/public/admin/README.md @@ -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-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 | +| `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) | | `import.php` | Redirects to `/admin/` (CSV import is inline in `index.php`) | | `status.php`, `markdown-cheatsheet-fragment.php`, `*fragment.php` | HTMX fragments / helpers | diff --git a/app/public/admin/session-keepalive.php b/app/public/admin/session-keepalive.php new file mode 100644 index 0000000..5817572 --- /dev/null +++ b/app/public/admin/session-keepalive.php @@ -0,0 +1,32 @@ +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; diff --git a/app/public/assets/js/app/admin-entry.js b/app/public/assets/js/app/admin-entry.js index ad4ef69..f1f9662 100644 --- a/app/public/assets/js/app/admin-entry.js +++ b/app/public/assets/js/app/admin-entry.js @@ -10,6 +10,7 @@ import "./beforeunload-guard.js"; import "./clipboard.js"; import "./smtp-error-focus.js"; +import "./admin-session-keepalive.js"; // HTMX-powered features (htmx is a global from vendor script) import "./htmx-global-setup.js"; diff --git a/app/public/assets/js/app/admin-session-keepalive.js b/app/public/assets/js/app/admin-session-keepalive.js new file mode 100644 index 0000000..87c5476 --- /dev/null +++ b/app/public/assets/js/app/admin-session-keepalive.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(); + }); +})(); diff --git a/app/src/AdminAuth.php b/app/src/AdminAuth.php index 70ead5f..2cb791e 100644 --- a/app/src/AdminAuth.php +++ b/app/src/AdminAuth.php @@ -20,11 +20,20 @@ class AdminAuth private const COOLDOWN_MINUTES = 15; // Session lifetime (server-side): idle timeout and absolute timeout. - // Aligned with OWASP ASVS / NIST 800-63B guidance for admin panels. - private const IDLE_TIMEOUT_SECONDS = 1800; // 30 min with no activity + // Aligned with OWASP ASVS / NIST 800-63B guidance for admin panels, with the + // 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 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). */ private static ?Database $dbOverride = null; @@ -73,15 +82,18 @@ class AdminAuth * * Called on every gated request. If the session has been idle longer than * 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 * (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])) { - return; // Not authenticated — nothing to time out. + return true; // Not authenticated — nothing to time out. } $now = time(); @@ -93,17 +105,22 @@ class AdminAuth if ($idle > self::IDLE_TIMEOUT_SECONDS || $absolute > self::ABSOLUTE_TIMEOUT_SECONDS) { self::logout(); - header('Location: ' . self::LOGIN_URL); - exit; + return false; } // 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['admin_last_rotation'] = $now; } // Refresh the activity timestamp on every authenticated request. $_SESSION['admin_last_activity'] = $now; + return true; } /** @@ -134,7 +151,10 @@ class AdminAuth public static function requireLogin(): void { self::startSession(); - self::enforceSessionTimeout(); + if (!self::enforceSessionTimeout()) { + header('Location: ' . self::LOGIN_URL); + exit; + } $storedHash = self::getStoredHash(); if ($storedHash === null) { return; // No password configured → dev / cli-server, skip. @@ -202,6 +222,7 @@ class AdminAuth $_SESSION[self::SESSION_KEY] = true; $_SESSION['admin_login_at'] = time(); $_SESSION['admin_last_activity'] = time(); + $_SESSION['admin_last_rotation'] = time(); return true; } @@ -272,14 +293,14 @@ class AdminAuth $subject = 'Réinitialisation du mot de passe — XAMXAM'; $body = << -

Réinitialisation du mot de passe

-

Une demande de réinitialisation du mot de passe administrateur a été effectuée.

-

Cliquez sur le lien ci-dessous pour définir un nouveau mot de passe. Ce lien est à usage unique et expire dans 30 minutes.

-

Définir un nouveau mot de passe

-

Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail.

- -HTML; + +

Réinitialisation du mot de passe

+

Une demande de réinitialisation du mot de passe administrateur a été effectuée.

+

Cliquez sur le lien ci-dessous pour définir un nouveau mot de passe. Ce lien est à usage unique et expire dans 30 minutes.

+

Définir un nouveau mot de passe

+

Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail.

+ + HTML; try { return SmtpRelay::send($db, $to, $subject, $body); @@ -330,7 +351,9 @@ HTML; public static function isAuthenticated(): bool { self::startSession(); - self::enforceSessionTimeout(); + if (!self::enforceSessionTimeout()) { + return false; + } $storedHash = self::getStoredHash(); if ($storedHash === null) { return true; // No password configured → dev mode. diff --git a/docs/security.md b/docs/security.md index 026924b..f7eaf72 100644 --- a/docs/security.md +++ b/docs/security.md @@ -18,13 +18,29 @@ Current security posture for XAMXAM. `Path=/admin`; regenerated on login. - **Session timeouts** (server-side, enforced by `AdminAuth::enforceSessionTimeout()` on every gated request): - - **Idle timeout** — 30 min without activity → session destroyed, redirected - to login. + - **Idle timeout** — 4 h without activity → session destroyed, redirected + 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 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 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, so `session.gc_maxlifetime` must be ≥ the 12 h absolute timeout or PHP would reap active sessions early. @@ -43,7 +59,7 @@ PHP's own session garbage collector must not reap active sessions before the ```ini ; 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 ; sessions from under the app. session.gc_maxlifetime = 43200 diff --git a/justfile b/justfile index bfd2301..35ea279 100644 --- a/justfile +++ b/justfile @@ -564,6 +564,11 @@ smoke-password-reset: # End-to-end smoke test for the password-reset flow (throwaway DB, no email). @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')] lint-php: # Static analysis (phpstan) + coding standards (php-cs-fixer) diff --git a/scripts/deploy-server.sh b/scripts/deploy-server.sh index 81c2a08..520b010 100755 --- a/scripts/deploy-server.sh +++ b/scripts/deploy-server.sh @@ -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. # 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. PHP_FPM_INI="/etc/php/8.4/fpm/conf.d/zz-xamxam-session.ini" cat > "$PHP_FPM_INI" <<'INI' ; 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 ; sessions from under the app. session.gc_maxlifetime = 43200 diff --git a/scripts/smoke-test-session-keepalive.php b/scripts/smoke-test-session-keepalive.php new file mode 100644 index 0000000..bba8a71 --- /dev/null +++ b/scripts/smoke-test-session-keepalive.php @@ -0,0 +1,118 @@ +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);