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.
+42
View File
@@ -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`):
+20
View File
@@ -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"