feat(07.1): session revocation on privilege change — CR-01/CR-02/CR-03

- 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 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-06-05 12:47:16 +02:00
co-authored by Claude Sonnet 4.6
parent 8d060a5da4
commit c38c6b1c01
14 changed files with 360 additions and 34 deletions
+8 -7
View File
@@ -2,9 +2,9 @@
gsd_state_version: 1.0 gsd_state_version: 1.0
milestone: v1.0 milestone: v1.0
milestone_name: Redo and Optimize LLM Integration milestone_name: Redo and Optimize LLM Integration
current_phase: 7.1 (planned — ready to execute) current_phase: 7.1 (complete)
status: ready_to_execute status: complete
last_updated: "2026-06-05T12:00:00.000Z" last_updated: "2026-06-05T15:00:00.000Z"
progress: progress:
total_phases: 7 total_phases: 7
completed_phases: 3 completed_phases: 3
@@ -17,7 +17,7 @@ progress:
**Project:** DocuVault **Project:** DocuVault
**Status:** v1.0 Milestone COMPLETE — All 7 phases done **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 **Last Updated:** 2026-06-05
## Phase Status ## 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.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) | | 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 | 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 ## Current Position
Phase: 7 (redo-and-optimize-llm-integration) — COMPLETE 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) **Progress:** [██████████] 100% (v1.0 base complete; Phase 7.1 inserted as urgent follow-up)
## Performance Metrics ## 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-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-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 | | 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 | | Pending decisions | None |
| Resume file | None | | Resume file | None |
@@ -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
```
@@ -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
```
+81 -2
View File
@@ -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). 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 ## Stack
@@ -116,7 +116,9 @@ This project uses the GSD (Get Shit Done) planning workflow. Planning artifacts
/gsd:progress — check status and advance workflow /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 ## Development Setup
@@ -134,6 +136,83 @@ cd frontend && npm run dev
cd backend && pytest -v 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:
# <type>(<scope>): <short summary>
# 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) ## Testing Protocol (Non-Negotiable)
Every feature, function, and bug fix requires tests. No phase or plan may advance until all tests pass. Every feature, function, and bug fix requires tests. No phase or plan may advance until all tests pass.
+20 -3
View File
@@ -489,6 +489,10 @@ async def change_password(
_ip = get_client_ip(request) _ip = get_client_ip(request)
user = await session.get(User, current_user.id) user = await session.get(User, current_user.id)
user.password_hash = auth_service.hash_password(body.new_password) 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) # D-13: password changed event (flush within same transaction before commit)
await write_audit_log( await write_audit_log(
session, session,
@@ -497,10 +501,11 @@ async def change_password(
actor_id=current_user.id, actor_id=current_user.id,
resource_id=None, resource_id=None,
ip_address=_ip, ip_address=_ip,
metadata_={"sessions_revoked": revoked},
) )
await session.commit() await session.commit()
return {"message": "Password updated"} return {"message": "Password updated", "sessions_revoked": revoked}
# ── Request models for new endpoints ───────────────────────────────────────── # ── Request models for new endpoints ─────────────────────────────────────────
@@ -575,6 +580,11 @@ async def enable_totp(
plain_codes = auth_service.generate_backup_codes(10) plain_codes = auth_service.generate_backup_codes(10)
await auth_service.store_backup_codes(session, current_user.id, plain_codes) 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 # D-13: TOTP enrolled event
_ip = get_client_ip(request) _ip = get_client_ip(request)
await write_audit_log( await write_audit_log(
@@ -584,10 +594,11 @@ async def enable_totp(
actor_id=current_user.id, actor_id=current_user.id,
resource_id=None, resource_id=None,
ip_address=_ip, ip_address=_ip,
metadata_={"sessions_revoked": revoked},
) )
await session.commit() await session.commit()
return {"backup_codes": plain_codes} return {"backup_codes": plain_codes, "sessions_revoked": revoked}
# ── DELETE /api/auth/totp ───────────────────────────────────────────────────── # ── DELETE /api/auth/totp ─────────────────────────────────────────────────────
@@ -610,6 +621,11 @@ async def disable_totp(
# Delete all backup codes for this user (including unused ones) # Delete all backup codes for this user (including unused ones)
await session.execute(delete(BackupCode).where(BackupCode.user_id == current_user.id)) 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 # D-13: TOTP revoked event
await write_audit_log( await write_audit_log(
session, session,
@@ -618,10 +634,11 @@ async def disable_totp(
actor_id=current_user.id, actor_id=current_user.id,
resource_id=None, resource_id=None,
ip_address=_ip, ip_address=_ip,
metadata_={"sessions_revoked": revoked},
) )
await session.commit() await session.commit()
return {"message": "TOTP disabled"} return {"message": "TOTP disabled", "sessions_revoked": revoked}
# ── POST /api/auth/password-reset ───────────────────────────────────────────── # ── POST /api/auth/password-reset ─────────────────────────────────────────────
+6 -1
View File
@@ -22,7 +22,7 @@ Usage in route handlers:
""" """
import uuid import uuid
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -36,6 +36,7 @@ security = HTTPBearer()
async def get_current_user( async def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security), credentials: HTTPAuthorizationCredentials = Depends(security),
session: AsyncSession = Depends(get_db), session: AsyncSession = Depends(get_db),
) -> User: ) -> User:
@@ -72,6 +73,10 @@ async def get_current_user(
headers={"WWW-Authenticate": "Bearer"}, 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 return user
+5 -8
View File
@@ -114,6 +114,10 @@ class DocuVaultUser(HttpUser):
wait_time = between(0.5, 2.0) wait_time = between(0.5, 2.0)
access_token: str = "" 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: def on_start(self) -> None:
global _TOKEN_IDX global _TOKEN_IDX
with _TOKEN_LOCK: with _TOKEN_LOCK:
@@ -128,7 +132,7 @@ class DocuVaultUser(HttpUser):
def _auth_headers(self) -> dict: def _auth_headers(self) -> dict:
return {"Authorization": f"Bearer {self.access_token}"} return {"Authorization": f"Bearer {self.access_token}"}
@task(5) @task(6)
def list_documents(self) -> None: def list_documents(self) -> None:
self.client.get("/api/documents/", headers=self._auth_headers(), name="GET /api/documents/") 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", 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 @events.quitting.add_listener
+1 -1
View File
@@ -188,7 +188,7 @@ async def lifespan(app: FastAPI):
# ── Application factory ─────────────────────────────────────────────────────── # ── 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) # Rate limiter state (slowapi)
app.state.limiter = auth_limiter app.state.limiter = auth_limiter
+9 -5
View File
@@ -216,17 +216,21 @@ async def rotate_refresh_token(
async def revoke_all_refresh_tokens( 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: ) -> int:
"""Mark all active refresh tokens for user_id as revoked. """Mark all active refresh tokens for user_id as revoked.
Returns the count of revoked tokens (supports sign-out-all-devices). 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( result = await session.execute(
select(RefreshToken).where( select(RefreshToken).where(*conditions)
RefreshToken.user_id == user_id,
RefreshToken.revoked.is_(False),
)
) )
rows = result.scalars().all() rows = result.scalars().all()
count = 0 count = 0
+4 -3
View File
@@ -4,14 +4,15 @@ from __future__ import annotations
from fastapi import Request from fastapi import Request
from slowapi import Limiter from slowapi import Limiter
from deps.utils import get_client_ip
def _account_key(request: Request) -> str: def _account_key(request: Request) -> str:
user = getattr(request.state, "current_user", None) user = getattr(request.state, "current_user", None)
if user is not None: if user is not None:
return str(user.id) return str(user.id)
if request.client: ip = get_client_ip(request)
return request.client.host return ip if ip is not None else "anonymous"
return "anonymous"
account_limiter = Limiter(key_func=_account_key) account_limiter = Limiter(key_func=_account_key)
+105 -1
View File
@@ -23,7 +23,7 @@ from httpx import ASGITransport, AsyncClient
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from db.models import BackupCode, Quota, User from db.models import BackupCode, Quota, RefreshToken, User
# ── Helpers ────────────────────────────────────────────────────────────────── # ── Helpers ──────────────────────────────────────────────────────────────────
@@ -496,3 +496,107 @@ async def test_patch_preferences_requires_auth(async_client):
json={"pdf_open_mode": "in_app"}, json={"pdf_open_mode": "in_app"},
) )
assert resp.status_code == 401 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"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "document-scanner-frontend", "name": "document-scanner-frontend",
"version": "1.0.0", "version": "0.1.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -1,6 +1,27 @@
<template> <template>
<div class="space-y-4"> <div class="space-y-4">
<!-- Sessions-revoked inline alert -->
<div
v-if="sessionRevokedToast"
class="flex items-center gap-3 bg-white border border-green-200 rounded-xl px-5 py-4"
>
<svg class="w-5 h-5 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="flex-1 text-sm font-semibold text-gray-900">Other sessions have been terminated.</p>
<button
@click="sessionRevokedToast = false"
aria-label="Dismiss notification"
class="text-gray-400 hover:text-gray-600 shrink-0"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Step: setup initial prompt to begin enrollment --> <!-- Step: setup initial prompt to begin enrollment -->
<template v-if="step === 'setup'"> <template v-if="step === 'setup'">
<div class="space-y-3"> <div class="space-y-3">
@@ -125,6 +146,7 @@ const error = ref(null)
const loading = ref(false) const loading = ref(false)
const verified = ref(false) const verified = ref(false)
const secretCopied = ref(false) const secretCopied = ref(false)
const sessionRevokedToast = ref(false)
async function startSetup() { async function startSetup() {
loading.value = true loading.value = true
@@ -149,6 +171,10 @@ async function confirmEnrollment() {
const data = await api.totpEnable(verifyCode.value) const data = await api.totpEnable(verifyCode.value)
backupCodes.value = data.backup_codes backupCodes.value = data.backup_codes
verified.value = true verified.value = true
if (data.sessions_revoked > 0) {
sessionRevokedToast.value = true
setTimeout(() => { sessionRevokedToast.value = false }, 5000)
}
// Brief success flash before transitioning to backup codes screen // Brief success flash before transitioning to backup codes screen
setTimeout(() => { setTimeout(() => {
step.value = 'backup-codes' step.value = 'backup-codes'
@@ -1,6 +1,29 @@
<template> <template>
<div class="space-y-6"> <div class="space-y-6">
<!-- Sessions-revoked toast (fixed top-right, auto-dismisses after 5s) -->
<div
v-if="sessionRevokedToast"
class="fixed top-4 right-4 z-50 flex items-center gap-3 bg-white border border-green-200 rounded-xl shadow-lg px-5 py-4 max-w-sm"
>
<svg class="w-5 h-5 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-gray-900">Other sessions have been terminated.</p>
</div>
<button
@click="sessionRevokedToast = false"
aria-label="Dismiss notification"
class="text-gray-400 hover:text-gray-600 shrink-0"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- 1. Account information --> <!-- 1. Account information -->
<section class="bg-white border border-gray-200 rounded-xl p-6"> <section class="bg-white border border-gray-200 rounded-xl p-6">
<h3 class="font-semibold text-gray-800 mb-4">Account information</h3> <h3 class="font-semibold text-gray-800 mb-4">Account information</h3>
@@ -185,19 +208,24 @@ const newPassword = ref('')
const changingPassword = ref(false) const changingPassword = ref(false)
const passwordError = ref(null) const passwordError = ref(null)
const passwordSuccess = ref(null) const passwordSuccess = ref(null)
const sessionRevokedToast = ref(false)
async function changePassword() { async function changePassword() {
changingPassword.value = true changingPassword.value = true
passwordError.value = null passwordError.value = null
passwordSuccess.value = null passwordSuccess.value = null
try { try {
await api.changePassword({ const data = await api.changePassword({
current_password: currentPassword.value, current_password: currentPassword.value,
new_password: newPassword.value, new_password: newPassword.value,
}) })
passwordSuccess.value = 'Password updated.' passwordSuccess.value = 'Password updated.'
currentPassword.value = '' currentPassword.value = ''
newPassword.value = '' newPassword.value = ''
if (data.sessions_revoked > 0) {
sessionRevokedToast.value = true
setTimeout(() => { sessionRevokedToast.value = false }, 5000)
}
} catch (e) { } catch (e) {
const msg = e.message || '' const msg = e.message || ''
if (msg.toLowerCase().includes('current') || msg.toLowerCase().includes('incorrect')) { if (msg.toLowerCase().includes('current') || msg.toLowerCase().includes('incorrect')) {
@@ -226,11 +254,15 @@ function onTotpEnrolled() {
async function disableTotp() { async function disableTotp() {
totpError.value = null totpError.value = null
try { try {
await api.totpDisable() const data = await api.totpDisable()
if (authStore.user) { if (authStore.user) {
authStore.user.totp_enabled = false authStore.user.totp_enabled = false
} }
confirmDisable2fa.value = false confirmDisable2fa.value = false
if (data.sessions_revoked > 0) {
sessionRevokedToast.value = true
setTimeout(() => { sessionRevokedToast.value = false }, 5000)
}
} catch (e) { } catch (e) {
totpError.value = e.message totpError.value = e.message
} }