feat: enforce idle + absolute timeouts on admin session

This commit is contained in:
Pontoporeia
2026-09-18 16:26:36 +02:00
parent 30a16f9e9e
commit 307988eb3c
3 changed files with 110 additions and 1 deletions
+48 -1
View File
@@ -19,6 +19,12 @@ class AdminAuth
private const MAX_ATTEMPTS = 5;
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
private const ABSOLUTE_TIMEOUT_SECONDS = 43200; // 12 h regardless of activity
private const COOKIE_LIFETIME_SECONDS = 604800; // 7 d — upper bound only
/** Test hook: override the Database connection (e.g. temp DB in smoke tests). */
private static ?Database $dbOverride = null;
@@ -53,7 +59,7 @@ class AdminAuth
}
// Harden session cookie (item #8)
session_set_cookie_params([
'lifetime' => 604800,
'lifetime' => self::COOKIE_LIFETIME_SECONDS,
'path' => '/admin',
'secure' => (php_sapi_name() !== 'cli-server'),
'httponly' => true,
@@ -62,6 +68,44 @@ class AdminAuth
session_start();
}
/**
* Enforce server-side idle + absolute timeouts on an authenticated session.
*
* 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.
*
* Only acts on authenticated sessions; leaves unauthenticated sessions
* (including throttle counters) untouched.
*/
private static function enforceSessionTimeout(): void
{
if (empty($_SESSION[self::SESSION_KEY])) {
return; // Not authenticated — nothing to time out.
}
$now = time();
$loginAt = (int) ($_SESSION['admin_login_at'] ?? $now);
$activity = (int) ($_SESSION['admin_last_activity'] ?? $loginAt);
$idle = $now - $activity;
$absolute = $now - $loginAt;
if ($idle > self::IDLE_TIMEOUT_SECONDS || $absolute > self::ABSOLUTE_TIMEOUT_SECONDS) {
self::logout();
header('Location: ' . self::LOGIN_URL);
exit;
}
// Rotate the session ID periodically to limit fixation/replay window.
if ($absolute > 0 && $absolute >= self::IDLE_TIMEOUT_SECONDS && $absolute % self::IDLE_TIMEOUT_SECONDS === 0) {
session_regenerate_id(true);
}
// Refresh the activity timestamp on every authenticated request.
$_SESSION['admin_last_activity'] = $now;
}
/**
* Fetch the admin password hash from site_settings.
* Returns null if not set (dev mode).
@@ -90,6 +134,7 @@ class AdminAuth
public static function requireLogin(): void
{
self::startSession();
self::enforceSessionTimeout();
$storedHash = self::getStoredHash();
if ($storedHash === null) {
return; // No password configured → dev / cli-server, skip.
@@ -156,6 +201,7 @@ class AdminAuth
session_regenerate_id(true);
$_SESSION[self::SESSION_KEY] = true;
$_SESSION['admin_login_at'] = time();
$_SESSION['admin_last_activity'] = time();
return true;
}
@@ -284,6 +330,7 @@ HTML;
public static function isAuthenticated(): bool
{
self::startSession();
self::enforceSessionTimeout();
$storedHash = self::getStoredHash();
if ($storedHash === null) {
return true; // No password configured → dev mode.