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:
Pontoporeia
2026-09-18 16:26:49 +02:00
parent 25c5133086
commit 554ba3ee8d
10 changed files with 317 additions and 24 deletions
+2 -2
View File
@@ -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
+118
View File
@@ -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);