Keep authentication database writes off the event loop #312
10
src/main.py
10
src/main.py
|
|
@ -586,7 +586,7 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
|
|||
)
|
||||
attempts = _login_attempt_store()
|
||||
try:
|
||||
retry_after = attempts.retry_after(source)
|
||||
retry_after = await asyncio.to_thread(attempts.retry_after, source)
|
||||
except LoginAttemptStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Sign-in throttling is temporarily unavailable"},
|
||||
|
|
@ -607,7 +607,7 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
|
|||
payload.access_token, configured_token
|
||||
):
|
||||
try:
|
||||
attempts.record_failure(source)
|
||||
await asyncio.to_thread(attempts.record_failure, source)
|
||||
except LoginAttemptStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Sign-in throttling is temporarily unavailable"},
|
||||
|
|
@ -616,7 +616,7 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
|
|||
)
|
||||
raise HTTPException(status_code=401, detail="Invalid access token")
|
||||
try:
|
||||
attempts.clear(source)
|
||||
await asyncio.to_thread(attempts.clear, source)
|
||||
except LoginAttemptStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Sign-in throttling is temporarily unavailable"},
|
||||
|
|
@ -624,7 +624,7 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
|
|||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
try:
|
||||
signed, session = dashboard_auth.issue_session()
|
||||
signed, session = await asyncio.to_thread(dashboard_auth.issue_session)
|
||||
except dashboard_auth.SessionStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Session registry is temporarily unavailable"},
|
||||
|
|
@ -668,7 +668,7 @@ async def session_status(request: Request):
|
|||
async def sign_out(request: Request, response: Response):
|
||||
session = request.state.dashboard_session
|
||||
try:
|
||||
dashboard_auth.revoke_session(session)
|
||||
await asyncio.to_thread(dashboard_auth.revoke_session, session)
|
||||
except dashboard_auth.SessionStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Session registry is temporarily unavailable"},
|
||||
|
|
|
|||
|
|
@ -430,6 +430,171 @@ async def test_session_registry_latency_does_not_block_the_event_loop(access_con
|
|||
assert heartbeat_elapsed < 0.08
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sign_in_throttle_lookup_does_not_block_the_event_loop(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
class SlowAttempts:
|
||||
def retry_after(self, source):
|
||||
time.sleep(0.15)
|
||||
return 17
|
||||
|
||||
monkeypatch.setattr(main, "_login_attempt_store", lambda: SlowAttempts())
|
||||
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.10", 1234))
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
sign_in = asyncio.create_task(
|
||||
client.post("/api/v1/session", json={"access_token": "wrong"})
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
started = time.perf_counter()
|
||||
await asyncio.sleep(0.01)
|
||||
heartbeat_elapsed = time.perf_counter() - started
|
||||
response = await sign_in
|
||||
|
||||
assert response.status_code == 429
|
||||
assert response.headers["retry-after"] == "17"
|
||||
assert heartbeat_elapsed < 0.08
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_failed_sign_in_recording_does_not_block_the_event_loop(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
class SlowAttempts:
|
||||
def retry_after(self, source):
|
||||
time.sleep(0.02)
|
||||
return 0
|
||||
|
||||
def record_failure(self, source):
|
||||
time.sleep(0.15)
|
||||
|
||||
monkeypatch.setattr(main, "_login_attempt_store", lambda: SlowAttempts())
|
||||
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.11", 1234))
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
sign_in = asyncio.create_task(
|
||||
client.post("/api/v1/session", json={"access_token": "wrong"})
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
started = time.perf_counter()
|
||||
await asyncio.sleep(0.04)
|
||||
heartbeat_elapsed = time.perf_counter() - started
|
||||
response = await sign_in
|
||||
|
||||
assert response.status_code == 401
|
||||
assert heartbeat_elapsed < 0.08
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_successful_sign_in_throttle_clear_does_not_block_the_event_loop(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
class SlowAttempts:
|
||||
def retry_after(self, source):
|
||||
time.sleep(0.02)
|
||||
return 0
|
||||
|
||||
def clear(self, source):
|
||||
time.sleep(0.15)
|
||||
|
||||
monkeypatch.setattr(main, "_login_attempt_store", lambda: SlowAttempts())
|
||||
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.12", 1234))
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
sign_in = asyncio.create_task(
|
||||
client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple"},
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
started = time.perf_counter()
|
||||
await asyncio.sleep(0.04)
|
||||
heartbeat_elapsed = time.perf_counter() - started
|
||||
response = await sign_in
|
||||
|
||||
assert response.status_code == 200
|
||||
assert heartbeat_elapsed < 0.08
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_session_activation_does_not_block_the_event_loop(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
class Attempts:
|
||||
def retry_after(self, source):
|
||||
time.sleep(0.02)
|
||||
return 0
|
||||
|
||||
def clear(self, source):
|
||||
return None
|
||||
|
||||
class SlowStore:
|
||||
def activate(self, session_id, expires_at):
|
||||
time.sleep(0.15)
|
||||
|
||||
monkeypatch.setattr(main, "_login_attempt_store", lambda: Attempts())
|
||||
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: SlowStore())
|
||||
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.13", 1234))
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
sign_in = asyncio.create_task(
|
||||
client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple"},
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
started = time.perf_counter()
|
||||
await asyncio.sleep(0.04)
|
||||
heartbeat_elapsed = time.perf_counter() - started
|
||||
response = await sign_in
|
||||
|
||||
assert response.status_code == 200
|
||||
assert any(
|
||||
"stackchain_session=" in value
|
||||
for value in response.headers.get_list("set-cookie")
|
||||
)
|
||||
assert heartbeat_elapsed < 0.08
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_single_session_revocation_does_not_block_the_event_loop(
|
||||
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 SlowStore:
|
||||
def is_active(self, session_id, expires_at):
|
||||
time.sleep(0.02)
|
||||
return True
|
||||
|
||||
def revoke(self, session_id):
|
||||
time.sleep(0.15)
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.dashboard_auth, "_session_store", lambda now=None: SlowStore()
|
||||
)
|
||||
sign_out = asyncio.create_task(
|
||||
client.delete(
|
||||
"/api/v1/session",
|
||||
headers={"Origin": "https://test", "X-CSRF-Token": csrf},
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
started = time.perf_counter()
|
||||
await asyncio.sleep(0.04)
|
||||
heartbeat_elapsed = time.perf_counter() - started
|
||||
response = await sign_out
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["authenticated"] is False
|
||||
assert heartbeat_elapsed < 0.08
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_session_registry_read_failure_fails_closed_before_gitea(
|
||||
access_control, monkeypatch
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user