"""Хэширование паролей (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])