diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 0725e20..17af9ce 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -51,6 +51,8 @@ _INSTANCE = hashlib.sha256( ).hexdigest() _generations: dict[str, int] = {} _active_sockets: dict[str, weakref.WeakKeyDictionary] = {} +# Logins already revoked because their users row disappeared. +_vanished: set[str] = set() AUTH_GENERATION_SYNC_SECONDS = 1.0 AUTH_GENERATION_MAX_AGE_SECONDS = 2.0 _generations_synced_at: float | None = None @@ -121,10 +123,13 @@ async def sync_generations() -> None: # The local-only dev-token principal is synthetic, never stored in users; # a DB watcher must not revoke its in-memory generation during test/demo # flows that deliberately exercise account invalidation. + # The revoked generation stays cached: dropping it would make the next + # lookup fall back to 0 and re-accept cookies issued before the revocation. synthetic = {"dev"} if get_settings().dev_auth_bypass else set() - for login in _generations.keys() - current.keys() - synthetic: + _vanished.intersection_update(_generations.keys() - current.keys()) + for login in _generations.keys() - current.keys() - synthetic - _vanished: invalidate_login(login) - _generations.pop(login, None) + _vanished.add(login) global _generations_synced_at _generations_synced_at = time.monotonic() diff --git a/backend/tests/test_auth_hardening.py b/backend/tests/test_auth_hardening.py index 05f22cc..eec963a 100644 --- a/backend/tests/test_auth_hardening.py +++ b/backend/tests/test_auth_hardening.py @@ -272,6 +272,40 @@ def test_peer_node_generation_sync_closes_revoked_websocket(monkeypatch): asyncio.run(run()) +def test_generation_sync_keeps_revocation_of_login_missing_from_db(monkeypatch): + # Демо-логины синтетические, в users их нет. Сброс поколения к 0 после + # выхода вернул бы силу cookie, выданной до выхода. + login = "demo-instructor" + + class FakeResult: + def all(self): + return [] + + class FakeDb: + async def execute(self, _query): + return FakeResult() + + class FakeSession: + async def __aenter__(self): + return FakeDb() + + async def __aexit__(self, *_args): + return None + + monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession()) + auth.prime_generations({}) + who = auth._demo_principal(Role.INSTRUCTOR) + request = SimpleNamespace(session={}) + auth._issue_session(request, who) + stale = dict(request.session) + + auth.invalidate_login(login) + asyncio.run(auth.sync_generations()) + + assert auth._session_principal(stale) is None + auth._generations.pop(login, None) + + def test_revocation_does_not_log_error_if_socket_already_disconnected(): class DisconnectedSocket: async def close(self, **_kwargs):