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

@@ -0,0 +1,128 @@
"""Проверка пароля не должна блокировать event loop (регресс после теста 31.07.2026).
Синхронный `verify_password` останавливал весь процесс backend на 95155 мс.
При массовом входе (около 70 человек разом) это давало p95 логина 7.28 секунды,
33 соединения к БД в состоянии `idle in transaction` при одном активном запросе
и отказы на посторонних ручках — включая вход в конференцию, где пароль вообще
не проверялся. Разбор — `.forcc/LOGIN-BOTTLENECK.md`.
Тесты ниже проверяют не скорость (она зависит от железа), а **свойства**:
event loop остаётся живым, проверки идут параллельно, старые хэши мигрируют.
"""
import asyncio
import time
from argon2 import PasswordHasher
from core.security import hash_password, needs_rehash, verify_password
PASSWORD = "correct-horse-battery-staple"
async def test_verify_password_does_not_block_event_loop() -> None:
"""Пока считается argon2, event loop продолжает обслуживать другие задачи.
Это главное свойство правки. Фоновая корутина тикает каждую миллисекунду;
если проверка пароля выполняется синхронно в loop, тиков за её время будет
ноль или единицы — именно так и вело себя приложение до исправления.
"""
password_hash = await hash_password(PASSWORD)
ticks = 0
stop = False
async def ticker() -> None:
nonlocal ticks
while not stop:
ticks += 1
await asyncio.sleep(0.001)
ticker_task = asyncio.create_task(ticker())
await asyncio.sleep(0.005) # даём тикеру стартовать
ticks_before = ticks
assert await verify_password(PASSWORD, password_hash) is True
ticks_during = ticks - ticks_before
stop = True
await ticker_task
# Даже на быстром железе argon2 занимает десятки миллисекунд — за это время
# loop обязан прокрутить заметное число тиков. Порог намеренно щадящий:
# при блокировке тиков будет 01, а не «мало».
assert ticks_during >= 5, (
f"event loop простоял во время проверки пароля: {ticks_during} тиков — "
"похоже, argon2 снова считается синхронно"
)
async def test_parallel_verifications_are_concurrent() -> None:
"""Параллельные проверки идут одновременно, а не выстраиваются в очередь.
argon2-cffi — C-расширение и освобождает GIL, поэтому пул потоков даёт
настоящий параллелизм. Проверяем, что 8 проверок занимают заметно меньше,
чем 8 последовательных: иначе массовый вход снова упрётся в сериализацию.
"""
password_hash = await hash_password(PASSWORD)
start = time.perf_counter()
await verify_password(PASSWORD, password_hash)
single = time.perf_counter() - start
start = time.perf_counter()
results = await asyncio.gather(*(verify_password(PASSWORD, password_hash) for _ in range(8)))
parallel = time.perf_counter() - start
assert all(results)
# На 4-ядерном сервере 8 проверок идеально легли бы в 2×single; берём 5×
# с большим запасом на шум CI и разное железо — важно лишь то, что это
# НЕ 8× (последовательное выполнение).
assert parallel < single * 5, (
f"8 параллельных проверок заняли {parallel:.3f} с при {single:.3f} с на одну — "
"похоже, они выполняются последовательно"
)
async def test_wrong_password_is_rejected() -> None:
"""Асинхронная обёртка не сломала саму проверку."""
password_hash = await hash_password(PASSWORD)
assert await verify_password(PASSWORD, password_hash) is True
assert await verify_password("wrong-password", password_hash) is False
async def test_hasher_uses_owasp_parameters() -> None:
"""Параметры argon2id — по рекомендации OWASP, а не дефолт библиотеки.
Дефолт argon2-cffi (t=3, m=64 МБ, p=4) стоил 95 мс на проверку, причём
`parallelism=4` занимал все четыре ядра сервера — те же, на которых
работает LiveKit.
"""
password_hash = await hash_password(PASSWORD)
# Параметры зашиты в саму строку хэша: $argon2id$v=19$m=19456,t=2,p=1$...
assert "m=19456" in password_hash
assert "t=2" in password_hash
assert "p=1" in password_hash
async def test_legacy_hash_is_verified_and_marked_for_rehash() -> None:
"""Хэш со старыми параметрами проверяется, но помечается на перевыпуск.
Гарантия обратной совместимости: пароли, выданные до смены параметров,
продолжают работать. `AuthService.login` перевыпускает такой хэш при
первом же успешном входе — другого момента, когда открытый пароль есть
на руках, не будет.
"""
legacy_hasher = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4)
legacy_hash = legacy_hasher.hash(PASSWORD)
assert await verify_password(PASSWORD, legacy_hash) is True
assert needs_rehash(legacy_hash) is True
fresh_hash = await hash_password(PASSWORD)
assert needs_rehash(fresh_hash) is False
async def test_broken_hash_is_marked_for_rehash() -> None:
"""Мусор вместо хэша не роняет вход, а помечается на замену."""
assert needs_rehash("not-a-valid-argon2-hash") is True