Revoke captured operator sessions on logout #269

Merged
timmy merged 1 commits from timmy/268-revoke-logout into main 2026-08-08 05:57:07 +00:00
6 changed files with 311 additions and 8 deletions

View File

@ -59,11 +59,22 @@ export GITEA_TOKEN='<read-notification-and-issue-write-token>'
export STACKCHAIN_DASHBOARD_AUTH_MODE='operator'
export STACKCHAIN_DASHBOARD_ACCESS_TOKEN='<operator-sign-in-secret>'
export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-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

View File

@ -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:

View File

@ -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,

79
src/session_store.py Normal file
View File

@ -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

View File

@ -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():

View File

@ -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,)