From 37d55e9b209e9a1e5e7de50ef37837a7c0e968fd Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 8 Aug 2026 09:55:34 +0000 Subject: [PATCH] feat: sign out all operator sessions (#287) --- README.md | 14 ++-- frontend/index.html | 2 + frontend/session.js | 17 ++++- src/dashboard_auth.py | 4 ++ src/main.py | 33 +++++++++ src/session_store.py | 7 ++ tests/test_dashboard_auth.py | 86 ++++++++++++++++++++++++ tests/test_dashboard_session_frontend.py | 25 ++++++- tests/test_session_store.py | 16 +++++ 9 files changed, 195 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 500b78d..c39225d 100644 --- a/README.md +++ b/README.md @@ -84,12 +84,14 @@ that setting, a reverse proxy is safely treated as one shared source. Each signed cookie includes an opaque session identifier whose hash and expiry are kept in the SQLite session registry. Keep that registry on persistent, writable -storage shared by all dashboard workers. Sign-out revokes only the current session -before clearing browser state, so a copied cookie cannot be replayed afterward; -other signed-in devices remain active. Deploying this version invalidates older -cookies that do not contain a registered identifier, so operators must sign in once -again. Registry read or write failures return a sanitized HTTP 503 before Gitea is -contacted. +storage shared by all dashboard workers. **Sign out & clear this device** revokes +only the current session before clearing browser state, so a copied cookie cannot +be replayed afterward; other signed-in devices remain active. **Sign out all +devices** is a separately confirmed lost-device safety action that atomically +revokes every existing operator session before clearing the current browser and +returning to sign-in. Deploying this version invalidates older cookies that do not +contain a registered identifier, so operators must sign in once again. Registry +read or write failures return a sanitized HTTP 503 before Gitea is contacted. Terminate TLS at the trusted reverse proxy: session cookies are deliberately `Secure`, `HttpOnly`, `SameSite=Strict`, and scoped to the deployment subpath. diff --git a/frontend/index.html b/frontend/index.html index 3128909..8b08650 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -14,6 +14,7 @@ html, body { height: 100%; margin: 0; background: var(--bg); color: var(--text); header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex; gap:16px; align-items:center; justify-content:space-between; background: linear-gradient(180deg, rgba(11,21,38,.95), rgba(11,21,38,.55), transparent); backdrop-filter: blur(4px); border-bottom: 1px solid #1b2d45; } .toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; } button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; } +#sign-out-all { min-height:44px; } button:hover { filter: brightness(1.15); } .panel { border: 1px solid #1b2d45; border-radius: 14px; padding: 12px; background: rgba(11,21,38,.92); } .panel > summary { cursor: pointer; list-style-position: inside; } @@ -298,6 +299,7 @@ textarea { resize: vertical; min-height: 120px; }
Live
+ diff --git a/frontend/session.js b/frontend/session.js index 5e3dfac..9b208cf 100644 --- a/frontend/session.js +++ b/frontend/session.js @@ -14,12 +14,15 @@ caches: root.caches, serviceWorker: root.navigator?.serviceWorker, location: root.location, + confirmAction: message => root.confirm(message), onExpired: () => root.dispatchEvent(new CustomEvent('stackchain:session-expired')), }); root.fetch = boundary.fetch; const attach = () => { const button = root.document.getElementById('sign-out'); if (button) button.addEventListener('click', () => boundary.signOut()); + const allDevicesButton = root.document.getElementById('sign-out-all'); + if (allDevicesButton) allDevicesButton.addEventListener('click', () => boundary.signOutAllDevices()); boundary.resumeQueuedWork(); }; if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', attach); @@ -27,7 +30,7 @@ root.stackchainSession = boundary; } })(typeof window !== 'undefined' ? window : this, function createSessionBoundary({ - cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, location, + cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, location, confirmAction, onExpired = () => {}, }) { const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); @@ -90,6 +93,16 @@ } } + async function signOutAllDevices() { + const confirmed = confirmAction?.('Sign out every device? You will need to sign in again everywhere.'); + if (!confirmed) return false; + const response = await sessionFetch(base + 'api/v1/sessions', { method: 'DELETE' }); + if (!response.ok) throw new Error('Could not sign out all devices'); + await clearPrivateDeviceData(); + location.assign(base + 'login'); + return true; + } + async function resumeQueuedWork() { try { const registration = await serviceWorker?.ready; @@ -97,5 +110,5 @@ } catch (_error) { /* Background Sync is optional; foreground delivery remains available. */ } } - return { fetch: sessionFetch, signOut, clearPrivateDeviceData, resumeQueuedWork }; + return { fetch: sessionFetch, signOut, signOutAllDevices, clearPrivateDeviceData, resumeQueuedWork }; }); diff --git a/src/dashboard_auth.py b/src/dashboard_auth.py index 337154b..48aa698 100644 --- a/src/dashboard_auth.py +++ b/src/dashboard_auth.py @@ -140,6 +140,10 @@ def revoke_session(session: Session) -> None: _session_store().revoke(session.session_id) +async def revoke_all_sessions() -> None: + await asyncio.to_thread(_session_store().revoke_all) + + async def request_session(request: Request) -> Session | None: return await asyncio.to_thread(verify_session, request.cookies.get(SESSION_COOKIE)) diff --git a/src/main.py b/src/main.py index a5bc35c..d390890 100644 --- a/src/main.py +++ b/src/main.py @@ -663,6 +663,39 @@ async def sign_out(request: Request, response: Response): return {"authenticated": False, "clear_private_device_data": True} +@app.delete("/api/v1/sessions") +async def sign_out_all_devices(request: Request, response: Response): + try: + await dashboard_auth.revoke_all_sessions() + except dashboard_auth.SessionStoreError: + return JSONResponse( + {"detail": "Session registry is temporarily unavailable"}, + status_code=503, + headers={"Cache-Control": "no-store"}, + ) + path = dashboard_auth.cookie_path(request) + response.delete_cookie( + dashboard_auth.SESSION_COOKIE, + path=path, + secure=True, + httponly=True, + samesite="strict", + ) + response.delete_cookie( + dashboard_auth.CSRF_COOKIE, + path=path, + secure=True, + httponly=False, + samesite="strict", + ) + response.headers["Cache-Control"] = "no-store" + return { + "authenticated": False, + "all_sessions_revoked": True, + "clear_private_device_data": True, + } + + @app.get("/readyz") async def readiness(): """Return readiness after verifying the configured Gitea connection.""" diff --git a/src/session_store.py b/src/session_store.py index de6062e..d5e1a50 100644 --- a/src/session_store.py +++ b/src/session_store.py @@ -86,3 +86,10 @@ class SessionStore: ) except (OSError, sqlite3.Error) as exc: raise SessionStoreError("Session registry is temporarily unavailable") from exc + + def revoke_all(self) -> None: + try: + with self._connect() as connection: + connection.execute("DELETE FROM active_sessions") + except (OSError, sqlite3.Error) as exc: + raise SessionStoreError("Session registry is temporarily unavailable") from exc diff --git a/tests/test_dashboard_auth.py b/tests/test_dashboard_auth.py index e3207b5..15a0b90 100644 --- a/tests/test_dashboard_auth.py +++ b/tests/test_dashboard_auth.py @@ -276,6 +276,92 @@ async def test_logout_clears_session_and_blocks_private_routes(access_control): assert all("Max-Age=0" in value for value in response.headers.get_list("set-cookie")) +@pytest.mark.anyio +async def test_sign_out_all_devices_revokes_every_existing_session(access_control): + transport = httpx.ASGITransport(app=main.app) + async with ( + httpx.AsyncClient(transport=transport, base_url="https://test") as phone, + httpx.AsyncClient(transport=transport, base_url="https://test") as laptop, + ): + await phone.post( + "/api/v1/session", json={"access_token": "correct horse battery staple"} + ) + await laptop.post( + "/api/v1/session", json={"access_token": "correct horse battery staple"} + ) + + response = await phone.delete( + "/api/v1/sessions", + headers={ + "Origin": "https://test", + "X-CSRF-Token": phone.cookies["stackchain_csrf"], + }, + ) + phone_private = await phone.get("/api/v1/background-identity") + laptop_private = await laptop.get("/api/v1/background-identity") + + assert response.status_code == 200 + assert response.json() == { + "authenticated": False, + "all_sessions_revoked": True, + "clear_private_device_data": True, + } + assert phone_private.status_code == 401 + assert laptop_private.status_code == 401 + assert all("Max-Age=0" in value for value in response.headers.get_list("set-cookie")) + + +@pytest.mark.anyio +async def test_sign_out_all_devices_rejects_cross_site_requests_without_revoking(access_control): + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="https://test") as client: + await client.post( + "/api/v1/session", json={"access_token": "correct horse battery staple"} + ) + response = await client.delete( + "/api/v1/sessions", + headers={ + "Origin": "https://evil.example", + "X-CSRF-Token": client.cookies["stackchain_csrf"], + }, + ) + session = await client.get("/api/v1/session") + + assert response.status_code == 403 + assert response.headers.get_list("set-cookie") == [] + assert session.status_code == 200 + assert session.json()["authenticated"] is True + + +@pytest.mark.anyio +async def test_sign_out_all_devices_registry_failure_sets_no_cookies(access_control, monkeypatch): + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="https://test") as client: + await client.post( + "/api/v1/session", json={"access_token": "correct horse battery staple"} + ) + csrf = client.cookies["stackchain_csrf"] + + class BrokenStore: + def is_active(self, session_id, expires_at): + return True + + def revoke_all(self): + raise SessionStoreError("database path and secret details") + + monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore()) + response = await client.delete( + "/api/v1/sessions", + headers={"Origin": "https://test", "X-CSRF-Token": csrf}, + ) + + assert response.status_code == 503 + assert response.json() == {"detail": "Session registry is temporarily unavailable"} + assert response.headers["cache-control"] == "no-store" + assert response.headers.get_list("set-cookie") == [] + assert "database path" not in response.text + + @pytest.mark.anyio async def test_logout_revokes_a_captured_cookie_before_gitea(access_control, monkeypatch): calls = 0 diff --git a/tests/test_dashboard_session_frontend.py b/tests/test_dashboard_session_frontend.py index 0d00a3f..8321a3a 100644 --- a/tests/test_dashboard_session_frontend.py +++ b/tests/test_dashboard_session_frontend.py @@ -14,7 +14,7 @@ SESSION_JS = ROOT / "frontend" / "session.js" def run_session_scenario(scenario: str) -> dict: harness = f""" const createSessionBoundary = require({json.dumps(str(SESSION_JS))}); -const state = {{ requests: [], removed: [], deletedDatabases: [], deletedCaches: [], assigned: '', workerMessages: [] }}; +const state = {{ requests: [], removed: [], deletedDatabases: [], deletedCaches: [], assigned: '', workerMessages: [], confirmations: [] }}; const storage = {{ values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]), get length() {{ return this.values.size; }}, @@ -35,6 +35,7 @@ const boundary = createSessionBoundary({{ caches: {{ keys: async () => ['stackchain-dashboard-shell-v15', 'gitea-assets'], delete: async key => {{ state.deletedCaches.push(key); }} }}, serviceWorker: {{ ready: Promise.resolve({{ active: {{ postMessage: message => state.workerMessages.push(message) }} }}) }}, location: {{ assign: value => {{ state.assigned = value; }} }}, + confirmAction: message => {{ state.confirmations.push(message); return true; }}, }}); (async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }}); """ @@ -73,6 +74,26 @@ process.stdout.write(JSON.stringify(state)); assert result["assigned"] == "/dashboard/login" +def test_sign_out_all_devices_requires_confirmation_and_uses_global_endpoint(): + result = run_session_scenario( + """ +await boundary.signOutAllDevices(); +state.remaining = Array.from(storage.values.keys()); +process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["confirmations"] == [ + "Sign out every device? You will need to sign in again everywhere." + ] + request = result["requests"][0] + assert request["url"] == "/dashboard/api/v1/sessions" + assert request["method"] == "DELETE" + assert request["headers"]["x-csrf-token"] == "csrf-proof" + assert result["remaining"] == ["gitea.preference"] + assert result["assigned"] == "/dashboard/login" + + def test_authenticated_dashboard_load_requests_queued_delivery_resume(): result = run_session_scenario( """ @@ -91,3 +112,5 @@ async def test_dashboard_loads_session_boundary_first_and_offers_sign_out(): assert '' in html assert html.index('static/session.js') < html.index('static/markdown.js') assert '' in html + assert '' in html + assert '#sign-out-all { min-height:44px; }' in html diff --git a/tests/test_session_store.py b/tests/test_session_store.py index 9393caf..e3c29ae 100644 --- a/tests/test_session_store.py +++ b/tests/test_session_store.py @@ -22,6 +22,22 @@ def test_revocation_is_durable_and_scoped_to_one_session(tmp_path): assert b"second-session-secret" not in database.read_bytes() +def test_revoke_all_is_durable_and_does_not_block_future_sessions(tmp_path): + now = [1_000.0] + database = tmp_path / "sessions.sqlite3" + first = SessionStore(database, clock=lambda: now[0]) + first.activate("phone-session", 2_000) + first.activate("laptop-session", 2_000) + + first.revoke_all() + + reconstructed = SessionStore(database, clock=lambda: now[0]) + assert reconstructed.is_active("phone-session", 2_000) is False + assert reconstructed.is_active("laptop-session", 2_000) is False + reconstructed.activate("new-session", 2_000) + assert reconstructed.is_active("new-session", 2_000) is True + + def test_validation_does_not_create_a_missing_registry(tmp_path): database = tmp_path / "sessions.sqlite3" store = SessionStore(database, clock=lambda: 1_000.0)