mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-11-09 19:20:09 +01: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:
1 parent
25c5133086
commit
554ba3ee8d
10 files changed
+317
-24
No files matched your search
+41
-18
@@ -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 = <<<HTML
|
||||
<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>
|
||||
<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><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>
|
||||
</body></html>
|
||||
HTML;
|
||||
<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>
|
||||
<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><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>
|
||||
</body></html>
|
||||
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.
|
||||
|
||||
Reference in new issue
Block a user