浏览代码

Docs: mark R01-N08 fixed, refresh SPEC §9 / §11 / §13

R01-N08 closed by bc745cd. Update REVIEW_01.md status block, the
fix-loop ordering, and SPEC.md §9 (Shipped — new entry above the
new-sprint-form / row-hover one), §11 (test count 202→211), §13
(git history).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa 2 天之前
父节点
当前提交
a8ed6afef2
共有 2 个文件被更改,包括 58 次插入3 次删除
  1. 34 1
      SPEC.md
  2. 24 2
      doc/REVIEW_01.md

+ 34 - 1
SPEC.md

@@ -1277,6 +1277,38 @@ is gone — see `src/Auth/BootstrapAdmin.php`.
       `Request::ip()` / `isHttps()`. Tests: 202 / 533 (was 184 / 502).
       Eighth fix from `doc/REVIEW_01.md`.
 
+- [x] **R01-N08 — Idle session timeout + CSRF rotation on login**
+      (`bc745cd`). A signed-in session previously stayed valid until the
+      browser closed or the 8h `gc_maxlifetime` GC tick fired — a stolen
+      session cookie paired with the same-session CSRF token was good
+      for hours of attacker-driven mutations. `SessionGuard` now drives
+      a 30-minute idle window (`IDLE_TIMEOUT_SECONDS = 1800`) inside
+      `start()`: any request that lands more than 1800 s after the
+      previous one drops `user_id` / `login_at` / `last_active` /
+      `csrf_token` and `session_regenerate_id(true)` rotates the id —
+      the next gate sees an anonymous session and redirects to
+      `/auth/login`. Foreign session keys (the OIDC library's
+      state/nonce/PKCE) are preserved so an in-flight bounce to Entra
+      is not killed by a stale idle clock from a previous logged-in
+      session. `login()` now stamps `last_active = time()` and
+      `unset()`s `csrf_token`, so a token a pre-login attacker may
+      have captured from the public homepage form cannot be replayed
+      against the now-authenticated session (the next `csrfToken()`
+      call mints a fresh `bin2hex(random_bytes(32))`). The boundary
+      is `>=` so a session exactly 1800 s idle is expired. Two pure-
+      static helpers carry the policy so it is testable without
+      spinning up PHP's session machinery:
+      `isIdleExpired(int $lastActive, int $now): bool` and
+      `expireIdleSession(array &$session, int $now): bool`. New
+      `tests/Auth/SessionGuardTest.php` (9 cases) pins the constant,
+      the boundary semantics (0 / 1 s / 1799 s / 1800 s / 2 h), the
+      anonymous-session no-op, the `last_active`-missing seed-on-
+      first-hit branch, the auth-key drop on idle with foreign-key
+      survival, the exact-boundary expiry, the just-fresh case, and
+      the non-int `user_id` defence that mirrors `currentUserId()`'s
+      contract. Tests: 211 / 562 (was 202 / 533). Ninth fix from
+      `doc/REVIEW_01.md`.
+
 - [x] **New sprint form: drop weeks input + task list row hover**
       (`3728106`). The `/sprints/new` form no longer collects an
       `n_weeks` value — the week count is derived from `start_date` /
@@ -1346,7 +1378,7 @@ for f in $(git ls-files '*.php'); do php -l "$f" | tail -1 | sed "s|^|$f: |"; do
 Run the test suite:
 ```bash
 vendor/bin/phpunit
-# → OK (202 tests, 533 assertions)
+# → OK (211 tests, 562 assertions)
 ```
 
 The Phase 20 parser tests need `ext-dom`, `ext-zip`, `ext-xmlreader`,
@@ -1393,6 +1425,7 @@ before acting — nothing here is load-bearing once it grows stale.
 ## 13. Git history (as of this writing)
 
 ```
+bc745cd Fix R01-N08: idle session timeout + CSRF rotation on login
 a2e77ea Fix R01-N05 + R01-N07: trusted-proxy aware HTTPS + client IP
 f565c86 Fix R01-N03: explicit env-bootstrap for the first OIDC admin
 2b8f167 Docs: mark R01-N06 fixed, refresh SPEC §3 / §4 / §7 / §9 / §11 / §13

+ 24 - 2
doc/REVIEW_01.md

@@ -292,7 +292,28 @@ note. Do not delete entries — they're history.
 
 ### R01-N08 — Long-lived sessions, no inactivity timeout, CSRF token never rotated
 - **Severity**: MEDIUM.
-- **Status**: open.
+- **Status**: fixed-in-`bc745cd` — `SessionGuard` gained a 30-minute idle
+  window (`IDLE_TIMEOUT_SECONDS = 1800`) enforced inside `start()`. New
+  pure-static helpers `isIdleExpired($lastActive, $now)` and
+  `expireIdleSession(array &$session, $now)` carry the policy: on every
+  `start()`, if `user_id` is set and `$now - last_active >= 1800`, the
+  authenticated keys (`user_id`, `login_at`, `last_active`,
+  `csrf_token`) are dropped and `session_regenerate_id(true)` rotates
+  the id — the next gate sees an anonymous session and redirects to
+  `/auth/login`. Foreign session state (the OIDC library's
+  state/nonce/PKCE) is preserved so an in-flight bounce to Entra is
+  not killed by a stale idle clock. `login()` now stamps
+  `last_active = time()` and `unset()`s `csrf_token`, so a token a
+  pre-login attacker may have captured from the public homepage
+  form cannot be replayed against the now-authenticated session
+  (the next `csrfToken()` call mints a fresh
+  `bin2hex(random_bytes(32))`). Boundary is `>=` so a session
+  exactly 1800s idle is expired. New `tests/Auth/SessionGuardTest.php`
+  (9 cases) pins the constant, the boundary semantics
+  (0 / 1s / 1799s / 1800s / 2h), the anonymous-session no-op, the
+  `last_active`-missing seed-on-first-hit, the auth-key drop on
+  idle with foreign-key survival, and the non-int `user_id`
+  defence. Tests: 211 / 562 (was 202 / 533).
 - **Where**: `src/Auth/SessionGuard.php` lines 27-60, 109-116.
 - **What**: `session.gc_maxlifetime = 28800` (8h) and the session cookie has
   `lifetime=0` (browser-session). Once authenticated, a user stays signed in
@@ -752,7 +773,8 @@ A reasonable cadence (do not treat as binding):
 7. ~~**R01-N03** (first-user bootstrap hardening)~~ — fixed in `f565c86`.
 8. ~~**R01-N07** + **R01-N05** (proxy / HTTPS handling) — paired~~ —
    fixed in `a2e77ea`.
-9. **R01-N08** (idle session timeout + CSRF rotation).
+9. ~~**R01-N08** (idle session timeout + CSRF rotation)~~ — fixed in
+   `bc745cd`.
 10. **R01-N10** (bind-not-concat sweep) — mechanical.
 11. **R01-N12** (date filter validation) — UX bug.
 12. The rest as time permits.