feat: lock idle dashboard sessions (#463)
CI / lint (pull_request) Successful in 56s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-10 07:45:00 +00:00
1 parent 9904f17e3d
commit 0b992543f1
12 files changed
+402 -27

No files matched your search

+30 -1
View File
@@ -18,6 +18,7 @@ from src.session_store import SessionStore, SessionStoreError
SESSION_COOKIE = "stackchain_session"
CSRF_COOKIE = "stackchain_csrf"
DEFAULT_TTL_SECONDS = 8 * 60 * 60
DEFAULT_IDLE_TIMEOUT_SECONDS = 15 * 60
STEP_UP_TTL_SECONDS = 90
MIN_SECRET_LENGTH = 24
OPERATOR_MODE = "operator"
@@ -96,6 +97,18 @@ def _session_store(now: int | None = None) -> SessionStore:
return SessionStore(database, clock=current)
def idle_timeout_seconds() -> int:
return max(
1,
int(
os.getenv(
"STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS",
str(DEFAULT_IDLE_TIMEOUT_SECONDS),
)
),
)
def issue_session(
now: int | None = None, *, device_label: str = "This device"
) -> tuple[str, Session]:
@@ -146,7 +159,14 @@ def verify_session_with_reason(
or not session.csrf
):
return SessionVerification(None)
if not _session_store(now).is_active(session.session_id, session.expires_at):
status = _session_store(now).status(
session.session_id,
session.expires_at,
idle_timeout_seconds=idle_timeout_seconds(),
)
if status == "idle":
return SessionVerification(None, "session_idle")
if status != "active":
return SessionVerification(None, "session_revoked")
return SessionVerification(session)
@@ -167,6 +187,15 @@ async def active_devices(session: Session):
return await asyncio.to_thread(_session_store().list_active, session.session_id)
async def touch_session(session: Session) -> bool:
return await asyncio.to_thread(
_session_store().touch,
session.session_id,
session.expires_at,
idle_timeout_seconds=idle_timeout_seconds(),
)
async def revoke_managed_session(management_id: str) -> bool:
return await asyncio.to_thread(_session_store().revoke_managed, management_id)
+30 -6
View File
@@ -670,18 +670,19 @@ async def require_operator_session(request: Request, call_next):
if not public and session is None:
if path.startswith("/api/"):
payload = {"detail": "Authentication required"}
if session_reason == "session_revoked":
if session_reason in {"session_revoked", "session_idle"}:
payload["code"] = session_reason
return JSONResponse(
payload,
status_code=401,
headers={"Cache-Control": "no-store"},
)
login_redirect = (
"login?reason=session-revoked"
if session_reason == "session_revoked"
else _share_target_login_redirect(request)
)
if session_reason == "session_revoked":
login_redirect = "login?reason=session-revoked"
elif session_reason == "session_idle":
login_redirect = "login?reason=session-idle"
else:
login_redirect = _share_target_login_redirect(request)
return RedirectResponse(
login_redirect,
status_code=303,
@@ -937,6 +938,29 @@ async def session_status(request: Request):
return payload
@app.post("/api/v1/session/activity")
async def record_session_activity(request: Request):
session = request.state.dashboard_session
try:
active = await dashboard_auth.touch_session(session)
except dashboard_auth.SessionStoreError:
return JSONResponse(
{"detail": "Session registry is temporarily unavailable"},
status_code=503,
headers={"Cache-Control": "no-store"},
)
if not active:
return JSONResponse(
{"detail": "Authentication required"},
status_code=401,
headers={"Cache-Control": "no-store"},
)
return {
"active": True,
"idle_expires_at": int(time.time()) + dashboard_auth.idle_timeout_seconds(),
}
def _today_store() -> TodayStore:
return TodayStore(
os.getenv("STACKCHAIN_TODAY_DB", str(_state_dir / "today.sqlite3")), limit=5
+59 -3
View File
@@ -58,7 +58,8 @@ class SessionStore:
expires_at INTEGER NOT NULL,
management_id TEXT,
device_label TEXT,
created_at INTEGER
created_at INTEGER,
last_active_at INTEGER
)
"""
)
@@ -84,6 +85,7 @@ class SessionStore:
"management_id": "TEXT",
"device_label": "TEXT",
"created_at": "INTEGER",
"last_active_at": "INTEGER",
}
for name, column_type in additions.items():
if name not in columns:
@@ -102,6 +104,10 @@ class SessionStore:
"UPDATE active_sessions SET created_at = ? WHERE created_at IS NULL",
(int(self.clock()),),
)
connection.execute(
"UPDATE active_sessions SET last_active_at = ? WHERE last_active_at IS NULL",
(int(self.clock()),),
)
connection.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS active_sessions_management_id "
"ON active_sessions(management_id)"
@@ -122,14 +128,15 @@ class SessionStore:
)
connection.execute(
"INSERT INTO active_sessions("
"session_hash, expires_at, management_id, device_label, created_at"
") VALUES (?, ?, ?, ?, ?)",
"session_hash, expires_at, management_id, device_label, created_at, last_active_at"
") VALUES (?, ?, ?, ?, ?, ?)",
(
self._digest(session_id),
expires_at,
secrets.token_urlsafe(18),
label,
now,
now,
),
)
except (OSError, sqlite3.Error) as exc:
@@ -147,6 +154,55 @@ class SessionStore:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
return row is not None and row[0] == expires_at and expires_at > now
def status(
self, session_id: str, expires_at: int, *, idle_timeout_seconds: int
) -> str:
now = int(self.clock())
query = (
"SELECT expires_at, last_active_at FROM active_sessions "
"WHERE session_hash = ?"
)
parameters = (self._digest(session_id),)
try:
try:
with self._connect() as connection:
row = connection.execute(query, parameters).fetchone()
except sqlite3.OperationalError as exc:
if "no such column: last_active_at" not in str(exc):
raise
with self._connect(initialize=True) as connection:
row = connection.execute(query, parameters).fetchone()
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
if row is None or row[0] != expires_at or expires_at <= now:
return "revoked"
if row[1] + max(1, idle_timeout_seconds) <= now:
return "idle"
return "active"
def touch(
self, session_id: str, expires_at: int, *, idle_timeout_seconds: int
) -> bool:
now = int(self.clock())
try:
with self._connect() as connection:
cursor = connection.execute(
"UPDATE active_sessions SET last_active_at = ? "
"WHERE session_hash = ? AND expires_at = ? AND expires_at > ? "
"AND last_active_at + ? > ?",
(
now,
self._digest(session_id),
expires_at,
now,
max(1, idle_timeout_seconds),
now,
),
)
return cursor.rowcount == 1
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
def revoke(self, session_id: str) -> None:
try:
with self._connect(initialize=True) as connection: