diff --git a/backend/api/admin.py b/backend/api/admin.py index d53ce60..67ec176 100644 --- a/backend/api/admin.py +++ b/backend/api/admin.py @@ -217,7 +217,7 @@ async def create_user( user = await repo.create( email=data.email, name_user=data.name_user, - password_hash=hash_password(data.password), + password_hash=await hash_password(data.password), team_id=data.team_id, ) user.email_verified = True diff --git a/backend/api/users.py b/backend/api/users.py index 33a60d9..87bad7d 100644 --- a/backend/api/users.py +++ b/backend/api/users.py @@ -101,11 +101,11 @@ async def change_current_user_password( вместе со сбросом пароля по email (v0.1.0, см. ADR-005 `docs/architecture/adr/005-password-reset-deferred.md`). """ - if not verify_password(data.current_password, user.password_hash): + if not await verify_password(data.current_password, user.password_hash): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_current_password" ) - user.password_hash = hash_password(data.new_password) + user.password_hash = await hash_password(data.new_password) await session.commit() diff --git a/backend/core/security.py b/backend/core/security.py index 7c7e6b0..fbf762e 100644 --- a/backend/core/security.py +++ b/backend/core/security.py @@ -1,33 +1,85 @@ """Хэширование паролей (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 VerifyMismatchError +from argon2.exceptions import InvalidHashError, VerifyMismatchError from core.config import get_settings JWT_ALGORITHM = "HS256" -_hasher = PasswordHasher() +# Параметры 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(password: str) -> str: - """Захэшировать пароль алгоритмом argon2 для хранения в БД.""" +def _hash_password_sync(password: str) -> str: return _hasher.hash(password) -def verify_password(password: str, password_hash: str) -> bool: - """Сверить пароль с сохранённым argon2-хэшем; пароль/хэш никогда не логируются.""" +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() diff --git a/backend/services/auth.py b/backend/services/auth.py index 12526cc..af4c27f 100644 --- a/backend/services/auth.py +++ b/backend/services/auth.py @@ -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: diff --git a/backend/services/conference_access.py b/backend/services/conference_access.py index c8981d7..cafe546 100644 --- a/backend/services/conference_access.py +++ b/backend/services/conference_access.py @@ -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 diff --git a/backend/services/conferences.py b/backend/services/conferences.py index 87935ea..e437277 100644 --- a/backend/services/conferences.py +++ b/backend/services/conferences.py @@ -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`) — сбросить/установить # переопределение; отсутствие поля в запросе значение не трогает. diff --git a/backend/tests/test_admin_api.py b/backend/tests/test_admin_api.py index 7747c2d..a7ae843 100644 --- a/backend/tests/test_admin_api.py +++ b/backend/tests/test_admin_api.py @@ -33,7 +33,7 @@ async def _make_user(session: AsyncSession, *, role: str = "user") -> User: user = User( email=f"{uuid.uuid4()}@example.com", name_user="Admin API Tester", - password_hash=hash_password("password123"), + password_hash=await hash_password("password123"), email_verified=True, role=role, ) diff --git a/backend/tests/test_admin_teams.py b/backend/tests/test_admin_teams.py index df31b5c..4398fc7 100644 --- a/backend/tests/test_admin_teams.py +++ b/backend/tests/test_admin_teams.py @@ -20,7 +20,7 @@ async def _make_user(session: AsyncSession, *, role: str = "user") -> User: user = User( email=f"{uuid.uuid4()}@example.com", name_user="Team API Tester", - password_hash=hash_password("password123"), + password_hash=await hash_password("password123"), email_verified=True, role=role, ) diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index c1c75e9..7fb828f 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -9,6 +9,7 @@ from typing import Annotated import httpx import pytest_asyncio +from argon2 import PasswordHasher from fastapi import Depends, FastAPI from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -16,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from api.auth import get_auth_service from core.db import get_session from core.redis import redis_client +from core.security import needs_rehash from models.team import Team from models.user import User from services.auth import AuthService @@ -491,3 +493,35 @@ async def test_register_no_reply_to_when_contact_email_disabled( assert response.status_code == 201, response.text assert email_backend.reply_to[-1] is None + + +async def test_login_rehashes_legacy_password( + client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend +) -> None: + """Вход с паролем, захэшированным старыми параметрами, перевыпускает хэш. + + Параметры argon2 зашиты в саму строку хэша, поэтому смена настроек + (0.0.17: дефолты библиотеки → рекомендации OWASP) сама по себе не ускоряет + проверку уже существующих паролей. Миграция идёт лениво — при первом + успешном входе, когда открытый пароль есть на руках. + """ + email = "legacy-hash@example.com" + password = "supersecret1" + await _register_and_verify(client, email_backend, email=email, password=password) + + # Подменяем хэш на выданный прежними параметрами (t=3, m=64 МБ, p=4). + legacy_hash = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4).hash(password) + user = await db_session.scalar(select(User).where(User.email == email)) + assert user is not None + user.password_hash = legacy_hash + await db_session.commit() + + response = await client.post( + "/api/v1/auth/token", data={"username": email, "password": password} + ) + assert response.status_code == 200, response.text + + await db_session.refresh(user) + assert user.password_hash != legacy_hash, "старый хэш не был перевыпущен" + assert "m=19456" in user.password_hash + assert needs_rehash(user.password_hash) is False diff --git a/backend/tests/test_chat_ws.py b/backend/tests/test_chat_ws.py index ec0b0eb..be3e2f6 100644 --- a/backend/tests/test_chat_ws.py +++ b/backend/tests/test_chat_ws.py @@ -44,7 +44,7 @@ async def _make_user(session: AsyncSession, *, name: str = "Chat Tester") -> Use user = User( email=f"{uuid.uuid4()}@example.com", name_user=name, - password_hash=hash_password("password123"), + password_hash=await hash_password("password123"), email_verified=True, ) session.add(user) diff --git a/backend/tests/test_conference_invitees.py b/backend/tests/test_conference_invitees.py index 4b5af2e..8bd8bda 100644 --- a/backend/tests/test_conference_invitees.py +++ b/backend/tests/test_conference_invitees.py @@ -19,7 +19,7 @@ async def _make_user(session: AsyncSession) -> User: user = User( email=f"{uuid.uuid4()}@example.com", name_user="Invitee Tester", - password_hash=hash_password("password123"), + password_hash=await hash_password("password123"), email_verified=True, ) session.add(user) diff --git a/backend/tests/test_conference_service.py b/backend/tests/test_conference_service.py index 382fe59..d2cfac8 100644 --- a/backend/tests/test_conference_service.py +++ b/backend/tests/test_conference_service.py @@ -25,7 +25,7 @@ async def _make_user(session: AsyncSession, *, name: str = "Service Tester") -> user = User( email=f"{uuid.uuid4()}@example.com", name_user=name, - password_hash=hash_password("password123"), + password_hash=await hash_password("password123"), email_verified=True, ) session.add(user) diff --git a/backend/tests/test_conferences_api.py b/backend/tests/test_conferences_api.py index 1fbf8d8..a6a6c9c 100644 --- a/backend/tests/test_conferences_api.py +++ b/backend/tests/test_conferences_api.py @@ -33,7 +33,7 @@ async def _make_user(session: AsyncSession, *, role: str = "user") -> User: user = User( email=f"{uuid.uuid4()}@example.com", name_user="Conference Tester", - password_hash=hash_password("password123"), + password_hash=await hash_password("password123"), email_verified=True, role=role, ) @@ -67,7 +67,7 @@ async def _make_conference( status=status, is_pinned=is_pinned, is_closed=is_closed, - password_hash=hash_password(password) if password else None, + password_hash=await hash_password(password) if password else None, ended_at=ended_at, scheduled_at=scheduled_at, duration_minutes=duration_minutes, diff --git a/backend/tests/test_hand_queue_ws.py b/backend/tests/test_hand_queue_ws.py index 9487001..f09dc1a 100644 --- a/backend/tests/test_hand_queue_ws.py +++ b/backend/tests/test_hand_queue_ws.py @@ -28,7 +28,7 @@ async def _make_user(session: AsyncSession, *, name: str = "Hand Tester") -> Use user = User( email=f"{uuid.uuid4()}@example.com", name_user=name, - password_hash=hash_password("password123"), + password_hash=await hash_password("password123"), email_verified=True, ) session.add(user) diff --git a/backend/tests/test_livekit_webhook.py b/backend/tests/test_livekit_webhook.py index 89570d6..6f0c5f2 100644 --- a/backend/tests/test_livekit_webhook.py +++ b/backend/tests/test_livekit_webhook.py @@ -131,7 +131,7 @@ async def _make_user(session: AsyncSession, email: str) -> User: user = User( email=email, name_user="Participant", - password_hash=hash_password("password123"), + password_hash=await hash_password("password123"), email_verified=True, ) session.add(user) diff --git a/backend/tests/test_mute_participant_api.py b/backend/tests/test_mute_participant_api.py index 4427b3f..d3c97b0 100644 --- a/backend/tests/test_mute_participant_api.py +++ b/backend/tests/test_mute_participant_api.py @@ -53,7 +53,7 @@ async def _make_user(session: AsyncSession, *, role: str = "user") -> User: user = User( email=f"{uuid.uuid4()}@example.com", name_user="Mute Tester", - password_hash=hash_password("password123"), + password_hash=await hash_password("password123"), email_verified=True, role=role, ) diff --git a/backend/tests/test_password_hashing.py b/backend/tests/test_password_hashing.py new file mode 100644 index 0000000..4a5ac06 --- /dev/null +++ b/backend/tests/test_password_hashing.py @@ -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 diff --git a/backend/tests/test_rbac.py b/backend/tests/test_rbac.py index 2603d36..94ee2e3 100644 --- a/backend/tests/test_rbac.py +++ b/backend/tests/test_rbac.py @@ -20,7 +20,7 @@ async def _make_user(session: AsyncSession, *, role: str = "user") -> User: user = User( email=f"{uuid.uuid4()}@example.com", name_user="Test User", - password_hash=hash_password("password123"), + password_hash=await hash_password("password123"), role=role, email_verified=True, ) diff --git a/backend/tests/test_teams_api.py b/backend/tests/test_teams_api.py index 57d3446..0ecc01c 100644 --- a/backend/tests/test_teams_api.py +++ b/backend/tests/test_teams_api.py @@ -18,7 +18,7 @@ async def _make_user(session: AsyncSession) -> User: user = User( email=f"{uuid.uuid4()}@example.com", name_user="Teams API Tester", - password_hash=hash_password("password123"), + password_hash=await hash_password("password123"), email_verified=True, ) session.add(user) diff --git a/backend/tests/test_users_api.py b/backend/tests/test_users_api.py index 11c11a9..b8ffc2c 100644 --- a/backend/tests/test_users_api.py +++ b/backend/tests/test_users_api.py @@ -24,7 +24,7 @@ async def _make_user(session: AsyncSession) -> User: user = User( email=f"{uuid.uuid4()}@example.com", name_user="List Tester", - password_hash=hash_password("password123"), + password_hash=await hash_password("password123"), email_verified=True, ) session.add(user) @@ -98,7 +98,7 @@ async def test_get_me_with_reserved_tld_email_does_not_500( user = User( email=legacy_email, name_user="Legacy Admin", - password_hash=hash_password("password123"), + password_hash=await hash_password("password123"), email_verified=True, role="admin", ) @@ -380,7 +380,7 @@ async def test_list_users_search_by_q_filters_by_name_or_email( match = User( email=f"{unique_marker}@example.com", name_user=f"Findable {unique_marker}", - password_hash=hash_password("password123"), + password_hash=await hash_password("password123"), email_verified=True, ) db_session.add(match)