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
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import sqlite3
|
||||
import time
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
@@ -245,10 +246,14 @@ async def test_session_status_reuses_the_middleware_validation(access_control, m
|
||||
lookups = 0
|
||||
|
||||
class CountingStore:
|
||||
def is_active(self, session_id, expires_at):
|
||||
def status(self, session_id, expires_at, *, idle_timeout_seconds):
|
||||
nonlocal lookups
|
||||
lookups += 1
|
||||
return original_store.is_active(session_id, expires_at)
|
||||
return original_store.status(
|
||||
session_id,
|
||||
expires_at,
|
||||
idle_timeout_seconds=idle_timeout_seconds,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: CountingStore())
|
||||
response = await client.get("/api/v1/session")
|
||||
@@ -291,6 +296,52 @@ async def test_anonymous_session_status_discloses_no_offline_lease(access_contro
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_idle_session_is_rejected_with_distinct_api_and_page_recovery(access_control, monkeypatch):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS", "900")
|
||||
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"}
|
||||
)
|
||||
with sqlite3.connect(main.dashboard_auth._session_store().path) as connection:
|
||||
connection.execute(
|
||||
"UPDATE active_sessions SET last_active_at = ?", (int(time.time()) - 900,)
|
||||
)
|
||||
|
||||
api_response = await client.get("/api/v1/session")
|
||||
page_response = await client.get("/")
|
||||
|
||||
assert api_response.status_code == 401
|
||||
assert api_response.json() == {
|
||||
"detail": "Authentication required",
|
||||
"code": "session_idle",
|
||||
}
|
||||
assert page_response.status_code == 303
|
||||
assert page_response.headers["location"] == "login?reason=session-idle"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_activity_heartbeat_extends_the_server_idle_deadline(access_control, monkeypatch):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS", "900")
|
||||
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"}
|
||||
)
|
||||
heartbeat = await client.post(
|
||||
"/api/v1/session/activity",
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
},
|
||||
)
|
||||
|
||||
assert heartbeat.status_code == 200
|
||||
assert heartbeat.json()["active"] is True
|
||||
assert heartbeat.json()["idle_expires_at"] >= int(time.time()) + 899
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_merge_requires_single_use_fresh_authorization_bound_to_exact_target(
|
||||
access_control, monkeypatch
|
||||
@@ -615,8 +666,8 @@ async def test_sign_out_all_devices_registry_failure_sets_no_cookies(access_cont
|
||||
grant = await fresh_grant(client, "revoke_all_sessions", "all")
|
||||
|
||||
class BrokenStore:
|
||||
def is_active(self, session_id, expires_at):
|
||||
return True
|
||||
def status(self, session_id, expires_at, *, idle_timeout_seconds):
|
||||
return "active"
|
||||
|
||||
def consume_step_up(self, grant, session_id, *, action, target):
|
||||
return True
|
||||
@@ -693,9 +744,9 @@ async def test_session_registry_latency_does_not_block_the_event_loop(access_con
|
||||
)
|
||||
|
||||
class SlowStore:
|
||||
def is_active(self, session_id, expires_at):
|
||||
def status(self, session_id, expires_at, *, idle_timeout_seconds):
|
||||
time.sleep(0.15)
|
||||
return True
|
||||
return "active"
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: SlowStore())
|
||||
private_request = asyncio.create_task(client.get("/api/v1/session"))
|
||||
@@ -847,9 +898,9 @@ async def test_single_session_revocation_does_not_block_the_event_loop(
|
||||
csrf = client.cookies["stackchain_csrf"]
|
||||
|
||||
class SlowStore:
|
||||
def is_active(self, session_id, expires_at):
|
||||
def status(self, session_id, expires_at, *, idle_timeout_seconds):
|
||||
time.sleep(0.02)
|
||||
return True
|
||||
return "active"
|
||||
|
||||
def revoke(self, session_id):
|
||||
time.sleep(0.15)
|
||||
@@ -893,7 +944,7 @@ async def test_session_registry_read_failure_fails_closed_before_gitea(
|
||||
)
|
||||
|
||||
class BrokenStore:
|
||||
def is_active(self, session_id, expires_at):
|
||||
def status(self, session_id, expires_at, *, idle_timeout_seconds):
|
||||
raise SessionStoreError("database path and secret details")
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
|
||||
@@ -938,8 +989,8 @@ async def test_logout_registry_failure_does_not_claim_revocation(access_control,
|
||||
csrf = client.cookies["stackchain_csrf"]
|
||||
|
||||
class BrokenStore:
|
||||
def is_active(self, session_id, expires_at):
|
||||
return True
|
||||
def status(self, session_id, expires_at, *, idle_timeout_seconds):
|
||||
return "active"
|
||||
|
||||
def revoke(self, session_id):
|
||||
raise SessionStoreError("database path and secret details")
|
||||
@@ -966,7 +1017,7 @@ async def test_public_routes_skip_session_registry_validation(access_control, mo
|
||||
)
|
||||
|
||||
class BrokenStore:
|
||||
def is_active(self, session_id, expires_at):
|
||||
def status(self, session_id, expires_at, *, idle_timeout_seconds):
|
||||
raise SessionStoreError("public routes must not read the registry")
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
|
||||
|
||||
@@ -14,7 +14,7 @@ SESSION_JS = ROOT / "frontend" / "session.js"
|
||||
def run_session_scenario(scenario: str) -> dict:
|
||||
harness = f"""
|
||||
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
|
||||
const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, replacedAfterDeletion: false, expiredAfterDeletion: false, workerMessages: [], confirmations: [], prompts: [], clearErrors: [], responseStatus: 200, responsePayload: {{}}, responses: [] }};
|
||||
const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, replacedAfterDeletion: false, expiredAfterDeletion: false, workerMessages: [], confirmations: [], prompts: [], clearErrors: [], activityListeners: {{}}, now: 100000, responseStatus: 200, responsePayload: {{}}, responses: [] }};
|
||||
const storage = {{
|
||||
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
|
||||
get length() {{ return this.values.size; }},
|
||||
@@ -57,6 +57,8 @@ const boundary = createSessionBoundary({{
|
||||
}},
|
||||
onClearError: error => state.clearErrors.push(error.message),
|
||||
onExpired: () => {{ state.expiredAfterDeletion = state.deletionCompleted; }},
|
||||
addActivityListener: (type, listener) => {{ state.activityListeners[type] = listener; }},
|
||||
now: () => state.now,
|
||||
setTimer: (_callback, delay) => {{ state.leaseDelay = delay; return 1; }},
|
||||
confirmAction: message => {{ state.confirmations.push(message); return true; }},
|
||||
promptAuthorization: details => {{ state.prompts.push(details); return 'correct horse battery staple'; }},
|
||||
@@ -232,6 +234,29 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["requests"][0]["headers"]["x-csrf-token"] == "csrf-proof"
|
||||
|
||||
|
||||
def test_user_activity_heartbeat_is_throttled_across_pointer_keyboard_and_touch_events():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
boundary.startActivityHeartbeat();
|
||||
await state.activityListeners.pointerdown();
|
||||
await state.activityListeners.keydown();
|
||||
state.now += 60000;
|
||||
await state.activityListeners.touchstart();
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert [request["url"] for request in result["requests"]] == [
|
||||
"/dashboard/api/v1/session/activity",
|
||||
"/dashboard/api/v1/session/activity",
|
||||
]
|
||||
assert all(request["method"] == "POST" for request in result["requests"])
|
||||
assert all(
|
||||
request["headers"]["x-csrf-token"] == "csrf-proof"
|
||||
for request in result["requests"]
|
||||
)
|
||||
|
||||
|
||||
def test_authenticated_session_status_persists_offline_lease_for_worker_and_expiry_timer():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
@@ -350,6 +375,24 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["replacedAfterDeletion"] is True
|
||||
|
||||
|
||||
def test_idle_response_locks_without_clearing_private_queued_work():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responseStatus = 401;
|
||||
state.responsePayload = {detail:'Authentication required', code:'session_idle'};
|
||||
await boundary.fetch('/dashboard/api/v1/live');
|
||||
state.remaining = Array.from(storage.values.keys());
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["remaining"] == ["stackchain.private", "gitea.preference"]
|
||||
assert result["deletedDatabases"] == []
|
||||
assert result["deletedCaches"] == []
|
||||
assert result["workerMessages"] == []
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-idle"]
|
||||
|
||||
|
||||
def test_worker_revocation_message_clears_window_storage_before_login():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
@@ -363,6 +406,19 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-revoked"]
|
||||
|
||||
|
||||
def test_worker_idle_message_locks_without_clearing_window_storage():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
await boundary.handleServiceWorkerMessage({data:{type:'stackchain-session-idle'}});
|
||||
state.remaining = Array.from(storage.values.keys());
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["remaining"] == ["stackchain.private", "gitea.preference"]
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-idle"]
|
||||
|
||||
|
||||
def test_worker_expiry_message_clears_window_storage_before_expired_login():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
|
||||
@@ -37,6 +37,28 @@ process.stdout.write(JSON.stringify({{ expired, ignored: status.textContent }}))
|
||||
assert state["ignored"] == state["expired"]
|
||||
|
||||
|
||||
def test_idle_session_reason_explains_that_saved_work_will_resume():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
const status = {{ textContent: '' }};
|
||||
const controller = createLoginController({{
|
||||
form: {{ reset: () => {{}} }}, status, button: {{ disabled: false }},
|
||||
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
|
||||
location: {{ replace: () => {{}} }},
|
||||
}});
|
||||
controller.showReason('session-idle');
|
||||
process.stdout.write(JSON.stringify({{ status: status.textContent }}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout)["status"] == (
|
||||
"Stackchain locked after inactivity. Your drafts and queued work are still "
|
||||
"on this device. Sign in to resume."
|
||||
)
|
||||
|
||||
|
||||
def test_revoked_session_reason_clears_private_data_before_enabling_sign_in():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
|
||||
@@ -237,6 +237,26 @@ def test_revoked_background_session_purges_worker_data_and_notifies_dashboard_cl
|
||||
]
|
||||
|
||||
|
||||
def test_idle_background_session_preserves_outbox_and_notifies_dashboard_clients():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.clientMessages=[];
|
||||
state.clientList=[{postMessage:message=>state.clientMessages.push(message)}];
|
||||
context.fetch=async()=>new Response(JSON.stringify({detail:'Authentication required',code:'session_idle'}),{status:401,headers:{'Content-Type':'application/json'}});
|
||||
const outcome=await context.self.__testFetchJson('/dashboard/api/v1/repos/o/r/issues',{method:'POST'})
|
||||
.then(()=>({ok:true}),error=>({status:error.status,code:error.code}));
|
||||
process.stdout.write(JSON.stringify({state,outcome}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["outcome"] == {"status": 401, "code": "session_idle"}
|
||||
assert result["state"]["outboxPurges"] == 0
|
||||
assert result["state"]["deleted"] == []
|
||||
assert result["state"]["clientMessages"] == [
|
||||
{"type": "stackchain-session-idle"}
|
||||
]
|
||||
|
||||
|
||||
def test_authenticated_page_message_resumes_queued_background_delivery():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
|
||||
@@ -77,6 +77,43 @@ def test_expired_sessions_are_rejected_without_writing_during_validation(tmp_pat
|
||||
assert connection.execute("SELECT COUNT(*) FROM active_sessions").fetchone() == (1,)
|
||||
|
||||
|
||||
def test_session_status_reports_idle_without_background_validation_extending_activity(tmp_path):
|
||||
now = [1_000.0]
|
||||
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
|
||||
store.activate("phone-session", 3_000)
|
||||
|
||||
now[0] = 1_899.0
|
||||
assert store.status("phone-session", 3_000, idle_timeout_seconds=900) == "active"
|
||||
now[0] = 1_900.0
|
||||
assert store.status("phone-session", 3_000, idle_timeout_seconds=900) == "idle"
|
||||
|
||||
|
||||
def test_touch_extends_only_the_matching_live_session(tmp_path):
|
||||
now = [1_000.0]
|
||||
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
|
||||
store.activate("phone-session", 3_000)
|
||||
store.activate("laptop-session", 3_000)
|
||||
|
||||
now[0] = 1_800.0
|
||||
assert store.touch("phone-session", 3_000, idle_timeout_seconds=900) is True
|
||||
now[0] = 2_000.0
|
||||
|
||||
assert store.status("phone-session", 3_000, idle_timeout_seconds=900) == "active"
|
||||
assert store.status("laptop-session", 3_000, idle_timeout_seconds=900) == "idle"
|
||||
assert store.touch("missing-session", 3_000, idle_timeout_seconds=900) is False
|
||||
|
||||
|
||||
def test_touch_cannot_revive_a_session_at_the_idle_boundary(tmp_path):
|
||||
now = [1_000.0]
|
||||
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
|
||||
store.activate("phone-session", 3_000)
|
||||
|
||||
now[0] = 1_900.0
|
||||
|
||||
assert store.touch("phone-session", 3_000, idle_timeout_seconds=900) is False
|
||||
assert store.status("phone-session", 3_000, idle_timeout_seconds=900) == "idle"
|
||||
|
||||
|
||||
def test_active_devices_are_listed_without_exposing_session_secrets(tmp_path):
|
||||
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0)
|
||||
store.activate("phone-session-secret", 2_000, device_label="Pixel 9")
|
||||
@@ -121,6 +158,25 @@ def test_existing_session_registry_migrates_without_invalidating_sessions(tmp_pa
|
||||
assert next(device for device in devices if device.current).device_label == "Existing device"
|
||||
|
||||
|
||||
def test_idle_status_migrates_existing_registry_and_starts_legacy_idle_clock_now(tmp_path):
|
||||
database = tmp_path / "sessions.sqlite3"
|
||||
digest = SessionStore._digest("existing-session")
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute(
|
||||
"CREATE TABLE active_sessions (session_hash TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)"
|
||||
)
|
||||
connection.execute("INSERT INTO active_sessions VALUES (?, ?)", (digest, 2_000))
|
||||
|
||||
store = SessionStore(database, clock=lambda: 1_000.0)
|
||||
|
||||
assert store.status("existing-session", 2_000, idle_timeout_seconds=900) == "active"
|
||||
with sqlite3.connect(database) as connection:
|
||||
last_active_at = connection.execute(
|
||||
"SELECT last_active_at FROM active_sessions WHERE session_hash = ?", (digest,)
|
||||
).fetchone()[0]
|
||||
assert last_active_at == 1_000
|
||||
|
||||
|
||||
def test_existing_session_can_mint_first_step_up_grant_during_schema_upgrade(tmp_path):
|
||||
database = tmp_path / "sessions.sqlite3"
|
||||
digest = SessionStore._digest("existing-session")
|
||||
|
||||
Reference in new issue
Block a user