На нагрузочном тесте 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 параллельных проверок укладываются заметно быстрее восьми последовательных; хэш со старыми параметрами принимается и перевыпускается при входе.
124 lines
5.9 KiB
Python
124 lines
5.9 KiB
Python
"""Хэширование паролей (argon2) и выпуск/проверка JWT (access + refresh)."""
|
||
|
||
import asyncio
|
||
import uuid
|
||
from datetime import UTC, datetime, timedelta
|
||
from typing import Any
|
||
|
||
import jwt
|
||
from argon2 import PasswordHasher
|
||
from argon2.exceptions import InvalidHashError, VerifyMismatchError
|
||
|
||
from core.config import get_settings
|
||
|
||
JWT_ALGORITHM = "HS256"
|
||
|
||
# Параметры argon2id по рекомендации OWASP (Password Storage Cheat Sheet):
|
||
# t=2, m=19 МБ, p=1. Раньше использовались дефолты argon2-cffi
|
||
# (t=3, m=64 МБ, p=4) — это был не выбор, а «что было в коробке».
|
||
#
|
||
# Замер на боевом сервере (4 ядра): 95 мс против 42 мс на одну проверку.
|
||
# Отдельно важен `parallelism`: при p=4 ОДНА проверка пароля занимала все
|
||
# четыре ядра, конкурируя с LiveKit за то же железо ровно в момент, когда
|
||
# люди массово заходят в конференцию.
|
||
#
|
||
# Существующие хэши не ломаются: параметры хранятся внутри самой строки хэша
|
||
# и читаются при verify. Старые хэши перевыпускаются постепенно — см.
|
||
# `needs_rehash` и его использование при успешном входе.
|
||
_hasher = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)
|
||
|
||
|
||
def _hash_password_sync(password: str) -> str:
|
||
return _hasher.hash(password)
|
||
|
||
|
||
def _verify_password_sync(password: str, password_hash: str) -> bool:
|
||
try:
|
||
return _hasher.verify(password_hash, password)
|
||
except VerifyMismatchError:
|
||
return False
|
||
|
||
|
||
async def hash_password(password: str) -> str:
|
||
"""Захэшировать пароль алгоритмом argon2 для хранения в БД.
|
||
|
||
Считается в отдельном потоке — argon2 это CPU-bound работа на десятки
|
||
миллисекунд, и в event loop ей не место (см. `verify_password`).
|
||
"""
|
||
return await asyncio.to_thread(_hash_password_sync, password)
|
||
|
||
|
||
async def verify_password(password: str, password_hash: str) -> bool:
|
||
"""Сверить пароль с сохранённым argon2-хэшем; пароль/хэш никогда не логируются.
|
||
|
||
Выполняется в пуле потоков, а не в event loop. Причина — нагрузочный тест
|
||
31.07.2026: синхронный вызов останавливал весь процесс на 95–155 мс, и при
|
||
массовом входе (около 70 человек разом) это давало p95 логина 7.28 секунды,
|
||
33 соединения к БД в состоянии `idle in transaction` при одном активном
|
||
запросе и отказы на совершенно посторонних ручках — включая вход в
|
||
конференцию, где никакого пароля не проверялось.
|
||
|
||
Потоки здесь работают по-настоящему параллельно: argon2-cffi — это
|
||
C-расширение, освобождающее GIL на время вычисления.
|
||
"""
|
||
return await asyncio.to_thread(_verify_password_sync, password, password_hash)
|
||
|
||
|
||
def needs_rehash(password_hash: str) -> bool:
|
||
"""Проверить, что хэш выпущен устаревшими параметрами argon2.
|
||
|
||
Дешёвая операция: разбор строки хэша, без вычислений. Вызывается после
|
||
успешной проверки пароля — только тогда у нас на руках открытый пароль,
|
||
которым можно перевыпустить хэш.
|
||
|
||
Невалидную строку считаем требующей перевыпуска: если в базе оказался
|
||
мусор, лучше заменить его корректным хэшем, чем падать при каждом входе.
|
||
"""
|
||
try:
|
||
return _hasher.check_needs_rehash(password_hash)
|
||
except InvalidHashError:
|
||
return True
|
||
|
||
|
||
def create_access_token(user_id: uuid.UUID, role: str) -> str:
|
||
"""Выпустить access-токен: `sub`=user_id, `role`=роль, TTL из настроек."""
|
||
settings = get_settings()
|
||
now = datetime.now(UTC)
|
||
payload = {
|
||
"sub": str(user_id),
|
||
"role": role,
|
||
"type": "access",
|
||
"iat": now,
|
||
"exp": now + timedelta(minutes=settings.access_token_ttl_minutes),
|
||
}
|
||
return jwt.encode(payload, settings.jwt_secret, algorithm=JWT_ALGORITHM)
|
||
|
||
|
||
def create_refresh_token(user_id: uuid.UUID) -> tuple[str, str]:
|
||
"""Выпустить refresh-токен с уникальным `jti`.
|
||
|
||
Возвращает пару (token, jti); сохранение jti в Redis — ответственность
|
||
вызывающего кода (`services.auth.AuthService`).
|
||
"""
|
||
settings = get_settings()
|
||
jti = str(uuid.uuid4())
|
||
now = datetime.now(UTC)
|
||
payload = {
|
||
"sub": str(user_id),
|
||
"jti": jti,
|
||
"type": "refresh",
|
||
"iat": now,
|
||
"exp": now + timedelta(days=settings.refresh_token_ttl_days),
|
||
}
|
||
token = jwt.encode(payload, settings.jwt_secret, algorithm=JWT_ALGORITHM)
|
||
return token, jti
|
||
|
||
|
||
def decode_token(token: str) -> dict[str, Any]:
|
||
"""Декодировать и верифицировать JWT (сигнатура + срок действия).
|
||
|
||
Бросает `jwt.PyJWTError` (или подкласс) при невалидном/просроченном токене.
|
||
"""
|
||
settings = get_settings()
|
||
return jwt.decode(token, settings.jwt_secret, algorithms=[JWT_ALGORITHM])
|