Compare commits
2 Commits
e5c596f2bf
...
v0.0.17
| Author | SHA1 | Date | |
|---|---|---|---|
| f39c21e7e1 | |||
| 84b7f807f7 |
@@ -112,7 +112,7 @@ SMTP_TIMEOUT_S=30
|
||||
# --- Версия инстанса (релиз v0.0.1) ---
|
||||
# install.sh копирует значение из корневого файла VERSION при каждой
|
||||
# установке/обновлении — руками менять не нужно.
|
||||
VIDCONF_VERSION=0.0.16
|
||||
VIDCONF_VERSION=0.0.17
|
||||
|
||||
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
|
||||
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,
|
||||
|
||||
21
CHANGELOG.md
21
CHANGELOG.md
@@ -3,6 +3,27 @@
|
||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
|
||||
проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
|
||||
|
||||
## [0.0.17] — 2026-08-01
|
||||
|
||||
Массовый вход в систему и в конференцию перестаёт упираться в проверку пароля.
|
||||
|
||||
### Исправлено
|
||||
- Проверка пароля больше не останавливает весь backend. Хэширование argon2 —
|
||||
это десятки миллисекунд счёта, и выполнялось оно синхронно внутри
|
||||
асинхронного обработчика: пока считался один пароль, процесс не обслуживал
|
||||
ничего другого. На нагрузочном тесте 31.07 с примерно семью десятками
|
||||
одновременных входов это дало p95 логина 7.28 секунды, p95 входа в
|
||||
конференцию 7.06 секунды и 33 соединения к базе, висящих в открытой
|
||||
транзакции при одном активном запросе. Страдали и посторонние запросы —
|
||||
вход в конференцию отказывал, хотя пароль там не проверялся вовсе.
|
||||
Теперь хэширование считается в пуле потоков.
|
||||
- Параметры argon2id приведены к рекомендации OWASP (t=2, m=19 МБ, p=1)
|
||||
вместо дефолтов библиотеки (t=3, m=64 МБ, p=4): 95 мс против 42 мс на
|
||||
проверку. Прежнее значение `parallelism=4` вдобавок занимало все четыре
|
||||
ядра сервера — те же, на которых работает медиа-сервер.
|
||||
- Пароли, сохранённые со старыми параметрами, продолжают работать и
|
||||
перевыпускаются автоматически при первом успешном входе.
|
||||
|
||||
## [0.0.16] — 2026-08-01
|
||||
|
||||
Роль организатора в комнате: поднятие руки с очередью и принудительный мьют.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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`) — сбросить/установить
|
||||
# переопределение; отсутствие поля в запросе значение не трогает.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -89,7 +89,7 @@ services:
|
||||
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
|
||||
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение
|
||||
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
|
||||
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.16}
|
||||
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.17}
|
||||
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
|
||||
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
|
||||
# проверьте бюджет соединений с БД: каждый воркер держит свой пул
|
||||
|
||||
Reference in New Issue
Block a user