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:
Pontoporeia
2026-09-18 16:26:49 +02:00
parent 25c5133086
commit 554ba3ee8d
10 changed files with 317 additions and 24 deletions
+20 -4
View File
@@ -18,13 +18,29 @@ Current security posture for XAMXAM.
`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.
- **Idle timeout** — 4 h without activity → session destroyed, redirected
to login. Raised from 30 min because the back-office's primary workflow is
long-form data entry; combined with the activity-driven keepalive below, this
means "no interaction at all", not "no navigation".
- **Absolute timeout** — 12 h since login → forced re-login, regardless of
activity.
- Session ID rotated periodically (every 30 min) to limit fixation/replay.
- Session ID rotated every 30 min to limit fixation/replay (interval is
independent of the idle timeout, so the replay window did not widen when the
idle window was raised).
- 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.
- **Activity-driven keepalive** (`app/public/admin/session-keepalive.php` +
`assets/js/app/admin-session-keepalive.js`). `admin_last_activity` is only
refreshed by requests the browser makes. A long data-entry session on an open
form issues none, so an actively-typing admin used to be logged out after
the idle window. The keepalive closes that gap: the client marks activity on real user
input (pointer, keyboard, input, scroll, wheel, touch, focus) and pings
`/admin/session-keepalive.php` at most once per 5 min while the tab is
visible. The endpoint calls `AdminAuth::isAuthenticated()`, which runs the
same timeout enforcement and refreshes the timestamp; it returns `204` when
authenticated and `401` otherwise (the client then reloads, and the server
redirects to login). A tab left open with no interaction never pings, so the
idle timeout still applies. Verify with `just smoke-session-keepalive`.
- **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.
@@ -43,7 +59,7 @@ PHP's own session garbage collector must not reap active sessions before the
```ini
; XAMXAM session tuning.
; AdminAuth enforces its own idle/absolute timeouts (30 min / 12 h), so
; AdminAuth enforces its own idle/absolute timeouts (4 h / 12 h), so
; gc_maxlifetime must be >= the absolute timeout or PHP would reap active
; sessions from under the app.
session.gc_maxlifetime = 43200