diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index bb25c65..291350c 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -166,6 +166,12 @@ async def _resolve_unknown_login(login: str) -> int | None: except Exception as exc: # noqa: BLE001 — fail closed, not FORBIDDEN log.error("разовая проверка полномочий не удалась (%s)", type(exc).__name__) raise _AuthStateUnavailable from exc + # A concurrent local revoke or sync tick may have written a newer + # generation while the SELECT above was in flight; the stale read + # must never clobber it back to an older, already-revoked epoch. + cached = _generations.get(login) + if cached is not None: + return cached if version is None: return None _generations[login] = version diff --git a/backend/tests/test_auth_hardening.py b/backend/tests/test_auth_hardening.py index 00cd8bf..d39c759 100644 --- a/backend/tests/test_auth_hardening.py +++ b/backend/tests/test_auth_hardening.py @@ -527,6 +527,45 @@ def test_middleware_rejects_login_missing_from_users_via_one_shot_query(monkeypa auth._lookup_locks.pop(login, None) +def test_unknown_login_lookup_does_not_clobber_a_newer_local_revocation(monkeypatch): + """A stale SELECT reply landing after a concurrent local revoke must not + resurrect the revoked cookie's epoch (regression: lock only deduplicates + concurrent lookups, it does not order a lookup against a write).""" + login = "peer-node-race-login" + started = asyncio.Event() + resume = asyncio.Event() + + class FakeDb: + async def scalar(self, _query): + started.set() + await resume.wait() + return 0 # the epoch as it stood before the concurrent revoke below + + class FakeSession: + async def __aenter__(self): + return FakeDb() + + async def __aexit__(self, *_args): + return None + + async def run(): + auth.prime_generations({}) + task = asyncio.ensure_future(auth._resolve_unknown_login(login)) + await started.wait() # the SELECT is in flight, holding the login's lock + auth.invalidate_login(login, 1) # a local logout/edit races the reply + resume.set() + result = await task + assert result == 1, "the newer local revocation must win over the stale SELECT reply" + assert auth._generations[login] == 1 + + monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession()) + try: + asyncio.run(run()) + finally: + auth._generations.pop(login, None) + auth._lookup_locks.pop(login, None) + + def test_unknown_login_lookup_deduplicates_concurrent_requests_into_one_select(monkeypatch): login = "peer-node-concurrent-login" calls = []