refactor(auth): one account per browser session
The session held a wallet of up to six accounts, and a request picked one by a header or an ?account= parameter. It now holds one: user_id and its epoch. A sign-in replaces whoever was signed in, sign-out and account deletion end the session, and an account refused mid-session (revoked, suspended, deactivated) clears it. /api/me/accounts and /api/me/switch are gone, and with them their refusal codes and the UI copy for them. A cookie the multi-account build wrote carries no single epoch and is refused: its holder signs in once more. A Google link finishing in a browser now signed in as someone else is refused, as before for an account outside the wallet. A test holds that no app code reads the wallet keys or the account header again.
This commit is contained in:
@@ -1,21 +1,25 @@
|
||||
"""Auth / session security core (TEST-1), DB-lane.
|
||||
|
||||
The wallet impersonation gate, epoch-based session revocation, single-use tokens and the
|
||||
enumeration-safe uniform 401 are the security mag, and had zero tests (only `_oauth_redirect_uri` was
|
||||
covered). Starlette's SessionMiddleware round-trips the signed cookie under TestClient, so the whole
|
||||
browser-less flow is exercisable. Uses the real auth stack via the `client` fixture (NO current_user
|
||||
override) so login, the wallet gate and epoch revoke are the code under test.
|
||||
One account per browser session, epoch-based session revocation, single-use tokens and the
|
||||
enumeration-safe uniform 401 are the security mag. Starlette's SessionMiddleware round-trips the
|
||||
signed cookie under TestClient, so the whole browser-less flow is exercisable. Uses the real auth
|
||||
stack via the `client` fixture (NO current_user override) so login, the session and epoch revoke are
|
||||
the code under test.
|
||||
|
||||
Note: `/api/me` is the bootstrap probe — it returns 200 `{authenticated: False}` when logged out, not a
|
||||
401. A revoked session is therefore checked on a PROTECTED endpoint (`/api/feed`), which does 401.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from itsdangerous import TimestampSigner
|
||||
|
||||
from app.auth import ACTIVE_ACCOUNT_HEADER
|
||||
from app.config import settings
|
||||
from app.main import app
|
||||
|
||||
_PW = "correct horse battery"
|
||||
@@ -46,74 +50,48 @@ def test_wrong_password_and_unknown_email_give_a_byte_identical_401(client, make
|
||||
assert wrong_pw.json() == unknown.json()
|
||||
|
||||
|
||||
def test_a_non_wallet_account_header_falls_back_to_the_default(client, make_user):
|
||||
# The X-Siftlode-Account header may only re-target an account already signed into THIS browser
|
||||
# (in the session wallet). A header naming someone else must be ignored, not honored — otherwise
|
||||
# it's an impersonation gate hole.
|
||||
make_user(email="alice2@example.com", password=_PW)
|
||||
mallory = make_user(email="mallory@example.com", password=_PW)
|
||||
assert _login(client, "alice2@example.com", _PW).status_code == 200
|
||||
me = client.get("/api/me", headers={ACTIVE_ACCOUNT_HEADER: str(mallory.id)})
|
||||
assert me.status_code == 200
|
||||
assert me.json()["email"] == "alice2@example.com" # still Alice, not Mallory
|
||||
def _cookie(payload: dict) -> str:
|
||||
"""A session cookie signed the way SessionMiddleware signs one — for a cookie an older build wrote."""
|
||||
data = base64.b64encode(json.dumps(payload).encode())
|
||||
return TimestampSigner(str(settings.secret_key)).sign(data).decode()
|
||||
|
||||
|
||||
def test_a_tab_pinned_to_an_account_signed_out_elsewhere_acts_as_nobody(client, make_user):
|
||||
# Two accounts in one browser: a tab pinned to Alice, Bob the default. Alice signs out in another
|
||||
# tab. The pinned tab used to go on acting as the default, Bob — its player checkpoints and pref
|
||||
# saves landed in his account, and its own Sign out signed HIM out.
|
||||
alice = make_user(email="alice3@example.com", password=_PW)
|
||||
def test_signing_in_as_another_account_replaces_the_first(client, make_user):
|
||||
# One account per browser session: a second sign-in is a fresh session, not a second account
|
||||
# beside the first — so signing out leaves nobody signed in here.
|
||||
make_user(email="alice3@example.com", password=_PW)
|
||||
make_user(email="bob3@example.com", password=_PW)
|
||||
assert _login(client, "alice3@example.com", _PW).status_code == 200
|
||||
assert _login(client, "bob3@example.com", _PW).status_code == 200 # Bob is now the default
|
||||
tab = {ACTIVE_ACCOUNT_HEADER: str(alice.id)}
|
||||
assert client.get("/api/feed", headers=tab).status_code == 200
|
||||
assert client.post("/auth/logout", headers=tab).status_code == 200 # Alice signs out
|
||||
assert _login(client, "bob3@example.com", _PW).status_code == 200
|
||||
assert client.get("/api/me").json()["email"] == "bob3@example.com"
|
||||
|
||||
assert client.get("/api/feed", headers=tab).status_code == 401
|
||||
assert client.post("/auth/logout", headers=tab).status_code == 200
|
||||
assert client.get("/api/feed").status_code == 200 # Bob is still signed in, the session intact
|
||||
# The bootstrap probe still answers as the default: how the stale tab learns who it is now.
|
||||
assert client.get("/api/me", headers=tab).json()["email"] == "bob3@example.com"
|
||||
assert client.post("/auth/logout").status_code == 200
|
||||
assert client.get("/api/feed").status_code == 401
|
||||
assert client.get("/api/me").json() == {"authenticated": False}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ended", ["revoked", "suspended"])
|
||||
def test_a_tab_pinned_to_an_account_whose_session_ended_comes_back_as_the_default(
|
||||
client, make_user, db, ended
|
||||
):
|
||||
# A tab pinned to Alice, Bob the default. Alice's session then ends while she is still in this
|
||||
# browser's wallet ("log out everywhere" on another device, or an admin suspends her). The tab's
|
||||
# bootstrap probe used to answer "signed out": the landing page with Bob still signed in, and no way
|
||||
# off it but closing the tab (signing in as anyone else there kept the tab pinned to Alice).
|
||||
def test_no_header_or_parameter_picks_another_account(client, make_user):
|
||||
# Alice signed in here before Bob: nothing a request carries makes it act as her any more.
|
||||
alice = make_user(email="alice4@example.com", password=_PW)
|
||||
make_user(email="bob4@example.com", password=_PW)
|
||||
assert _login(client, "alice4@example.com", _PW).status_code == 200
|
||||
assert _login(client, "bob4@example.com", _PW).status_code == 200
|
||||
tab = {ACTIVE_ACCOUNT_HEADER: str(alice.id)}
|
||||
assert client.get("/api/feed", headers=tab).status_code == 200
|
||||
if ended == "revoked":
|
||||
alice.session_epoch = (alice.session_epoch or 0) + 1
|
||||
else:
|
||||
alice.is_suspended = True
|
||||
db.commit()
|
||||
me = client.get(f"/api/me?account={alice.id}", headers={"X-Siftlode-Account": str(alice.id)})
|
||||
assert me.json()["email"] == "bob4@example.com"
|
||||
|
||||
assert client.get("/api/me", headers=tab).json()["email"] == "bob4@example.com"
|
||||
assert client.get("/api/feed", headers=tab).status_code == 401 # still nobody, never Bob
|
||||
assert client.get("/api/feed").status_code == 200 # Bob's session is intact
|
||||
|
||||
@pytest.mark.parametrize("path", ["/api/me/accounts", "/api/me/switch"])
|
||||
def test_there_is_no_account_list_or_switch(client, make_user, path):
|
||||
make_user(email="alice5@example.com", password=_PW)
|
||||
assert _login(client, "alice5@example.com", _PW).status_code == 200
|
||||
r = client.get(path) if path.endswith("accounts") else client.post(path, json={"user_id": 1})
|
||||
assert r.status_code in (404, 405)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ended", ["revoked", "suspended", "deactivated"])
|
||||
def test_switching_to_an_account_whose_session_ended_refuses_it_and_drops_it_from_the_list(
|
||||
client, make_user, db, monkeypatch, ended
|
||||
):
|
||||
# The account switcher lists this browser's wallet. A revoked account was dropped from it on a refused
|
||||
# switch, but a suspended one stayed, refused on every click; a deactivated one was not refused at all —
|
||||
# made the default, the next request found it inactive and signed every account here out.
|
||||
monkeypatch.setattr("app.routes.me.is_allowed", lambda _db, _email: True) # both hold an invite
|
||||
alice = make_user(email="alice5@example.com", password=_PW)
|
||||
make_user(email="bob5@example.com", password=_PW)
|
||||
assert _login(client, "alice5@example.com", _PW).status_code == 200
|
||||
assert _login(client, "bob5@example.com", _PW).status_code == 200
|
||||
def test_a_session_whose_account_ended_signs_the_browser_out(client, make_user, db, ended):
|
||||
alice = make_user(email="alice6@example.com", password=_PW)
|
||||
assert _login(client, "alice6@example.com", _PW).status_code == 200
|
||||
if ended == "revoked":
|
||||
alice.session_epoch = (alice.session_epoch or 0) + 1
|
||||
elif ended == "suspended":
|
||||
@@ -121,10 +99,70 @@ def test_switching_to_an_account_whose_session_ended_refuses_it_and_drops_it_fro
|
||||
else:
|
||||
alice.is_active = False
|
||||
db.commit()
|
||||
assert client.get("/api/feed").status_code == 401
|
||||
assert "user_id" not in _session(client) # the refusal ended the session, not just this request
|
||||
assert client.get("/api/me").json() == {"authenticated": False}
|
||||
|
||||
assert client.post("/api/me/switch", json={"user_id": alice.id}).status_code == 403
|
||||
assert [a["email"] for a in client.get("/api/me/accounts").json()] == ["bob5@example.com"]
|
||||
assert client.get("/api/feed").status_code == 200 # Bob is still signed in
|
||||
|
||||
def test_a_cookie_from_the_multi_account_build_signs_in_again(client, make_user):
|
||||
# The old session held a wallet and one epoch per account. It is not read: whoever holds one
|
||||
# signs in once more.
|
||||
alice = make_user(email="alice7@example.com", password=_PW)
|
||||
old = {"user_id": alice.id, "account_ids": [alice.id], "epochs": {str(alice.id): 0}}
|
||||
client.cookies.set("session", _cookie(old))
|
||||
assert client.get("/api/feed").status_code == 401
|
||||
assert client.get("/api/me").json() == {"authenticated": False}
|
||||
|
||||
|
||||
def test_deleting_the_account_signs_the_browser_out(client, make_user):
|
||||
make_user(email="alice8@example.com", password=_PW)
|
||||
assert _login(client, "alice8@example.com", _PW).status_code == 200
|
||||
assert client.delete("/api/me/account").status_code == 200
|
||||
# The cookie names nobody now: the probe answers signed out without looking the account up.
|
||||
assert "user_id" not in _session(client)
|
||||
|
||||
|
||||
def _session(client) -> dict:
|
||||
"""The decoded payload of the client's session cookie."""
|
||||
raw = client.cookies.get("session")
|
||||
if not raw:
|
||||
return {}
|
||||
data = TimestampSigner(str(settings.secret_key)).unsign(raw.encode())
|
||||
return json.loads(base64.b64decode(data))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("signed_in_as", ["starter", "someone_else"])
|
||||
def test_a_google_link_lands_only_on_the_account_still_signed_in(db, make_user, signed_in_as):
|
||||
# The link marker names who started it; a sign-in as someone else before the callback must not
|
||||
# get that Google identity attached to the account that started it from the other person's session.
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.routes.auth import _complete_link
|
||||
|
||||
starter, other = make_user(), make_user()
|
||||
session = {"user_id": starter.id if signed_in_as == "starter" else other.id}
|
||||
request = Request({"type": "http", "session": session, "headers": []})
|
||||
resp = _complete_link(request, db, starter.id, True, "sub-123", {"email": ""}, {"scope": ""})
|
||||
db.refresh(starter)
|
||||
if signed_in_as == "starter":
|
||||
assert resp.headers["location"] == "/?link=ok"
|
||||
assert starter.google_sub == "sub-123"
|
||||
else:
|
||||
assert resp.headers["location"] == "/?link=error"
|
||||
assert starter.google_sub is None
|
||||
|
||||
|
||||
def test_no_app_code_reads_a_second_account():
|
||||
# The multi-account machinery is gone; a module reading its session keys or its header again
|
||||
# would bring back a session that names more than one account.
|
||||
app_dir = Path(__file__).parent.parent / "app"
|
||||
hits = [
|
||||
f"{path.relative_to(app_dir)}: {word}"
|
||||
for path in app_dir.rglob("*.py")
|
||||
for word in ("account_ids", '"epochs"', "x-siftlode-account", 'query_params.get("account")')
|
||||
if word in path.read_text(encoding="utf-8").lower()
|
||||
]
|
||||
assert hits == []
|
||||
|
||||
|
||||
def test_logout_others_revokes_the_other_session_but_keeps_the_caller(client, make_user):
|
||||
|
||||
Reference in New Issue
Block a user