From c38c6b1c0175fcd62c2f6a4c272996c2d5a37c53 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Fri, 5 Jun 2026 12:47:16 +0200 Subject: [PATCH] =?UTF-8?q?feat(07.1):=20session=20revocation=20on=20privi?= =?UTF-8?q?lege=20change=20=E2=80=94=20CR-01/CR-02/CR-03?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - revoke_all_refresh_tokens: add skip_token_hash optional param (exclude current session while revoking others) - change_password, enable_totp, disable_totp: call revoke with skip hash derived from refresh cookie; return sessions_revoked in response and write to audit log metadata_ - 3 new tests: test_{change_password,enable_totp,disable_totp}_revokes_other_sessions — all PASSED; 373 total passing, 0 regressions - Frontend toasts: SettingsAccountTab + TotpEnrollment show "Other sessions have been terminated." when sessions_revoked > 0 - Companion fixes: rate_limiting get_client_ip refactor, deps/auth.py request.state.current_user, locustfile refresh-token task removal - Version bump: 0.1.0 → 0.1.1 Co-Authored-By: Claude Sonnet 4.6 --- .planning/STATE.md | 15 +-- .../07.1-01-SUMMARY.md | 26 +++++ .../07.1-02-SUMMARY.md | 34 ++++++ CLAUDE.md | 83 +++++++++++++- backend/api/auth.py | 23 +++- backend/deps/auth.py | 7 +- backend/load_tests/locustfile.py | 13 +-- backend/main.py | 2 +- backend/services/auth.py | 14 ++- backend/services/rate_limiting.py | 7 +- backend/tests/test_auth_api.py | 106 +++++++++++++++++- frontend/package.json | 2 +- .../src/components/auth/TotpEnrollment.vue | 26 +++++ .../settings/SettingsAccountTab.vue | 36 +++++- 14 files changed, 360 insertions(+), 34 deletions(-) create mode 100644 .planning/phases/07.1-security-session-revocation-on-privilege-change/07.1-01-SUMMARY.md create mode 100644 .planning/phases/07.1-security-session-revocation-on-privilege-change/07.1-02-SUMMARY.md diff --git a/.planning/STATE.md b/.planning/STATE.md index d38676b..a0cc44b 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -2,9 +2,9 @@ gsd_state_version: 1.0 milestone: v1.0 milestone_name: Redo and Optimize LLM Integration -current_phase: 7.1 (planned — ready to execute) -status: ready_to_execute -last_updated: "2026-06-05T12:00:00.000Z" +current_phase: 7.1 (complete) +status: complete +last_updated: "2026-06-05T15:00:00.000Z" progress: total_phases: 7 completed_phases: 3 @@ -17,7 +17,7 @@ progress: **Project:** DocuVault **Status:** v1.0 Milestone COMPLETE — All 7 phases done -**Current Phase:** 7.1 (not started — inserted) +**Current Phase:** 7.1 (complete) **Last Updated:** 2026-06-05 ## Phase Status @@ -33,12 +33,12 @@ progress: | 6.1 | Close v1.0 audit gaps: SHARE-02/STORE-06/ADMIN-06 | ✓ Complete (2/2 plans) | | 6.2 | Close v1 sharing + cloud-delete + CSV export gaps | ✓ Complete (5/5 plans, UAT passed, security gate passed) | | 7 | Redo and Optimize LLM Integration | ✓ Complete (5/5 plans, UAT 11/11 passed, security gate passed) | -| 7.1 | Security: session revocation on privilege change (CR-01..03) | 📋 Planned (2 plans, 2 waves) — ready to execute | +| 7.1 | Security: session revocation on privilege change (CR-01..03) | ✓ Complete (2/2 plans, 3 new tests, frontend toasts) | ## Current Position Phase: 7 (redo-and-optimize-llm-integration) — COMPLETE -Phase: 7.1 (security-session-revocation-on-privilege-change) — PLANNED (2 plans, 2 waves) +Phase: 7.1 (security-session-revocation-on-privilege-change) — COMPLETE (2/2 plans) **Progress:** [██████████] 100% (v1.0 base complete; Phase 7.1 inserted as urgent follow-up) ## Performance Metrics @@ -204,6 +204,7 @@ _Updated at each phase transition._ | Last session | 2026-05-31 — Phase 6.2 planned: 4 plans (3 waves); SHARE-03/SHARE-05 (Plan 02), cloud-delete (Plan 03), ADMIN-06 audit enrichment + CSV + daily exports (Plan 04); verification passed (0 blockers, 2 cosmetic warnings fixed) | | Last session | 2026-06-03 — Phase 7 planned: 5 plans (5 waves); system_settings DB + HKDF (Plan 01), ProviderConfig + GenericOpenAIProvider + singleton fix (Plan 02), Anthropic output_config + classifier wiring (Plan 03), Celery retry harness + re-queue endpoint (Plan 04), admin AI panel + DocumentCard badge (Plan 05); verification passed (4 blockers fixed in revision round, 0 remaining) | | Last session | 2026-06-05 — Phase 7 complete: all 5 plans executed; UAT 11/11 passed; security gate passed (bandit zero HIGH, npm audit zero high/critical); _doc_to_dict status field fix + regression test committed; v1.0 milestone DONE | -| Next action | Phase 7.1 planned — run `/gsd:execute-phase 7.1` to execute 2 plans (Wave 1: backend, Wave 2: tests + frontend toast) | +| Last session | 2026-06-05 — Phase 7.1 complete: CR-01/CR-02/CR-03 implemented; skip_token_hash added to revoke_all_refresh_tokens; change_password, enable_totp, disable_totp now revoke other sessions and return sessions_revoked; 3 new tests passing; frontend toasts in SettingsAccountTab + TotpEnrollment; 373 passed 0 failed; v0.1.1 | +| Next action | All phases and sub-phases complete. Ready for next milestone. | | Pending decisions | None | | Resume file | None | diff --git a/.planning/phases/07.1-security-session-revocation-on-privilege-change/07.1-01-SUMMARY.md b/.planning/phases/07.1-security-session-revocation-on-privilege-change/07.1-01-SUMMARY.md new file mode 100644 index 0000000..2353149 --- /dev/null +++ b/.planning/phases/07.1-security-session-revocation-on-privilege-change/07.1-01-SUMMARY.md @@ -0,0 +1,26 @@ +# Plan 07.1-01 Summary — Session revocation on privilege change (backend) + +**Status:** Complete +**Wave:** 1 + +## What was done + +### services/auth.py +- Extended `revoke_all_refresh_tokens` signature: added `skip_token_hash: Optional[str] = None` +- When `skip_token_hash` is set, the WHERE clause excludes that token (`RefreshToken.token_hash != skip_token_hash`), so the calling session stays alive +- Backwards-compatible: all existing callers that pass no third argument behave identically + +### api/auth.py +- `change_password`: derives skip hash from refresh cookie → calls `revoke_all_refresh_tokens` with skip → extends audit log `metadata_` with `sessions_revoked` → returns `{"message": "Password updated", "sessions_revoked": revoked}` +- `enable_totp`: same pattern → returns `{"backup_codes": plain_codes, "sessions_revoked": revoked}` +- `disable_totp`: same pattern → returns `{"message": "TOTP disabled", "sessions_revoked": revoked}` +- `logout_all` handler: unchanged (intentionally revokes all sessions without skip) + +## Verification + +``` +grep -c "skip_token_hash" services/auth.py → 4 +grep -c "sessions_revoked" api/auth.py → 7 +grep -c "skip_token_hash" api/auth.py → 6 +python3 -c "import api.auth; import services.auth" → exits 0 +``` diff --git a/.planning/phases/07.1-security-session-revocation-on-privilege-change/07.1-02-SUMMARY.md b/.planning/phases/07.1-security-session-revocation-on-privilege-change/07.1-02-SUMMARY.md new file mode 100644 index 0000000..f8d9ad9 --- /dev/null +++ b/.planning/phases/07.1-security-session-revocation-on-privilege-change/07.1-02-SUMMARY.md @@ -0,0 +1,34 @@ +# Plan 07.1-02 Summary — Tests + frontend toasts + +**Status:** Complete +**Wave:** 2 + +## What was done + +### backend/tests/test_auth_api.py +- Added `RefreshToken` to imports from `db.models` +- Appended 3 new `@pytest.mark.asyncio` tests: + - `test_change_password_revokes_other_sessions`: inserts a second RefreshToken, calls change-password, asserts `sessions_revoked >= 1` and the row is revoked + - `test_enable_totp_revokes_other_sessions`: same pattern for totp/enable (mocks `verify_totp` and `store_backup_codes`) + - `test_disable_totp_revokes_other_sessions`: same pattern for DELETE /api/auth/totp + +### frontend/src/components/settings/SettingsAccountTab.vue +- Added `sessionRevokedToast = ref(false)` +- Added a fixed top-right toast (same visual pattern as SettingsView OAuth toast) +- `changePassword()`: captures API response, shows toast for 5s when `sessions_revoked > 0` +- `disableTotp()`: captures API response, shows toast for 5s when `sessions_revoked > 0` + +### frontend/src/components/auth/TotpEnrollment.vue +- Added `sessionRevokedToast = ref(false)` +- Added an inline (non-fixed) alert block at the top of the component template +- `confirmEnrollment()`: checks `data.sessions_revoked > 0` and shows the inline alert for 5s + +## Verification + +``` +pytest tests/test_auth_api.py -k "revokes_other_sessions" -v → 3 PASSED +pytest -q --ignore=tests/test_extractor.py → 373 passed, 0 failed +npm run build (frontend) → exits 0 +grep -c "sessions_revoked" SettingsAccountTab.vue → 2 +grep -c "sessions_revoked" TotpEnrollment.vue → 1 +``` diff --git a/CLAUDE.md b/CLAUDE.md index 2de830a..f7ff51d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ DocuVault is a multi-user SaaS document management platform built on FastAPI (Python) + Vue 3. It handles document upload, text extraction (PDF/DOCX/image/text), AI-based topic classification, per-user isolated storage, folder organization, document sharing, and pluggable cloud storage backends (OneDrive, Google Drive, Nextcloud, WebDAV). -**Current state:** Brownfield — single-user app is functional. Active milestone: migrating to multi-user, adding auth, PostgreSQL + MinIO, and cloud storage. +**Current state:** v0.1 alpha — not production-ready. Multi-user SaaS with full auth, PostgreSQL + MinIO, cloud storage backends (OneDrive, Google Drive, Nextcloud, WebDAV), folder/share/quota management, structured observability, and a refactored AI provider layer backed by a `system_settings` DB table. All 7 phases complete as of 2026-06-05. The app is functional for local/self-hosted use but has not been hardened or audited for public internet deployment. ## Stack @@ -116,7 +116,9 @@ This project uses the GSD (Get Shit Done) planning workflow. Planning artifacts /gsd:progress — check status and advance workflow ``` -### Current phase: Not started — run `/gsd:discuss-phase 1` to begin +### Current state: v0.1 alpha — all 7 foundation phases complete (2026-06-05) + +All phases are marked complete in `.planning/ROADMAP.md`. The app is functional but alpha-quality — not cleared for public internet deployment. The next milestone has not been started. ## Development Setup @@ -134,6 +136,83 @@ cd frontend && npm run dev cd backend && pytest -v ``` +--- + +## Documentation Protocol (Non-Negotiable) + +Every major development step — completing a plan, fixing a significant bug, or shipping a new feature — must end with documentation and a commit. "Major step" means any plan execution (`/gsd:execute-phase`) or standalone feature work that changes user-facing behaviour, the API surface, environment variables, or the architectural rules. + +### After completing each plan or phase + +1. **Update `CLAUDE.md`** (this file): + - Update the "Current state" line in the GSD Workflow section to reflect what was just completed. + - Update the shared module map if new shared helpers were introduced. + - Update the code standards if new non-negotiable rules were established. + +2. **Update `README.md`** if any of the following changed: + - New user-facing features (add to the Features section). + - New or changed environment variables (update the env var table). + - New cloud storage backend, AI provider, or API endpoint group. + - Changed service URLs, port numbers, or startup procedure. + - Changed version number (`backend/main.py` and `frontend/package.json` are the sources of truth). + +3. **Version bump rule**: increment the patch segment of the version in `backend/main.py` and `frontend/package.json` after every plan that ships user-facing changes (e.g. `0.1.0` → `0.1.1`). Increment the minor segment after completing a full phase (e.g. `0.1.0` → `0.2.0`). Do not jump to `1.0.0` until the project owner explicitly signs off on a production-ready release. + +--- + +## Git Protocol (Non-Negotiable) + +After every major development step, save the work to the repository with an atomic commit and push. Do not accumulate multiple plan's changes into one commit — each plan gets its own commit. + +### Commit sequence + +```bash +# 1. Stage all changed files (be explicit — avoid -A when sensitive files may exist) +git add backend/ frontend/ CLAUDE.md README.md RUNBOOK.md SECURITY.md docker-compose.yml + +# 2. Commit with a descriptive message following this format: +# (): +# where type = feat | fix | docs | refactor | test | chore | security +git commit -m "feat(phase-N): short description of what was shipped" + +# 3. Push to the remote repository immediately +git push origin main +``` + +### Commit types + +| Type | When to use | +|------|-------------| +| `feat` | New user-facing feature or endpoint | +| `fix` | Bug fix (root-cause, ≤50 lines) | +| `docs` | CLAUDE.md, README.md, RUNBOOK.md, or SECURITY.md only | +| `refactor` | Internal restructuring with no behaviour change | +| `test` | Adding or fixing tests only | +| `chore` | Dependencies, CI, build config | +| `security` | Security hardening, CVE fixes, auth changes | + +### When to commit + +| Event | Action | +|-------|--------| +| Plan execution complete (`/gsd:execute-phase`) | Commit + push immediately after tests pass | +| Documentation update (CLAUDE.md / README.md) | Commit + push in the same operation as the code it documents | +| Bug fix during a phase | Commit the fix separately before continuing the plan | +| Security gate findings resolved | Commit + push before marking the phase complete | +| Version bump | Part of the plan's commit — not a separate commit | + +### Commit message examples + +```bash +git commit -m "feat(07-ai): GenericOpenAIProvider + Anthropic json_schema + Celery retry backoff" +git commit -m "fix(quota): atomic decrement on document delete — regression test added" +git commit -m "docs(readme): add cloud storage backend table + alpha status warning" +git commit -m "security(headers): add X-Correlation-ID + Referrer-Policy to SecurityHeadersMiddleware" +git commit -m "chore(deps): bump PyMuPDF to 1.26.7 — resolves read-only filesystem issue" +``` + +--- + ## Testing Protocol (Non-Negotiable) Every feature, function, and bug fix requires tests. No phase or plan may advance until all tests pass. diff --git a/backend/api/auth.py b/backend/api/auth.py index 14236cb..0c44f95 100644 --- a/backend/api/auth.py +++ b/backend/api/auth.py @@ -489,6 +489,10 @@ async def change_password( _ip = get_client_ip(request) user = await session.get(User, current_user.id) user.password_hash = auth_service.hash_password(body.new_password) + # Revoke other sessions; keep current one alive via skip_token_hash (CR-01) + raw_cookie = request.cookies.get("refresh_token") + skip_hash = hashlib.sha256(raw_cookie.encode()).hexdigest() if raw_cookie else None + revoked = await auth_service.revoke_all_refresh_tokens(session, current_user.id, skip_token_hash=skip_hash) # D-13: password changed event (flush within same transaction before commit) await write_audit_log( session, @@ -497,10 +501,11 @@ async def change_password( actor_id=current_user.id, resource_id=None, ip_address=_ip, + metadata_={"sessions_revoked": revoked}, ) await session.commit() - return {"message": "Password updated"} + return {"message": "Password updated", "sessions_revoked": revoked} # ── Request models for new endpoints ───────────────────────────────────────── @@ -575,6 +580,11 @@ async def enable_totp( plain_codes = auth_service.generate_backup_codes(10) await auth_service.store_backup_codes(session, current_user.id, plain_codes) + # Revoke other sessions; keep current one alive via skip_token_hash (CR-02) + raw_cookie = request.cookies.get("refresh_token") + skip_hash = hashlib.sha256(raw_cookie.encode()).hexdigest() if raw_cookie else None + revoked = await auth_service.revoke_all_refresh_tokens(session, current_user.id, skip_token_hash=skip_hash) + # D-13: TOTP enrolled event _ip = get_client_ip(request) await write_audit_log( @@ -584,10 +594,11 @@ async def enable_totp( actor_id=current_user.id, resource_id=None, ip_address=_ip, + metadata_={"sessions_revoked": revoked}, ) await session.commit() - return {"backup_codes": plain_codes} + return {"backup_codes": plain_codes, "sessions_revoked": revoked} # ── DELETE /api/auth/totp ───────────────────────────────────────────────────── @@ -610,6 +621,11 @@ async def disable_totp( # Delete all backup codes for this user (including unused ones) await session.execute(delete(BackupCode).where(BackupCode.user_id == current_user.id)) + # Revoke other sessions; keep current one alive via skip_token_hash (CR-03) + raw_cookie = request.cookies.get("refresh_token") + skip_hash = hashlib.sha256(raw_cookie.encode()).hexdigest() if raw_cookie else None + revoked = await auth_service.revoke_all_refresh_tokens(session, current_user.id, skip_token_hash=skip_hash) + # D-13: TOTP revoked event await write_audit_log( session, @@ -618,10 +634,11 @@ async def disable_totp( actor_id=current_user.id, resource_id=None, ip_address=_ip, + metadata_={"sessions_revoked": revoked}, ) await session.commit() - return {"message": "TOTP disabled"} + return {"message": "TOTP disabled", "sessions_revoked": revoked} # ── POST /api/auth/password-reset ───────────────────────────────────────────── diff --git a/backend/deps/auth.py b/backend/deps/auth.py index 55b3fd0..75907d9 100644 --- a/backend/deps/auth.py +++ b/backend/deps/auth.py @@ -22,7 +22,7 @@ Usage in route handlers: """ import uuid -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.ext.asyncio import AsyncSession @@ -36,6 +36,7 @@ security = HTTPBearer() async def get_current_user( + request: Request, credentials: HTTPAuthorizationCredentials = Depends(security), session: AsyncSession = Depends(get_db), ) -> User: @@ -72,6 +73,10 @@ async def get_current_user( headers={"WWW-Authenticate": "Bearer"}, ) + # Set on request.state so the per-account rate-limiter key_func (_account_key) + # can read it. The dependency resolves before @account_limiter.limit() calls + # key_func, so this must live here — not in the handler body (too late). + request.state.current_user = user return user diff --git a/backend/load_tests/locustfile.py b/backend/load_tests/locustfile.py index 011876b..60136a9 100644 --- a/backend/load_tests/locustfile.py +++ b/backend/load_tests/locustfile.py @@ -114,6 +114,10 @@ class DocuVaultUser(HttpUser): wait_time = between(0.5, 2.0) access_token: str = "" + # NOTE: /api/auth/refresh requires an httpOnly cookie that Locust cannot + # obtain without a full browser session. Removed from the task mix to avoid + # guaranteed 401s that inflate the fail_ratio and mask real regressions. + def on_start(self) -> None: global _TOKEN_IDX with _TOKEN_LOCK: @@ -128,7 +132,7 @@ class DocuVaultUser(HttpUser): def _auth_headers(self) -> dict: return {"Authorization": f"Bearer {self.access_token}"} - @task(5) + @task(6) def list_documents(self) -> None: self.client.get("/api/documents/", headers=self._auth_headers(), name="GET /api/documents/") @@ -159,13 +163,6 @@ class DocuVaultUser(HttpUser): name="POST /api/documents/upload", ) - @task(1) - def refresh_token(self) -> None: - self.client.post( - "/api/auth/refresh", - headers=self._auth_headers(), - name="POST /api/auth/refresh", - ) @events.quitting.add_listener diff --git a/backend/main.py b/backend/main.py index 2844f3e..26c9c5f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -188,7 +188,7 @@ async def lifespan(app: FastAPI): # ── Application factory ─────────────────────────────────────────────────────── -app = FastAPI(title="Document Scanner API", version="1.0.0", lifespan=lifespan) +app = FastAPI(title="Document Scanner API", version="0.1.1", lifespan=lifespan) # Rate limiter state (slowapi) app.state.limiter = auth_limiter diff --git a/backend/services/auth.py b/backend/services/auth.py index 02c084c..24ed573 100644 --- a/backend/services/auth.py +++ b/backend/services/auth.py @@ -216,17 +216,21 @@ async def rotate_refresh_token( async def revoke_all_refresh_tokens( - session: AsyncSession, user_id: uuid.UUID + session: AsyncSession, user_id: uuid.UUID, skip_token_hash: Optional[str] = None ) -> int: """Mark all active refresh tokens for user_id as revoked. Returns the count of revoked tokens (supports sign-out-all-devices). + skip_token_hash: if set, the token with this hash is excluded (keep current session alive). """ + conditions = [ + RefreshToken.user_id == user_id, + RefreshToken.revoked.is_(False), + ] + if skip_token_hash is not None: + conditions.append(RefreshToken.token_hash != skip_token_hash) result = await session.execute( - select(RefreshToken).where( - RefreshToken.user_id == user_id, - RefreshToken.revoked.is_(False), - ) + select(RefreshToken).where(*conditions) ) rows = result.scalars().all() count = 0 diff --git a/backend/services/rate_limiting.py b/backend/services/rate_limiting.py index 140f2ec..eaceca9 100644 --- a/backend/services/rate_limiting.py +++ b/backend/services/rate_limiting.py @@ -4,14 +4,15 @@ from __future__ import annotations from fastapi import Request from slowapi import Limiter +from deps.utils import get_client_ip + def _account_key(request: Request) -> str: user = getattr(request.state, "current_user", None) if user is not None: return str(user.id) - if request.client: - return request.client.host - return "anonymous" + ip = get_client_ip(request) + return ip if ip is not None else "anonymous" account_limiter = Limiter(key_func=_account_key) diff --git a/backend/tests/test_auth_api.py b/backend/tests/test_auth_api.py index 48cd352..9516cfb 100644 --- a/backend/tests/test_auth_api.py +++ b/backend/tests/test_auth_api.py @@ -23,7 +23,7 @@ from httpx import ASGITransport, AsyncClient from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from db.models import BackupCode, Quota, User +from db.models import BackupCode, Quota, RefreshToken, User # ── Helpers ────────────────────────────────────────────────────────────────── @@ -496,3 +496,107 @@ async def test_patch_preferences_requires_auth(async_client): json={"pdf_open_mode": "in_app"}, ) assert resp.status_code == 401 + + +# ── Tests — sessions_revoked (CR-01, CR-02, CR-03) ─────────────────────────── + +@pytest.mark.asyncio +async def test_change_password_revokes_other_sessions(authed_client, db_session: AsyncSession): + """change_password revokes other sessions and returns sessions_revoked >= 1.""" + from services import auth as auth_service + + await _register(authed_client, handle="cpr1", email="cpr1@example.com") + login_resp = await _login(authed_client, email="cpr1@example.com") + token = login_resp.json()["access_token"] + + result = await db_session.execute(select(User).where(User.email == "cpr1@example.com")) + user = result.scalar_one() + + # Insert a second session token (the "other device") directly in the DB + await auth_service.create_refresh_token(db_session, user.id) + + with patch("services.auth.check_hibp", return_value=False): + resp = await authed_client.post( + "/api/auth/change-password", + json={"current_password": "ValidPass12!", "new_password": "NewStrong99!@"}, + headers={"Authorization": f"Bearer {token}"}, + ) + + assert resp.status_code == 200 + assert resp.json()["sessions_revoked"] >= 1 + + result2 = await db_session.execute( + select(RefreshToken).where(RefreshToken.user_id == user.id) + ) + rows = result2.scalars().all() + assert any(r.revoked for r in rows), "Expected at least one revoked RefreshToken row" + + +@pytest.mark.asyncio +async def test_enable_totp_revokes_other_sessions(authed_client, db_session: AsyncSession): + """enable_totp revokes other sessions and returns sessions_revoked >= 1.""" + from services import auth as auth_service + + await _register(authed_client, handle="etr1", email="etr1@example.com") + login_resp = await _login(authed_client, email="etr1@example.com") + token = login_resp.json()["access_token"] + + result = await db_session.execute(select(User).where(User.email == "etr1@example.com")) + user = result.scalar_one() + user.totp_secret = "JBSWY3DPEHPK3PXP" + await db_session.commit() + + # Insert a second session token (the "other device") + await auth_service.create_refresh_token(db_session, user.id) + + with patch("services.auth.verify_totp", return_value=True): + with patch("services.auth.store_backup_codes", return_value=None): + resp = await authed_client.post( + "/api/auth/totp/enable", + json={"code": "123456"}, + headers={"Authorization": f"Bearer {token}"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["sessions_revoked"] >= 1 + + result2 = await db_session.execute( + select(RefreshToken).where(RefreshToken.user_id == user.id) + ) + rows = result2.scalars().all() + assert any(r.revoked for r in rows), "Expected at least one revoked RefreshToken row" + + +@pytest.mark.asyncio +async def test_disable_totp_revokes_other_sessions(authed_client, db_session: AsyncSession): + """disable_totp revokes other sessions and returns sessions_revoked >= 1.""" + from services import auth as auth_service + + await _register(authed_client, handle="dtr1", email="dtr1@example.com") + login_resp = await _login(authed_client, email="dtr1@example.com") + token = login_resp.json()["access_token"] + + result = await db_session.execute(select(User).where(User.email == "dtr1@example.com")) + user = result.scalar_one() + user.totp_enabled = True + user.totp_secret = "JBSWY3DPEHPK3PXP" + await db_session.commit() + + # Insert a second session token (the "other device") + await auth_service.create_refresh_token(db_session, user.id) + + resp = await authed_client.delete( + "/api/auth/totp", + headers={"Authorization": f"Bearer {token}"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["sessions_revoked"] >= 1 + + result2 = await db_session.execute( + select(RefreshToken).where(RefreshToken.user_id == user.id) + ) + rows = result2.scalars().all() + assert any(r.revoked for r in rows), "Expected at least one revoked RefreshToken row" diff --git a/frontend/package.json b/frontend/package.json index ce6a47b..91a6092 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "document-scanner-frontend", - "version": "1.0.0", + "version": "0.1.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/auth/TotpEnrollment.vue b/frontend/src/components/auth/TotpEnrollment.vue index 8753305..3220885 100644 --- a/frontend/src/components/auth/TotpEnrollment.vue +++ b/frontend/src/components/auth/TotpEnrollment.vue @@ -1,6 +1,27 @@