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:
@@ -24,6 +24,7 @@ from core.security import (
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
hash_password,
|
||||
needs_rehash,
|
||||
verify_password,
|
||||
)
|
||||
from models.email_verification import EmailVerificationToken
|
||||
@@ -131,7 +132,7 @@ class AuthService:
|
||||
user = await self._users.create(
|
||||
email=email,
|
||||
name_user=name_user,
|
||||
password_hash=hash_password(password),
|
||||
password_hash=await hash_password(password),
|
||||
team_id=team_id,
|
||||
)
|
||||
reply_to = cfg.contact_email if cfg.contact_email_enabled else None
|
||||
@@ -161,10 +162,21 @@ class AuthService:
|
||||
async def login(self, *, email: str, password: str) -> TokenPair:
|
||||
"""Проверить учётные данные и выдать пару access/refresh токенов."""
|
||||
user = await self._users.get_by_email(email)
|
||||
if user is None or not verify_password(password, user.password_hash):
|
||||
if user is None or not await verify_password(password, user.password_hash):
|
||||
raise InvalidCredentialsError
|
||||
if not user.email_verified:
|
||||
raise EmailNotVerifiedError
|
||||
|
||||
# Постепенная миграция на актуальные параметры argon2 (см. core/security.py):
|
||||
# параметры зашиты в саму строку хэша, поэтому старые записи так и
|
||||
# проверялись бы вдвое дольше. Открытый пароль есть только здесь и
|
||||
# только сейчас — другого места для перевыпуска не будет.
|
||||
if needs_rehash(user.password_hash):
|
||||
user.password_hash = await hash_password(password)
|
||||
# Явный commit: выдача токенов идёт через Redis и БД не трогает,
|
||||
# поэтому без него перевыпущенный хэш откатился бы вместе с сессией.
|
||||
await self._session.commit()
|
||||
|
||||
return await self._issue_token_pair(user.id, user.role)
|
||||
|
||||
async def refresh(self, refresh_token: str) -> TokenPair:
|
||||
|
||||
@@ -27,7 +27,7 @@ class InvalidPasswordError(Exception):
|
||||
"""Указанный пароль не совпадает с паролем закрытой конференции."""
|
||||
|
||||
|
||||
def ensure_joinable(conference: Conference, *, password: str | None) -> None:
|
||||
async def ensure_joinable(conference: Conference, *, password: str | None) -> None:
|
||||
"""Проверить, что в конференцию можно войти прямо сейчас.
|
||||
|
||||
Бросает `ConferenceEndedError` для терминального статуса `ended`
|
||||
@@ -42,7 +42,7 @@ def ensure_joinable(conference: Conference, *, password: str | None) -> None:
|
||||
return
|
||||
if conference.password_hash is None or password is None:
|
||||
raise PasswordRequiredError
|
||||
if not verify_password(password, conference.password_hash):
|
||||
if not await verify_password(password, conference.password_hash):
|
||||
raise InvalidPasswordError
|
||||
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ class ConferenceService:
|
||||
повторением без явного `scheduled_at` — плановая конференция,
|
||||
ожидающая своего первого вхождения, а не мгновенный вход.
|
||||
"""
|
||||
password_hash = hash_password(data.password) if data.password else None
|
||||
password_hash = await hash_password(data.password) if data.password else None
|
||||
is_instant = data.scheduled_at is None and data.recurrence is None
|
||||
conference_status = "active" if is_instant else "scheduled"
|
||||
recurrence_json = data.recurrence.model_dump(mode="json") if data.recurrence else None
|
||||
@@ -242,7 +242,7 @@ class ConferenceService:
|
||||
) -> JoinOut:
|
||||
"""Войти в конференцию зарегистрированным пользователем."""
|
||||
conference = await self._get_or_raise(conference_id)
|
||||
ensure_joinable(conference, password=password)
|
||||
await ensure_joinable(conference, password=password)
|
||||
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled
|
||||
return build_join(
|
||||
conference,
|
||||
@@ -256,7 +256,7 @@ class ConferenceService:
|
||||
async def join_as_guest(self, conference_id: uuid.UUID, *, data: GuestJoinIn) -> JoinOut:
|
||||
"""Войти в конференцию гостем: создать `GuestAccess` и выдать токен."""
|
||||
conference = await self._get_or_raise(conference_id)
|
||||
ensure_joinable(conference, password=data.password)
|
||||
await ensure_joinable(conference, password=data.password)
|
||||
|
||||
guest = GuestAccess(
|
||||
conference_id=conference.id, display_name=data.display_name, email=data.email
|
||||
@@ -330,7 +330,7 @@ class ConferenceService:
|
||||
if data.is_closed is not None:
|
||||
conference.is_closed = data.is_closed
|
||||
if data.password is not None:
|
||||
conference.password_hash = hash_password(data.password)
|
||||
conference.password_hash = await hash_password(data.password)
|
||||
if "summary_recipients" in data.model_fields_set:
|
||||
# Явная передача (в т.ч. `null`) — сбросить/установить
|
||||
# переопределение; отсутствие поля в запросе значение не трогает.
|
||||
|
||||
Reference in New Issue
Block a user