feat: lock idle dashboard sessions (#463)
This commit is contained in:
1 parent
9904f17e3d
commit
0b992543f1
12 files changed
+402
-27
No files matched your search
+30
-1
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user