Revoke captured operator sessions on logout #269
11
README.md
11
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
27
src/main.py
27
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,
|
||||
|
|
|
|||
79
src/session_store.py
Normal file
79
src/session_store.py
Normal 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
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
31
tests/test_session_store.py
Normal file
31
tests/test_session_store.py
Normal 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,)
|
||||
Loading…
Reference in New Issue
Block a user