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:
@@ -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 |
|
||||
|
||||
@@ -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 "./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";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user