fix: отозванная cookie не оживает, когда логина нет в users

This commit is contained in:
Ivan Gerasimov 2026-09-27 15:35:20 +03:00
commit a2ce08f46a
2 changed files with 41 additions and 2 deletions

View file

@ -51,6 +51,8 @@ _INSTANCE = hashlib.sha256(
).hexdigest() ).hexdigest()
_generations: dict[str, int] = {} _generations: dict[str, int] = {}
_active_sockets: dict[str, weakref.WeakKeyDictionary] = {} _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_SYNC_SECONDS = 1.0
AUTH_GENERATION_MAX_AGE_SECONDS = 2.0 AUTH_GENERATION_MAX_AGE_SECONDS = 2.0
_generations_synced_at: float | None = None _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; # 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 # a DB watcher must not revoke its in-memory generation during test/demo
# flows that deliberately exercise account invalidation. # 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() 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) invalidate_login(login)
_generations.pop(login, None) _vanished.add(login)
global _generations_synced_at global _generations_synced_at
_generations_synced_at = time.monotonic() _generations_synced_at = time.monotonic()

View file

@ -272,6 +272,40 @@ def test_peer_node_generation_sync_closes_revoked_websocket(monkeypatch):
asyncio.run(run()) 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(): def test_revocation_does_not_log_error_if_socket_already_disconnected():
class DisconnectedSocket: class DisconnectedSocket:
async def close(self, **_kwargs): async def close(self, **_kwargs):