На нагрузочном тесте 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 параллельных проверок укладываются заметно быстрее восьми последовательных; хэш со старыми параметрами принимается и перевыпускается при входе.
129 lines
6.6 KiB
Python
129 lines
6.6 KiB
Python
"""Проверка пароля не должна блокировать event loop (регресс после теста 31.07.2026).
|
||
|
||
Синхронный `verify_password` останавливал весь процесс backend на 95–155 мс.
|
||
При массовом входе (около 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 обязан прокрутить заметное число тиков. Порог намеренно щадящий:
|
||
# при блокировке тиков будет 0–1, а не «мало».
|
||
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
|