feat: review and revoke active devices (#325)
This commit is contained in:
1 parent
d46d296e09
commit
7034ec55ed
16 files changed
+464
-34
No files matched your search
+14
-2
@@ -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
@@ -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
@@ -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
@@ -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>"""
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user