mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-09-25 01:53:03 +02:00
feat: enforce idle + absolute timeouts on admin session
This commit is contained in:
+48
-1
@@ -19,6 +19,12 @@ class AdminAuth
|
|||||||
private const MAX_ATTEMPTS = 5;
|
private const MAX_ATTEMPTS = 5;
|
||||||
private const COOLDOWN_MINUTES = 15;
|
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). */
|
/** Test hook: override the Database connection (e.g. temp DB in smoke tests). */
|
||||||
private static ?Database $dbOverride = null;
|
private static ?Database $dbOverride = null;
|
||||||
|
|
||||||
@@ -53,7 +59,7 @@ class AdminAuth
|
|||||||
}
|
}
|
||||||
// Harden session cookie (item #8)
|
// Harden session cookie (item #8)
|
||||||
session_set_cookie_params([
|
session_set_cookie_params([
|
||||||
'lifetime' => 604800,
|
'lifetime' => self::COOKIE_LIFETIME_SECONDS,
|
||||||
'path' => '/admin',
|
'path' => '/admin',
|
||||||
'secure' => (php_sapi_name() !== 'cli-server'),
|
'secure' => (php_sapi_name() !== 'cli-server'),
|
||||||
'httponly' => true,
|
'httponly' => true,
|
||||||
@@ -62,6 +68,44 @@ class AdminAuth
|
|||||||
session_start();
|
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.
|
* Fetch the admin password hash from site_settings.
|
||||||
* Returns null if not set (dev mode).
|
* Returns null if not set (dev mode).
|
||||||
@@ -90,6 +134,7 @@ class AdminAuth
|
|||||||
public static function requireLogin(): void
|
public static function requireLogin(): void
|
||||||
{
|
{
|
||||||
self::startSession();
|
self::startSession();
|
||||||
|
self::enforceSessionTimeout();
|
||||||
$storedHash = self::getStoredHash();
|
$storedHash = self::getStoredHash();
|
||||||
if ($storedHash === null) {
|
if ($storedHash === null) {
|
||||||
return; // No password configured → dev / cli-server, skip.
|
return; // No password configured → dev / cli-server, skip.
|
||||||
@@ -156,6 +201,7 @@ class AdminAuth
|
|||||||
session_regenerate_id(true);
|
session_regenerate_id(true);
|
||||||
$_SESSION[self::SESSION_KEY] = true;
|
$_SESSION[self::SESSION_KEY] = true;
|
||||||
$_SESSION['admin_login_at'] = time();
|
$_SESSION['admin_login_at'] = time();
|
||||||
|
$_SESSION['admin_last_activity'] = time();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,6 +330,7 @@ HTML;
|
|||||||
public static function isAuthenticated(): bool
|
public static function isAuthenticated(): bool
|
||||||
{
|
{
|
||||||
self::startSession();
|
self::startSession();
|
||||||
|
self::enforceSessionTimeout();
|
||||||
$storedHash = self::getStoredHash();
|
$storedHash = self::getStoredHash();
|
||||||
if ($storedHash === null) {
|
if ($storedHash === null) {
|
||||||
return true; // No password configured → dev mode.
|
return true; // No password configured → dev mode.
|
||||||
|
|||||||
@@ -16,10 +16,52 @@ Current security posture for XAMXAM.
|
|||||||
- `AdminAuth::requireLogin()` guards every admin action/route.
|
- `AdminAuth::requireLogin()` guards every admin action/route.
|
||||||
- Session cookies hardened: `HttpOnly`, `SameSite=Strict`, `Secure`,
|
- Session cookies hardened: `HttpOnly`, `SameSite=Strict`, `Secure`,
|
||||||
`Path=/admin`; regenerated on login.
|
`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
|
- nginx `auth_basic` layer has been removed; the PHP session layer is the only
|
||||||
gate. (LDAP-based login is a proposed future enhancement — see
|
gate. (LDAP-based login is a proposed future enhancement — see
|
||||||
[`ldap.md`](ldap.md). It is **not** implemented.)
|
[`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
|
## Transport & headers
|
||||||
|
|
||||||
Enforced in `nginx/xamxam.conf` (see `nginx/docs/SECURITY_HEADERS.md`):
|
Enforced in `nginx/xamxam.conf` (see `nginx/docs/SECURITY_HEADERS.md`):
|
||||||
|
|||||||
@@ -97,6 +97,22 @@ chown www-data:xamxam /var/log/xamxam
|
|||||||
chmod 2775 /var/log/xamxam
|
chmod 2775 /var/log/xamxam
|
||||||
ok "Log dir: /var/log/xamxam owned by www-data:xamxam (2775)"
|
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
|
# 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
|
# (xamxam group) so scripts/migrate.sh can write a pre-deploy snapshot before
|
||||||
# running migrations.
|
# running migrations.
|
||||||
@@ -164,6 +180,10 @@ echo "------------------------------"
|
|||||||
systemctl reload nginx
|
systemctl reload nginx
|
||||||
ok "Nginx reloaded"
|
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 ──────────────────────────────────────────────────────────────────────
|
# ── Done ──────────────────────────────────────────────────────────────────────
|
||||||
printf "\n"
|
printf "\n"
|
||||||
ok "Permissions fixed"
|
ok "Permissions fixed"
|
||||||
|
|||||||
Reference in New Issue
Block a user