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:
co-authored by
Claude Sonnet 4.6
parent
8d060a5da4
commit
c38c6b1c01
+8
-7
@@ -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 |
|
||||
|
||||
+26
@@ -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
|
||||
```
|
||||
+34
@@ -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
|
||||
```
|
||||
@@ -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:
|
||||
# <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)
|
||||
|
||||
Every feature, function, and bug fix requires tests. No phase or plan may advance until all tests pass.
|
||||
|
||||
+20
-3
@@ -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 ─────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "document-scanner-frontend",
|
||||
"version": "1.0.0",
|
||||
"version": "0.1.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
<template>
|
||||
<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 -->
|
||||
<template v-if="step === 'setup'">
|
||||
<div class="space-y-3">
|
||||
@@ -125,6 +146,7 @@ const error = ref(null)
|
||||
const loading = ref(false)
|
||||
const verified = ref(false)
|
||||
const secretCopied = ref(false)
|
||||
const sessionRevokedToast = ref(false)
|
||||
|
||||
async function startSetup() {
|
||||
loading.value = true
|
||||
@@ -149,6 +171,10 @@ async function confirmEnrollment() {
|
||||
const data = await api.totpEnable(verifyCode.value)
|
||||
backupCodes.value = data.backup_codes
|
||||
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
|
||||
setTimeout(() => {
|
||||
step.value = 'backup-codes'
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
<template>
|
||||
<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 -->
|
||||
<section class="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<h3 class="font-semibold text-gray-800 mb-4">Account information</h3>
|
||||
@@ -185,19 +208,24 @@ const newPassword = ref('')
|
||||
const changingPassword = ref(false)
|
||||
const passwordError = ref(null)
|
||||
const passwordSuccess = ref(null)
|
||||
const sessionRevokedToast = ref(false)
|
||||
|
||||
async function changePassword() {
|
||||
changingPassword.value = true
|
||||
passwordError.value = null
|
||||
passwordSuccess.value = null
|
||||
try {
|
||||
await api.changePassword({
|
||||
const data = await api.changePassword({
|
||||
current_password: currentPassword.value,
|
||||
new_password: newPassword.value,
|
||||
})
|
||||
passwordSuccess.value = 'Password updated.'
|
||||
currentPassword.value = ''
|
||||
newPassword.value = ''
|
||||
if (data.sessions_revoked > 0) {
|
||||
sessionRevokedToast.value = true
|
||||
setTimeout(() => { sessionRevokedToast.value = false }, 5000)
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e.message || ''
|
||||
if (msg.toLowerCase().includes('current') || msg.toLowerCase().includes('incorrect')) {
|
||||
@@ -226,11 +254,15 @@ function onTotpEnrolled() {
|
||||
async function disableTotp() {
|
||||
totpError.value = null
|
||||
try {
|
||||
await api.totpDisable()
|
||||
const data = await api.totpDisable()
|
||||
if (authStore.user) {
|
||||
authStore.user.totp_enabled = false
|
||||
}
|
||||
confirmDisable2fa.value = false
|
||||
if (data.sessions_revoked > 0) {
|
||||
sessionRevokedToast.value = true
|
||||
setTimeout(() => { sessionRevokedToast.value = false }, 5000)
|
||||
}
|
||||
} catch (e) {
|
||||
totpError.value = e.message
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user