feat: review and revoke active devices (#325)
CI / lint (pull_request) Successful in 34s
CI / build-frontend (pull_request) Successful in 6s

This commit is contained in:
timmy committed 2026-08-08 17:59:17 +00:00
1 parent d46d296e09
commit 7034ec55ed
16 files changed
+464 -34

No files matched your search

+14 -2
View File
@@ -89,7 +89,9 @@ def _session_store(now: int | None = None) -> SessionStore:
return SessionStore(database, clock=current)
def issue_session(now: int | None = None) -> tuple[str, Session]:
def issue_session(
now: int | None = None, *, device_label: str = "This device"
) -> 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(
@@ -104,7 +106,9 @@ def issue_session(now: int | None = None) -> tuple[str, Session]:
).encode()
encoded = _encode(payload)
signature = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest())
_session_store(now).activate(session.session_id, session.expires_at)
_session_store(now).activate(
session.session_id, session.expires_at, device_label=device_label
)
return f"{encoded}.{signature}", session
@@ -144,6 +148,14 @@ async def revoke_all_sessions() -> None:
await asyncio.to_thread(_session_store().revoke_all)
async def active_devices(session: Session):
return await asyncio.to_thread(_session_store().list_active, session.session_id)
async def revoke_managed_session(management_id: str) -> bool:
return await asyncio.to_thread(_session_store().revoke_managed, management_id)
async def request_session(request: Request) -> Session | None:
return await asyncio.to_thread(verify_session, request.cookies.get(SESSION_COOKIE))
+61 -1
View File
@@ -132,6 +132,15 @@ class ReadinessPayloadError(ValueError):
class DashboardSignIn(BaseModel):
access_token: str = Field(min_length=1, max_length=1_024)
device_label: str = Field(default="This device", min_length=1, max_length=64)
@field_validator("device_label")
@classmethod
def normalize_device_label(cls, value: str) -> str:
normalized = " ".join(value.split())
if not normalized:
raise ValueError("device label cannot be blank")
return normalized
def _login_attempt_store() -> LoginAttemptStore:
@@ -660,7 +669,9 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
headers={"Cache-Control": "no-store"},
)
try:
signed, session = await asyncio.to_thread(dashboard_auth.issue_session)
signed, session = await asyncio.to_thread(
dashboard_auth.issue_session, device_label=payload.device_label
)
except dashboard_auth.SessionStoreError:
return JSONResponse(
{"detail": "Session registry is temporarily unavailable"},
@@ -730,6 +741,55 @@ async def sign_out(request: Request, response: Response):
return {"authenticated": False, "clear_private_device_data": True}
@app.get("/api/v1/sessions")
async def list_active_devices(request: Request, response: Response):
try:
devices = await dashboard_auth.active_devices(request.state.dashboard_session)
except dashboard_auth.SessionStoreError:
return JSONResponse(
{"detail": "Session registry is temporarily unavailable"},
status_code=503,
headers={"Cache-Control": "no-store"},
)
response.headers["Cache-Control"] = "no-store"
return {
"devices": [
{
"management_id": device.management_id,
"device_label": device.device_label,
"created_at": device.created_at,
"expires_at": device.expires_at,
"current": device.current,
}
for device in devices
]
}
@app.delete("/api/v1/sessions/{management_id}")
async def revoke_active_device(
request: Request,
management_id: str = PathParam(
min_length=16, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"
),
):
try:
devices = await dashboard_auth.active_devices(request.state.dashboard_session)
target = next(
(device for device in devices if device.management_id == management_id), None
)
revoked = await dashboard_auth.revoke_managed_session(management_id)
except dashboard_auth.SessionStoreError:
return JSONResponse(
{"detail": "Session registry is temporarily unavailable"},
status_code=503,
headers={"Cache-Control": "no-store"},
)
if target is None or not revoked:
raise HTTPException(status_code=404, detail="Active device not found")
return {"revoked": True, "current_session": target.current}
@app.delete("/api/v1/sessions")
async def sign_out_all_devices(request: Request, response: Response):
try:
+94 -5
View File
@@ -1,7 +1,9 @@
"""Durable active-session registry used to revoke signed operator sessions."""
import hashlib
import secrets
import sqlite3
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
@@ -10,6 +12,15 @@ class SessionStoreError(RuntimeError):
"""Raised when session state cannot be read or changed safely."""
@dataclass(frozen=True)
class ActiveDevice:
management_id: str
device_label: str
created_at: int
expires_at: int
current: bool
class SessionStore:
def __init__(
self,
@@ -44,23 +55,67 @@ class SessionStore:
"""
CREATE TABLE IF NOT EXISTS active_sessions (
session_hash TEXT PRIMARY KEY,
expires_at INTEGER NOT NULL
expires_at INTEGER NOT NULL,
management_id TEXT,
device_label TEXT,
created_at INTEGER
)
"""
)
columns = {
row[1] for row in connection.execute("PRAGMA table_info(active_sessions)")
}
additions = {
"management_id": "TEXT",
"device_label": "TEXT",
"created_at": "INTEGER",
}
for name, column_type in additions.items():
if name not in columns:
connection.execute(
f"ALTER TABLE active_sessions ADD COLUMN {name} {column_type}"
)
connection.execute(
"UPDATE active_sessions SET management_id = lower(hex(randomblob(16))) "
"WHERE management_id IS NULL"
)
connection.execute(
"UPDATE active_sessions SET device_label = 'Existing device' "
"WHERE device_label IS NULL"
)
connection.execute(
"UPDATE active_sessions SET created_at = ? WHERE created_at IS NULL",
(int(self.clock()),),
)
connection.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS active_sessions_management_id "
"ON active_sessions(management_id)"
)
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:
def activate(
self, session_id: str, expires_at: int, *, device_label: str = "This device"
) -> None:
label = " ".join(str(device_label).split())[:64] or "This device"
now = int(self.clock())
try:
with self._connect(initialize=True) as connection:
connection.execute(
"DELETE FROM active_sessions WHERE expires_at <= ?", (int(self.clock()),)
"DELETE FROM active_sessions WHERE expires_at <= ?", (now,)
)
connection.execute(
"INSERT INTO active_sessions(session_hash, expires_at) VALUES (?, ?)",
(self._digest(session_id), expires_at),
"INSERT INTO active_sessions("
"session_hash, expires_at, management_id, device_label, created_at"
") VALUES (?, ?, ?, ?, ?)",
(
self._digest(session_id),
expires_at,
secrets.token_urlsafe(18),
label,
now,
),
)
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
@@ -87,6 +142,40 @@ class SessionStore:
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
def list_active(self, current_session_id: str) -> list[ActiveDevice]:
now = int(self.clock())
current_hash = self._digest(current_session_id)
try:
with self._connect() as connection:
rows = connection.execute(
"SELECT management_id, device_label, created_at, expires_at, session_hash "
"FROM active_sessions WHERE expires_at > ? "
"ORDER BY expires_at DESC, created_at DESC",
(now,),
).fetchall()
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
return [
ActiveDevice(
management_id=row[0],
device_label=row[1],
created_at=row[2],
expires_at=row[3],
current=secrets.compare_digest(row[4], current_hash),
)
for row in rows
]
def revoke_managed(self, management_id: str) -> bool:
try:
with self._connect() as connection:
cursor = connection.execute(
"DELETE FROM active_sessions WHERE management_id = ?", (management_id,)
)
return cursor.rowcount == 1
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:
+1 -1
View File
@@ -13,7 +13,7 @@ LOGIN_HTML = """<!doctype html>
<title>Sign in · Stackchain Dashboard</title>
<style>body{margin:0;background:#07111f;color:#eef6ff;font:16px system-ui;display:grid;min-height:100vh;place-items:center}main{box-sizing:border-box;width:min(90vw,24rem);padding:2rem;border:1px solid #29415d;border-radius:1rem;background:#0d1b2b}label,input,button{display:block;width:100%;box-sizing:border-box}input,button{min-height:48px;margin-top:.6rem;border-radius:.6rem;border:1px solid #49647f;padding:.75rem}button{margin-top:1rem;background:#55d6be;color:#06121b;font-weight:700}p{color:#a9bed3}</style></head>
<body><main><h1>Operator sign in</h1><p>Enter the dashboard access token. It is exchanged for a private, short-lived session and is never stored on this device.</p>
<form id="sign-in"><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button id="submit-sign-in">Sign in</button><p id="status" role="status" aria-live="polite"></p></form></main>
<form id="sign-in"><label>Device name<input name="device_label" type="text" autocomplete="name" maxlength="64" value="This device" required></label><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button id="submit-sign-in">Sign in</button><p id="status" role="status" aria-live="polite"></p></form></main>
<script src="static/login.js"></script></body></html>"""