feat: sign out all operator sessions (#287)
This commit is contained in:
1 parent
94cbde3cf9
commit
37d55e9b20
9 files changed
+195
-9
No files matched your search
@@ -276,6 +276,92 @@ async def test_logout_clears_session_and_blocks_private_routes(access_control):
|
||||
assert all("Max-Age=0" in value for value in response.headers.get_list("set-cookie"))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sign_out_all_devices_revokes_every_existing_session(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with (
|
||||
httpx.AsyncClient(transport=transport, base_url="https://test") as phone,
|
||||
httpx.AsyncClient(transport=transport, base_url="https://test") as laptop,
|
||||
):
|
||||
await phone.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
await laptop.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
|
||||
response = await phone.delete(
|
||||
"/api/v1/sessions",
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": phone.cookies["stackchain_csrf"],
|
||||
},
|
||||
)
|
||||
phone_private = await phone.get("/api/v1/background-identity")
|
||||
laptop_private = await laptop.get("/api/v1/background-identity")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"authenticated": False,
|
||||
"all_sessions_revoked": True,
|
||||
"clear_private_device_data": True,
|
||||
}
|
||||
assert phone_private.status_code == 401
|
||||
assert laptop_private.status_code == 401
|
||||
assert all("Max-Age=0" in value for value in response.headers.get_list("set-cookie"))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sign_out_all_devices_rejects_cross_site_requests_without_revoking(access_control):
|
||||
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"}
|
||||
)
|
||||
response = await client.delete(
|
||||
"/api/v1/sessions",
|
||||
headers={
|
||||
"Origin": "https://evil.example",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
},
|
||||
)
|
||||
session = await client.get("/api/v1/session")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.headers.get_list("set-cookie") == []
|
||||
assert session.status_code == 200
|
||||
assert session.json()["authenticated"] is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sign_out_all_devices_registry_failure_sets_no_cookies(access_control, monkeypatch):
|
||||
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"}
|
||||
)
|
||||
csrf = client.cookies["stackchain_csrf"]
|
||||
|
||||
class BrokenStore:
|
||||
def is_active(self, session_id, expires_at):
|
||||
return True
|
||||
|
||||
def revoke_all(self):
|
||||
raise SessionStoreError("database path and secret details")
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
|
||||
response = await client.delete(
|
||||
"/api/v1/sessions",
|
||||
headers={"Origin": "https://test", "X-CSRF-Token": csrf},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {"detail": "Session registry is temporarily unavailable"}
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.headers.get_list("set-cookie") == []
|
||||
assert "database path" not in response.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_logout_revokes_a_captured_cookie_before_gitea(access_control, monkeypatch):
|
||||
calls = 0
|
||||
|
||||
@@ -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: [], deletedCaches: [], assigned: '', workerMessages: [] }};
|
||||
const state = {{ requests: [], removed: [], deletedDatabases: [], deletedCaches: [], assigned: '', workerMessages: [], confirmations: [] }};
|
||||
const storage = {{
|
||||
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
|
||||
get length() {{ return this.values.size; }},
|
||||
@@ -35,6 +35,7 @@ const boundary = createSessionBoundary({{
|
||||
caches: {{ keys: async () => ['stackchain-dashboard-shell-v15', 'gitea-assets'], delete: async key => {{ state.deletedCaches.push(key); }} }},
|
||||
serviceWorker: {{ ready: Promise.resolve({{ active: {{ postMessage: message => state.workerMessages.push(message) }} }}) }},
|
||||
location: {{ assign: value => {{ state.assigned = value; }} }},
|
||||
confirmAction: message => {{ state.confirmations.push(message); return true; }},
|
||||
}});
|
||||
(async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
@@ -73,6 +74,26 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["assigned"] == "/dashboard/login"
|
||||
|
||||
|
||||
def test_sign_out_all_devices_requires_confirmation_and_uses_global_endpoint():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
await boundary.signOutAllDevices();
|
||||
state.remaining = Array.from(storage.values.keys());
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["confirmations"] == [
|
||||
"Sign out every device? You will need to sign in again everywhere."
|
||||
]
|
||||
request = result["requests"][0]
|
||||
assert request["url"] == "/dashboard/api/v1/sessions"
|
||||
assert request["method"] == "DELETE"
|
||||
assert request["headers"]["x-csrf-token"] == "csrf-proof"
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["assigned"] == "/dashboard/login"
|
||||
|
||||
|
||||
def test_authenticated_dashboard_load_requests_queued_delivery_resume():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
@@ -91,3 +112,5 @@ async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
|
||||
assert '<script src="static/session.js"></script>' in html
|
||||
assert html.index('static/session.js') < html.index('static/markdown.js')
|
||||
assert '<button id="sign-out" type="button">Sign out & clear this device</button>' in html
|
||||
assert '<button id="sign-out-all" type="button">Sign out all devices</button>' in html
|
||||
assert '#sign-out-all { min-height:44px; }' in html
|
||||
@@ -22,6 +22,22 @@ def test_revocation_is_durable_and_scoped_to_one_session(tmp_path):
|
||||
assert b"second-session-secret" not in database.read_bytes()
|
||||
|
||||
|
||||
def test_revoke_all_is_durable_and_does_not_block_future_sessions(tmp_path):
|
||||
now = [1_000.0]
|
||||
database = tmp_path / "sessions.sqlite3"
|
||||
first = SessionStore(database, clock=lambda: now[0])
|
||||
first.activate("phone-session", 2_000)
|
||||
first.activate("laptop-session", 2_000)
|
||||
|
||||
first.revoke_all()
|
||||
|
||||
reconstructed = SessionStore(database, clock=lambda: now[0])
|
||||
assert reconstructed.is_active("phone-session", 2_000) is False
|
||||
assert reconstructed.is_active("laptop-session", 2_000) is False
|
||||
reconstructed.activate("new-session", 2_000)
|
||||
assert reconstructed.is_active("new-session", 2_000) is True
|
||||
|
||||
|
||||
def test_validation_does_not_create_a_missing_registry(tmp_path):
|
||||
database = tmp_path / "sessions.sqlite3"
|
||||
store = SessionStore(database, clock=lambda: 1_000.0)
|
||||
|
||||
Reference in new issue
Block a user