fix(auth): проверка пароля больше не блокирует весь backend

На нагрузочном тесте 31.07.2026 около 70 человек заходили одновременно.
Вход развалился: p95 `/api/v1/auth/token` — 7.28 с, p95 `guest-join` —
7.06 с, в БД 33 соединения `idle in transaction` при ОДНОМ активном
запросе. Люди попадали внутрь с пятой-десятой попытки, часть не попала
вовсе. Медиа при этом работало штатно: 30 участников с 27 камерами в
следующем окне прошли без единого лага.

Причина — argon2 считался синхронно внутри async-обработчика. Замер на
боевом сервере: 95–155 мс на одну проверку, и всё это время event loop
процесса стоит целиком. Транзакция БД к тому моменту уже открыта
(`get_by_email` сделал SELECT), поэтому соединение висело без работы, пул
из 40 выбирался, и отказы получали совершенно посторонние ручки — включая
вход в конференцию, где никакого пароля не проверялось.

Что изменилось:
- `hash_password`/`verify_password` стали асинхронными и считаются в пуле
  потоков (`asyncio.to_thread`). argon2-cffi освобождает GIL, поэтому
  проверки идут по-настоящему параллельно;
- параметры argon2id заменены с дефолтов библиотеки (t=3, m=64 МБ, p=4) на
  рекомендацию OWASP (t=2, m=19 МБ, p=1): 95 мс → 42 мс. Отдельно важен
  `parallelism`: при p=4 одна проверка пароля занимала все четыре ядра
  сервера — те же, на которых работает LiveKit;
- добавлен `needs_rehash`: существующие хэши проверяются как прежде
  (параметры зашиты в саму строку) и лениво перевыпускаются при первом
  успешном входе.

Расчёт по замерам: пачка из 70 логинов — 6.7–10.9 с блокировки против
~0.36 с без неё.

Тесты: event loop продолжает тикать во время проверки; 8 параллельных
проверок укладываются заметно быстрее восьми последовательных; хэш со
старыми параметрами принимается и перевыпускается при входе.
This commit is contained in:
2026-08-01 23:19:52 +03:00
parent e5c596f2bf
commit 84b7f807f7
20 changed files with 258 additions and 32 deletions

View File

@@ -24,7 +24,7 @@ async def _make_user(session: AsyncSession) -> User:
user = User(
email=f"{uuid.uuid4()}@example.com",
name_user="List Tester",
password_hash=hash_password("password123"),
password_hash=await hash_password("password123"),
email_verified=True,
)
session.add(user)
@@ -98,7 +98,7 @@ async def test_get_me_with_reserved_tld_email_does_not_500(
user = User(
email=legacy_email,
name_user="Legacy Admin",
password_hash=hash_password("password123"),
password_hash=await hash_password("password123"),
email_verified=True,
role="admin",
)
@@ -380,7 +380,7 @@ async def test_list_users_search_by_q_filters_by_name_or_email(
match = User(
email=f"{unique_marker}@example.com",
name_user=f"Findable {unique_marker}",
password_hash=hash_password("password123"),
password_hash=await hash_password("password123"),
email_verified=True,
)
db_session.add(match)