mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-09-25 01:53:03 +02:00
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.
119 lines
4.6 KiB
PHP
119 lines
4.6 KiB
PHP
<?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);
|