From 307988eb3c3205f77af8636ca523e591fc7639e5 Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Mon, 24 Aug 2026 13:34:42 +0200 Subject: [PATCH] feat: enforce idle + absolute timeouts on admin session --- app/src/AdminAuth.php | 49 +++++++++++++++++++++++++++++++++++++++- docs/security.md | 42 ++++++++++++++++++++++++++++++++++ scripts/deploy-server.sh | 20 ++++++++++++++++ 3 files changed, 110 insertions(+), 1 deletion(-) diff --git a/app/src/AdminAuth.php b/app/src/AdminAuth.php index 131576d..70ead5f 100644 --- a/app/src/AdminAuth.php +++ b/app/src/AdminAuth.php @@ -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. diff --git a/docs/security.md b/docs/security.md index 01261ca..d44c4e3 100644 --- a/docs/security.md +++ b/docs/security.md @@ -16,10 +16,52 @@ Current security posture for XAMXAM. - `AdminAuth::requireLogin()` guards every admin action/route. - Session cookies hardened: `HttpOnly`, `SameSite=Strict`, `Secure`, `Path=/admin`; regenerated on login. +- **Session timeouts** (server-side, enforced by `AdminAuth::enforceSessionTimeout()` + on every gated request): + - **Idle timeout** — 30 min without activity → session destroyed, redirected + to login. + - **Absolute timeout** — 12 h since login → forced re-login, regardless of + activity. + - Session ID rotated periodically (every 30 min) to limit fixation/replay. + - The session *cookie* `lifetime` is 7 days, but that is only an upper bound + on browser retention — the actual session is bounded by the two timeouts above. +- **PHP-FPM session GC tuning** — see below. The app enforces its own timeouts, + so `session.gc_maxlifetime` must be ≥ the 12 h absolute timeout or PHP would + reap active sessions early. - nginx `auth_basic` layer has been removed; the PHP session layer is the only gate. (LDAP-based login is a proposed future enhancement — see [`ldap.md`](ldap.md). It is **not** implemented.) +### PHP-FPM session GC configuration + +The admin session's lifetime is enforced *by the app* (the timeouts above), so +PHP's own session garbage collector must not reap active sessions before the +12 h absolute timeout. The following is provisioned by +`scripts/deploy-server.sh` (run via `just deploy-nginx`): + +**File:** `/etc/php/8.4/fpm/conf.d/zz-xamxam-session.ini` + +```ini +; XAMXAM session tuning. +; AdminAuth enforces its own idle/absolute timeouts (30 min / 12 h), so +; gc_maxlifetime must be >= the absolute timeout or PHP would reap active +; sessions from under the app. +session.gc_maxlifetime = 43200 +session.gc_probability = 1 +session.gc_divisor = 100 +``` + +- `gc_maxlifetime = 43200` (12 h) — matches `ABSOLUTE_TIMEOUT_SECONDS`; + **must stay ≥ the app's absolute timeout**. +- `gc_probability = 1` / `gc_divisor = 100` — re-enables the GC (default was + `gc_probability = 0`, i.e. disabled, so stale session files were never reaped). +- Applied on FPM reload (`systemctl reload php8.4-fpm`), done by the deploy script. + +> ⚠️ Keep these three knobs in sync with the constants in +> `app/src/AdminAuth.php` (`IDLE_TIMEOUT_SECONDS`, `ABSOLUTE_TIMEOUT_SECONDS`, +> `COOKIE_LIFETIME_SECONDS`). If you raise the app's absolute timeout beyond +> 12 h, raise `gc_maxlifetime` to match. + ## Transport & headers Enforced in `nginx/xamxam.conf` (see `nginx/docs/SECURITY_HEADERS.md`): diff --git a/scripts/deploy-server.sh b/scripts/deploy-server.sh index c5f9e7f..b994393 100755 --- a/scripts/deploy-server.sh +++ b/scripts/deploy-server.sh @@ -97,6 +97,22 @@ chown www-data:xamxam /var/log/xamxam chmod 2775 /var/log/xamxam 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 +# 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 +; gc_maxlifetime must be >= the absolute timeout or PHP would reap active +; sessions from under the app. +session.gc_maxlifetime = 43200 +session.gc_probability = 1 +session.gc_divisor = 100 +INI +ok "PHP-FPM session GC: $PHP_FPM_INI" + # Backups dir must be writable by both www-data (cron) and the deploy user # (xamxam group) so scripts/migrate.sh can write a pre-deploy snapshot before # running migrations. @@ -164,6 +180,10 @@ echo "------------------------------" systemctl reload nginx ok "Nginx reloaded" +# Reload PHP-FPM so the session GC settings take effect. +systemctl reload php8.4-fpm 2>/dev/null || systemctl reload php-fpm 2>/dev/null || true +ok "PHP-FPM reloaded" + # ── Done ────────────────────────────────────────────────────────────────────── printf "\n" ok "Permissions fixed"