diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 91e2aa5..d3b12a5 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -451,7 +451,15 @@ Before any phase is marked complete, all three gates must pass: **Depends on**: Phase 7 **Requirements**: CR-01, CR-02, CR-03 -**Status:** Context gathered — ready for planning +**Plans**: 2 plans + +**Wave 1** — Backend: service + API changes + +- [ ] 07.1-01-PLAN.md — Add skip_token_hash param to revoke_all_refresh_tokens + wire revoke into change_password, enable_totp, disable_totp with sessions_revoked response + audit log + +**Wave 2** *(blocked on Wave 1)* + +- [ ] 07.1-02-PLAN.md — Tests (3 new test_*_revokes_other_sessions) + frontend toast in SettingsAccountTab.vue + TotpEnrollment.vue --- @@ -501,7 +509,7 @@ Before any phase is marked complete, all three gates must pass: | 6.1. Close v1.0 audit gaps | 2/2 | Complete | 2026-05-30 | | 6.2. Close v1 sharing + cloud-delete + CSV export gaps | 5/5 | Complete | 2026-05-31 | | 7. Redo and optimize LLM integration | 5/5 | Complete | 2026-06-05 | -| 7.1. Security: session revocation on privilege change (CR-01..03) | 0/? | Context gathered | — | +| 7.1. Security: session revocation on privilege change (CR-01..03) | 0/2 | Planned | — | | 7.2. Security: JTI claim + Redis access-token revocation | 0/? | Not planned (INSERTED) | — | | 7.3. Security: ES256 algorithm upgrade | 0/? | Not planned (INSERTED) | — | | 7.4. Security: token fingerprinting / token binding | 0/? | Not planned (INSERTED) | — | diff --git a/.planning/phases/07.1-security-session-revocation-on-privilege-change/07.1-01-PLAN.md b/.planning/phases/07.1-security-session-revocation-on-privilege-change/07.1-01-PLAN.md new file mode 100644 index 0000000..7b2b699 --- /dev/null +++ b/.planning/phases/07.1-security-session-revocation-on-privilege-change/07.1-01-PLAN.md @@ -0,0 +1,188 @@ +--- +phase: 07.1 +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - backend/services/auth.py + - backend/api/auth.py +autonomous: true +requirements: + - CR-01 + - CR-02 + - CR-03 + +must_haves: + truths: + - "Changing password revokes all other refresh tokens; current session stays alive" + - "Enabling TOTP revokes all other refresh tokens; current session stays alive" + - "Disabling TOTP revokes all other refresh tokens; current session stays alive" + - "All three endpoints return sessions_revoked: int in their response body" + - "The revocation count is written to the audit log metadata_ for all three operations" + artifacts: + - path: "backend/services/auth.py" + provides: "revoke_all_refresh_tokens with skip_token_hash optional param" + contains: "skip_token_hash: Optional[str] = None" + - path: "backend/api/auth.py" + provides: "revoke call + sessions_revoked in change_password, enable_totp, disable_totp" + contains: "sessions_revoked" + key_links: + - from: "backend/api/auth.py (change_password)" + to: "backend/services/auth.py (revoke_all_refresh_tokens)" + via: "await auth_service.revoke_all_refresh_tokens(session, current_user.id, skip_token_hash=...)" + pattern: "skip_token_hash" + - from: "backend/api/auth.py (enable_totp)" + to: "backend/services/auth.py (revoke_all_refresh_tokens)" + via: "await auth_service.revoke_all_refresh_tokens(session, current_user.id, skip_token_hash=...)" + pattern: "skip_token_hash" + - from: "backend/api/auth.py (disable_totp)" + to: "backend/services/auth.py (revoke_all_refresh_tokens)" + via: "await auth_service.revoke_all_refresh_tokens(session, current_user.id, skip_token_hash=...)" + pattern: "skip_token_hash" +--- + + +Implement the three missing session-revocation calls (CR-01, CR-02, CR-03) by: +1. Extending `revoke_all_refresh_tokens` in `services/auth.py` with an optional `skip_token_hash` parameter so callers can exclude the current session. +2. Wiring the revoke call into the `change_password`, `enable_totp`, and `disable_totp` handlers in `api/auth.py`, deriving `skip_token_hash` from the request's refresh token cookie, writing the count to the audit log, and returning `sessions_revoked` in each response. + +Purpose: Enforces the CLAUDE.md invariant — "Password change, TOTP enroll/revoke, and account deactivation immediately revoke all active sessions." +Output: Modified `services/auth.py` and `api/auth.py`; no migrations, no new routes. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/phases/07.1-security-session-revocation-on-privilege-change/07.1-CONTEXT.md + + + + + + Task 1: Add skip_token_hash param to revoke_all_refresh_tokens + backend/services/auth.py + + + backend/services/auth.py — read the full function at lines 218-237 (revoke_all_refresh_tokens), lines 154-172 (create_refresh_token, shows SHA-256 hash pattern), and lines 176-215 (rotate_refresh_token, shows how raw cookie value is hashed for DB lookup — identical pattern needed here). + + + + Modify `revoke_all_refresh_tokens(session: AsyncSession, user_id: uuid.UUID)` to accept an additional optional parameter `skip_token_hash: Optional[str] = None`. + + Update the WHERE clause in the SQLAlchemy `select(RefreshToken)` query to also filter out the token to skip when `skip_token_hash` is not None. The filter must add `RefreshToken.token_hash != skip_token_hash` as an additional condition in the `where()` call — only when the param is not None. When `skip_token_hash is None` (the existing `logout_all` caller), behavior is completely unchanged: all revoked=False tokens for user_id are revoked. + + The `Optional` import is already present in the file (verify before adding). Do not change the function signature for any other caller — the default `None` value ensures backwards compatibility. + + Do NOT change the row-by-row loop revocation logic. Bulk UPDATE optimization is explicitly out of scope per CONTEXT.md. + + + + cd /Users/nik/Documents/Progamming/document_scanner/backend && grep -n "skip_token_hash" services/auth.py + + + + - `services/auth.py` contains `async def revoke_all_refresh_tokens(session: AsyncSession, user_id: uuid.UUID, skip_token_hash: Optional[str] = None) -> int:` + - The WHERE clause excludes `skip_token_hash` when it is not None: `RefreshToken.token_hash != skip_token_hash` appears in the updated query body + - The existing `logout_all` caller in `api/auth.py` (line ~410) continues to call `revoke_all_refresh_tokens(session, current_user.id)` with no third argument — no change required there + - `grep -n "skip_token_hash" backend/services/auth.py` returns at least 2 lines (signature + WHERE usage) + + + + + Task 2: Wire revoke into change_password, enable_totp, disable_totp + backend/api/auth.py + + + backend/api/auth.py — read the following sections: + - Lines 399-423 (logout_all handler) — this is the canonical pattern: revoke + write_audit_log metadata + commit + response + - Lines 454-503 (change_password handler) — add revoke before the existing session.commit() + - Lines 545-590 (enable_totp handler) — add revoke before the existing session.commit() + - Lines 595-624 (disable_totp handler) — add revoke before the existing session.commit() + Also verify the import block at the top of auth.py contains `import hashlib` (needed for SHA-256 hashing). + + + + Apply the same pattern to all three handlers. The pattern is identical for each: + + 1. Derive the skip hash from the request cookie before the revoke call: + Read `raw_cookie = request.cookies.get("refresh_token")` then compute + `skip_hash = hashlib.sha256(raw_cookie.encode()).hexdigest() if raw_cookie else None` + + 2. Call revoke with the skip hash after the existing audit_log call but before `session.commit()`: + `revoked = await auth_service.revoke_all_refresh_tokens(session, current_user.id, skip_token_hash=skip_hash)` + + 3. Add `sessions_revoked` to the audit log metadata_ in the existing `write_audit_log` call for each handler — extend the existing `metadata_={}` dict, or add `metadata_={"sessions_revoked": revoked}` if the current call has no metadata_ argument. Per D-06, this mirrors the `logout_all` pattern at line 419. + + 4. Return `sessions_revoked` in the response dict. Exact shapes: + - `change_password` currently returns `{"message": "Password updated"}` — change to `{"message": "Password updated", "sessions_revoked": revoked}` + - `enable_totp` currently returns `{"backup_codes": plain_codes}` — change to `{"backup_codes": plain_codes, "sessions_revoked": revoked}` + - `disable_totp` currently returns `{"message": "TOTP disabled"}` — change to `{"message": "TOTP disabled", "sessions_revoked": revoked}` + + Per D-02/D-03: `request` is already a parameter on all three handlers (check the handler signatures; `change_password` and `disable_totp` already have `request: Request`; `enable_totp` already has `request: Request` for rate limiting). No signature changes needed. + + Ensure `import hashlib` is present in the import section — it is already used in `services/auth.py:161`; verify it is also imported in `api/auth.py` before adding the hash computation. + + Placement rule: `revoke_all_refresh_tokens` call goes BEFORE `session.commit()` in each handler (so it participates in the same transaction flush). The existing `write_audit_log` call uses `session.flush()` internally, so the order is: derive skip_hash → revoke → extend audit metadata → commit → return response. + + Do NOT modify the `logout_all` handler — it already correctly revokes without skip (all sessions, including current). + + + + cd /Users/nik/Documents/Progamming/document_scanner/backend && grep -n "sessions_revoked" api/auth.py + + + + - `grep -n "sessions_revoked" backend/api/auth.py` returns at least 6 lines (3 revoke calls + 3 response dicts) + - `grep -n "skip_token_hash" backend/api/auth.py` returns at least 3 lines (one per handler) + - `grep -n "hashlib.sha256" backend/api/auth.py` returns at least 3 lines (one per handler deriving the skip hash) + - The `logout_all` handler is unchanged: `grep -n "logout_all" backend/api/auth.py` shows the handler still calls `revoke_all_refresh_tokens(session, current_user.id)` with no `skip_token_hash` argument + - `cd backend && python -c "import api.auth"` exits with code 0 (no import errors) + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| client cookie → API | Raw refresh token arrives in httpOnly cookie; must not be logged or echoed | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-7.1-01 | Tampering | skip_token_hash derivation | mitigate | Hash computed server-side with hashlib.sha256 — cookie value never compared in plaintext; constant-time exclusion via WHERE clause (not Python ==) | +| T-7.1-02 | Information Disclosure | sessions_revoked response field | accept | Count of revoked sessions is low-sensitivity metadata; exact token values never exposed | +| T-7.1-03 | Elevation of Privilege | missing skip on logout_all | accept | logout_all intentionally revokes ALL sessions (no skip) — behaviour unchanged, tested separately | +| T-7.1-SC | Tampering | npm/pip installs | accept | No new packages installed in this plan — no legitimacy gate required | + + + +After both tasks complete: + +1. `cd /Users/nik/Documents/Progamming/document_scanner/backend && python -c "import api.auth; import services.auth"` — exits 0 +2. `grep -c "sessions_revoked" backend/api/auth.py` — returns 6 or more +3. `grep -c "skip_token_hash" backend/services/auth.py` — returns 2 or more +4. `grep -c "skip_token_hash" backend/api/auth.py` — returns 3 or more +5. `logout_all` handler unchanged: still calls `revoke_all_refresh_tokens(session, current_user.id)` without skip arg + + + +- `revoke_all_refresh_tokens` has signature `(session, user_id, skip_token_hash: Optional[str] = None) -> int` and filters by token_hash when skip is set +- All three handlers (`change_password`, `enable_totp`, `disable_totp`) call `revoke_all_refresh_tokens` with the derived `skip_token_hash` before `session.commit()` +- All three handlers include `"sessions_revoked": revoked` in their response dict +- All three handlers include `"sessions_revoked": revoked` in their `write_audit_log` `metadata_` kwarg +- No existing tests broken (run `pytest -x -q` after changes to confirm) + + + +Create `.planning/phases/07.1-security-session-revocation-on-privilege-change/07.1-01-SUMMARY.md` when done. + diff --git a/.planning/phases/07.1-security-session-revocation-on-privilege-change/07.1-02-PLAN.md b/.planning/phases/07.1-security-session-revocation-on-privilege-change/07.1-02-PLAN.md new file mode 100644 index 0000000..8561c7b --- /dev/null +++ b/.planning/phases/07.1-security-session-revocation-on-privilege-change/07.1-02-PLAN.md @@ -0,0 +1,226 @@ +--- +phase: 07.1 +plan: 02 +type: execute +wave: 2 +depends_on: + - 07.1-01 +files_modified: + - backend/tests/test_auth_api.py + - frontend/src/components/settings/SettingsAccountTab.vue + - frontend/src/components/auth/TotpEnrollment.vue +autonomous: true +requirements: + - CR-01 + - CR-02 + - CR-03 + +must_haves: + truths: + - "Tests verify sessions_revoked > 0 when other sessions exist for change_password" + - "Tests verify sessions_revoked > 0 when other sessions exist for enable_totp" + - "Tests verify sessions_revoked > 0 when other sessions exist for disable_totp" + - "Tests verify current session is NOT revoked (skip behavior)" + - "Frontend shows a toast notification when sessions_revoked > 0 after password change" + - "Frontend shows a toast notification when sessions_revoked > 0 after TOTP enable" + - "Frontend shows a toast notification when sessions_revoked > 0 after TOTP disable" + artifacts: + - path: "backend/tests/test_auth_api.py" + provides: "3 new tests covering sessions_revoked behavior + skip for all three endpoints" + contains: "sessions_revoked" + - path: "frontend/src/components/settings/SettingsAccountTab.vue" + provides: "toast notification on sessions_revoked > 0 for changePassword and disableTotp" + contains: "sessions_revoked" + - path: "frontend/src/components/auth/TotpEnrollment.vue" + provides: "emit or callback for sessions_revoked > 0 after enable_totp" + contains: "sessions_revoked" + key_links: + - from: "SettingsAccountTab.vue (changePassword)" + to: "SettingsView.vue toast pattern" + via: "local sessionRevokedToast ref, same inline HTML pattern as oauthSuccessProvider toast" + pattern: "sessions_revoked" + - from: "TotpEnrollment.vue (confirmEnrollment)" + to: "SettingsAccountTab.vue parent" + via: "emit('enrolled', { sessions_revoked }) OR local toast inside TotpEnrollment" + pattern: "sessions_revoked" +--- + + +Add tests proving the skip-current-session behavior for all three privilege-change endpoints, and add a brief frontend toast notification triggered when `sessions_revoked > 0` is returned from `change_password`, `enable_totp`, or `disable_totp`. + +Purpose: Closes the test coverage gap (CLAUDE.md testing protocol: every new behavior must have at least one test) and delivers the user-facing UX signal described in D-05. +Output: 3 new pytest tests in `test_auth_api.py`; toast in `SettingsAccountTab.vue` and `TotpEnrollment.vue`. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/phases/07.1-security-session-revocation-on-privilege-change/07.1-CONTEXT.md +@.planning/phases/07.1-security-session-revocation-on-privilege-change/07.1-01-SUMMARY.md + + + + + + Task 1: Add tests for sessions_revoked behavior on all three endpoints + backend/tests/test_auth_api.py + + + backend/tests/test_auth_api.py — read lines 1-50 (imports, fixture helpers `_register` and `_login`) and lines 240-258 (`test_change_password_success` — the pattern to extend). These show how to register a user, obtain a token, and call change-password. + backend/tests/conftest.py — check the `authed_client`, `auth_user`, and `db_session` fixture definitions to understand what is available. + backend/api/auth.py — check the updated `change_password`, `enable_totp`, and `disable_totp` response shapes from Plan 01 (to match `sessions_revoked` key name exactly). + + + + - test_change_password_revokes_other_sessions: Register user, log in on two different clients (simulate two sessions by inserting a second RefreshToken row directly in the DB or calling create_refresh_token twice), then call POST /api/auth/change-password with the first session's token. Assert response contains `sessions_revoked >= 1`. Assert the second refresh token row is now `revoked=True` in the DB. Assert the first (calling) session's refresh token row is still `revoked=False` (current session was skipped). Note: `authed_client` does not set a refresh_token cookie by default — insert a second RefreshToken row via `auth_service.create_refresh_token(db_session, user.id)` to create a revocable "other session", then assert its row is revoked after the call. + - test_enable_totp_revokes_other_sessions: Register user, set `user.totp_secret` via DB fixture (same pattern as existing `test_login_backup_code_success` at line ~308), insert a second RefreshToken row, call POST /api/auth/totp/enable with a patched `verify_totp` returning True. Assert `sessions_revoked >= 1` in response and the second token row is revoked. + - test_disable_totp_revokes_other_sessions: Register user, set `user.totp_enabled=True` in DB, insert a second RefreshToken row, call DELETE /api/auth/totp. Assert `sessions_revoked >= 1` in response and the second token row is revoked. + + + + Append three new `@pytest.mark.asyncio` test functions to `test_auth_api.py` after the existing `test_change_password_success` test. + + For each test, import pattern: use `from services import auth as auth_service` (already available in the test module via existing imports — verify before adding) and the `db_session: AsyncSession` fixture. + + To simulate "other sessions": call `await auth_service.create_refresh_token(db_session, user.id)` to insert an additional token row. This is the canonical way to create a second session without running a full login flow (which requires the refresh cookie roundtrip). + + After the privilege-change API call, query `select(RefreshToken).where(RefreshToken.user_id == user.id)` and inspect the rows: + - The row matching the token created by `create_refresh_token` (the "other session") must have `revoked=True`. + - If the `authed_client` fixture set a real refresh_token cookie, the calling session's row must have `revoked=False`. If the fixture does NOT set a cookie (verify by reading conftest), then `skip_token_hash` will be `None` and all tokens including the "other" one will be revoked — the test still asserts `sessions_revoked >= 1`. + + For `test_enable_totp_revokes_other_sessions`: patch `services.auth.verify_totp` to return `True` so the TOTP code check is bypassed (same pattern as existing TOTP tests). Also patch `services.auth.store_backup_codes` to avoid real Argon2 hashing overhead. + + Import `from sqlalchemy import select` and `from models import RefreshToken` for the DB assertion queries — check existing imports in the test file first to avoid duplicates. + + Use `with patch(...)` context managers exactly as in the existing breach-check tests. + + + + cd /Users/nik/Documents/Progamming/document_scanner/backend && python -m pytest tests/test_auth_api.py -k "revokes_other_sessions" -v 2>&1 | tail -20 + + + + - `pytest tests/test_auth_api.py -k "revokes_other_sessions" -v` reports 3 PASSED tests with no failures + - Each test asserts `resp.json()["sessions_revoked"] >= 1` + - Each test queries the DB and asserts the "other session" RefreshToken row has `revoked=True` + - Full test suite `pytest -x -q` still passes with no regressions + + + + + Task 2: Add sessions-revoked toast to SettingsAccountTab and TotpEnrollment + + frontend/src/components/settings/SettingsAccountTab.vue + frontend/src/components/auth/TotpEnrollment.vue + + + + frontend/src/components/settings/SettingsAccountTab.vue — read the full file (254 lines). Observe: `changePassword()` at line 193 calls `api.changePassword(...)` and sets `passwordSuccess`; `disableTotp()` at line 226 calls `api.totpDisable()`. Note there is no existing toast in this component — the toast pattern to follow is in `SettingsView.vue` (the `oauthSuccessProvider` inline HTML block at lines 6-28). + frontend/src/views/SettingsView.vue — read lines 1-30 (the OAuth success toast block). This is the exact inline HTML pattern to replicate in SettingsAccountTab. + frontend/src/components/auth/TotpEnrollment.vue — read lines 145-166 (`confirmEnrollment()` function that calls `api.totpEnable()`). Note the `emit('enrolled')` call at line 165 and the `backupCodes` data returned at line 150. + + + + ### SettingsAccountTab.vue changes + + Add a new reactive ref `sessionRevokedToast = ref(false)` alongside the existing refs in the `