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:
128
backend/tests/test_password_hashing.py
Normal file
128
backend/tests/test_password_hashing.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Проверка пароля не должна блокировать 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
|
||||
Reference in New Issue
Block a user