From c8de28ca0c62a9c75700e28734f88777bf3cad03 Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 8 Aug 2026 05:55:51 +0000 Subject: [PATCH] feat: revoke operator sessions on logout (#268) --- README.md | 11 +++ src/dashboard_auth.py | 41 +++++++++-- src/main.py | 27 +++++++- src/session_store.py | 79 +++++++++++++++++++++ tests/test_dashboard_auth.py | 130 ++++++++++++++++++++++++++++++++++- tests/test_session_store.py | 31 +++++++++ 6 files changed, 311 insertions(+), 8 deletions(-) create mode 100644 src/session_store.py create mode 100644 tests/test_session_store.py diff --git a/README.md b/README.md index e9a95cc..dd3a845 100644 --- a/README.md +++ b/README.md @@ -59,11 +59,22 @@ export GITEA_TOKEN='' export STACKCHAIN_DASHBOARD_AUTH_MODE='operator' export STACKCHAIN_DASHBOARD_ACCESS_TOKEN='' export STACKCHAIN_DASHBOARD_SESSION_SECRET='' +# Optional; defaults to STACKCHAIN_STATE_DIR/sessions.sqlite3. +export STACKCHAIN_SESSION_DB='/var/lib/stackchain-dashboard/sessions.sqlite3' # Optional; defaults to eight hours. export STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS=28800 uvicorn src.main:app --host 127.0.0.1 --port 8000 ``` +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. + Terminate TLS at the trusted reverse proxy: session cookies are deliberately `Secure`, `HttpOnly`, `SameSite=Strict`, and scoped to the deployment subpath. Use **Sign out & clear this device** on shared devices; it clears Stackchain's diff --git a/src/dashboard_auth.py b/src/dashboard_auth.py index fe3f406..99ab08a 100644 --- a/src/dashboard_auth.py +++ b/src/dashboard_auth.py @@ -12,6 +12,8 @@ from dataclasses import dataclass from fastapi import Request +from src.session_store import SessionStore, SessionStoreError + SESSION_COOKIE = "stackchain_session" CSRF_COOKIE = "stackchain_csrf" DEFAULT_TTL_SECONDS = 8 * 60 * 60 @@ -22,6 +24,7 @@ INSECURE_LOCAL_MODE = "insecure-local" @dataclass(frozen=True) class Session: + session_id: str csrf: str expires_at: int @@ -76,17 +79,31 @@ def _decode(value: str) -> bytes: return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) +def _session_store(now: int | None = None) -> SessionStore: + database = os.getenv( + "STACKCHAIN_SESSION_DB", + os.path.join(os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state"), "sessions.sqlite3"), + ) + current = time.time if now is None else lambda: now + return SessionStore(database, clock=current) + + def issue_session(now: int | None = None) -> tuple[str, Session]: issued_at = int(time.time() if now is None else now) ttl = int(os.getenv("STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS", str(DEFAULT_TTL_SECONDS))) - session = Session(csrf=secrets.token_urlsafe(24), expires_at=issued_at + max(1, ttl)) + session = Session( + session_id=secrets.token_urlsafe(32), + csrf=secrets.token_urlsafe(24), + expires_at=issued_at + max(1, ttl), + ) payload = json.dumps( - {"csrf": session.csrf, "exp": session.expires_at}, + {"csrf": session.csrf, "exp": session.expires_at, "sid": session.session_id}, separators=(",", ":"), sort_keys=True, ).encode() encoded = _encode(payload) signature = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest()) + _session_store(now).activate(session.session_id, session.expires_at) return f"{encoded}.{signature}", session @@ -99,13 +116,27 @@ def verify_session(value: str | None, now: int | None = None) -> Session | None: return None try: payload = json.loads(_decode(encoded)) - session = Session(csrf=payload["csrf"], expires_at=int(payload["exp"])) + session = Session( + session_id=payload["sid"], + csrf=payload["csrf"], + expires_at=int(payload["exp"]), + ) except (ValueError, TypeError, KeyError, json.JSONDecodeError): return None current = int(time.time() if now is None else now) - if session.expires_at <= current or not isinstance(session.csrf, str) or not session.csrf: + if ( + session.expires_at <= current + or not isinstance(session.session_id, str) + or not session.session_id + or not isinstance(session.csrf, str) + or not session.csrf + ): return None - return session + return session if _session_store(now).is_active(session.session_id, session.expires_at) else None + + +def revoke_session(session: Session) -> None: + _session_store().revoke(session.session_id) def request_session(request: Request) -> Session | None: diff --git a/src/main.py b/src/main.py index 89c15d6..4c42abd 100644 --- a/src/main.py +++ b/src/main.py @@ -455,7 +455,14 @@ async def require_operator_session(request: Request, call_next): or path.startswith("/static/") or (path == "/api/v1/session" and request.method == "POST") ) - session = dashboard_auth.request_session(request) + try: + session = dashboard_auth.request_session(request) + except dashboard_auth.SessionStoreError: + return JSONResponse( + {"detail": "Session registry is temporarily unavailable"}, + status_code=503, + headers={"Cache-Control": "no-store"}, + ) if not public and session is None: if path.startswith("/api/"): return JSONResponse( @@ -519,7 +526,14 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response payload.access_token, configured_token ): raise HTTPException(status_code=401, detail="Invalid access token") - signed, session = dashboard_auth.issue_session() + try: + signed, session = dashboard_auth.issue_session() + 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) max_age = max(1, session.expires_at - int(time.time())) response.set_cookie( @@ -555,6 +569,15 @@ async def session_status(request: Request): @app.delete("/api/v1/session") async def sign_out(request: Request, response: Response): + session = request.state.dashboard_session + try: + dashboard_auth.revoke_session(session) + 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, diff --git a/src/session_store.py b/src/session_store.py new file mode 100644 index 0000000..18d4051 --- /dev/null +++ b/src/session_store.py @@ -0,0 +1,79 @@ +"""Durable active-session registry used to revoke signed operator sessions.""" + +import hashlib +import sqlite3 +from pathlib import Path +from typing import Callable + + +class SessionStoreError(RuntimeError): + """Raised when session state cannot be read or changed safely.""" + + +class SessionStore: + def __init__( + self, + path: str | Path, + *, + clock: Callable[[], float], + lock_timeout_seconds: float = 0.1, + ) -> None: + self.path = Path(path) + self.clock = clock + self.lock_timeout_seconds = lock_timeout_seconds + + @staticmethod + def _digest(session_id: str) -> str: + return hashlib.sha256(session_id.encode()).hexdigest() + + def _connect(self) -> sqlite3.Connection: + try: + self.path.parent.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(self.path, timeout=self.lock_timeout_seconds) + connection.execute( + """ + CREATE TABLE IF NOT EXISTS active_sessions ( + session_hash TEXT PRIMARY KEY, + expires_at INTEGER NOT NULL + ) + """ + ) + return connection + except (OSError, sqlite3.Error) as exc: + raise SessionStoreError("Session registry is temporarily unavailable") from exc + + def activate(self, session_id: str, expires_at: int) -> None: + try: + with self._connect() as connection: + connection.execute( + "DELETE FROM active_sessions WHERE expires_at <= ?", (int(self.clock()),) + ) + connection.execute( + "INSERT INTO active_sessions(session_hash, expires_at) VALUES (?, ?)", + (self._digest(session_id), expires_at), + ) + except (OSError, sqlite3.Error) as exc: + raise SessionStoreError("Session registry is temporarily unavailable") from exc + + def is_active(self, session_id: str, expires_at: int) -> bool: + now = int(self.clock()) + try: + with self._connect() as connection: + connection.execute("DELETE FROM active_sessions WHERE expires_at <= ?", (now,)) + row = connection.execute( + "SELECT expires_at FROM active_sessions WHERE session_hash = ?", + (self._digest(session_id),), + ).fetchone() + except (OSError, sqlite3.Error) as exc: + raise SessionStoreError("Session registry is temporarily unavailable") from exc + return row is not None and row[0] == expires_at and expires_at > now + + def revoke(self, session_id: str) -> None: + try: + with self._connect() as connection: + connection.execute( + "DELETE FROM active_sessions WHERE session_hash = ?", + (self._digest(session_id),), + ) + 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 22dc59f..b2cf937 100644 --- a/tests/test_dashboard_auth.py +++ b/tests/test_dashboard_auth.py @@ -2,13 +2,15 @@ import httpx import pytest from src import main +from src.session_store import SessionStoreError @pytest.fixture -def access_control(monkeypatch): +def access_control(monkeypatch, tmp_path): monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator") monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple") monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy") + monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3")) @pytest.mark.anyio @@ -207,6 +209,132 @@ 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_logout_revokes_a_captured_cookie_before_gitea(access_control, monkeypatch): + calls = 0 + + async def user(): + nonlocal calls + calls += 1 + return {"id": 1, "login": "timmy"} + + monkeypatch.setattr(main, "current_user", user) + 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"} + ) + captured = client.cookies["stackchain_session"] + csrf = client.cookies["stackchain_csrf"] + + async with httpx.AsyncClient( + transport=transport, + base_url="https://test", + headers={"Cookie": f"stackchain_session={captured}"}, + ) as replay: + before = await replay.get("/api/v1/background-identity") + + await client.delete( + "/api/v1/session", + headers={"Origin": "https://test", "X-CSRF-Token": csrf}, + ) + + async with httpx.AsyncClient( + transport=transport, + base_url="https://test", + headers={"Cookie": f"stackchain_session={captured}"}, + ) as replay: + after = await replay.get("/api/v1/background-identity") + + assert before.status_code == 200 + assert after.status_code == 401 + assert after.headers["cache-control"] == "no-store" + assert calls == 1 + + +@pytest.mark.anyio +async def test_session_registry_read_failure_fails_closed_before_gitea( + access_control, monkeypatch +): + called = False + + async def user(): + nonlocal called + called = True + return {"id": 1, "login": "timmy"} + + monkeypatch.setattr(main, "current_user", user) + 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"} + ) + + class BrokenStore: + def is_active(self, session_id, expires_at): + raise SessionStoreError("database path and secret details") + + monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore()) + response = await client.get("/api/v1/background-identity") + health = await client.get("/healthz") + + assert response.status_code == 503 + assert response.json() == {"detail": "Session registry is temporarily unavailable"} + assert response.headers["cache-control"] == "no-store" + assert "database path" not in response.text + assert called is False + assert health.status_code == 200 + + +@pytest.mark.anyio +async def test_sign_in_registry_write_failure_issues_no_cookie(access_control, monkeypatch): + class BrokenStore: + def activate(self, session_id, expires_at): + raise SessionStoreError("database path and secret details") + + monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore()) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="https://test") as client: + response = await client.post( + "/api/v1/session", json={"access_token": "correct horse battery staple"} + ) + + 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_registry_failure_does_not_claim_revocation(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(self, session_id): + raise SessionStoreError("database path and secret details") + + monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore()) + response = await client.delete( + "/api/v1/session", + 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_public_routes_remain_available_and_readiness_hides_identity(access_control, monkeypatch): async def user(): diff --git a/tests/test_session_store.py b/tests/test_session_store.py new file mode 100644 index 0000000..c8c20bd --- /dev/null +++ b/tests/test_session_store.py @@ -0,0 +1,31 @@ +import sqlite3 + +from src.session_store import SessionStore + + +def test_revocation_is_durable_and_scoped_to_one_session(tmp_path): + now = [1_000.0] + database = tmp_path / "sessions.sqlite3" + first = SessionStore(database, clock=lambda: now[0]) + first.activate("first-session-secret", 2_000) + first.activate("second-session-secret", 2_000) + + reconstructed = SessionStore(database, clock=lambda: now[0]) + reconstructed.revoke("first-session-secret") + + assert reconstructed.is_active("first-session-secret", 2_000) is False + assert reconstructed.is_active("second-session-secret", 2_000) is True + assert b"first-session-secret" not in database.read_bytes() + assert b"second-session-secret" not in database.read_bytes() + + +def test_expired_sessions_are_rejected_and_pruned(tmp_path): + now = [1_000.0] + store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0]) + store.activate("expiring-session", 1_001) + + now[0] = 1_001.0 + + assert store.is_active("expiring-session", 1_001) is False + with sqlite3.connect(store.path) as connection: + assert connection.execute("SELECT COUNT(*) FROM active_sessions").fetchone() == (0,)