feat(tradein/auth): глобальный потолок попыток входа на имя пользователя (#2571) #2663

Merged
bot-backend merged 2 commits from feat/2571-login-throttle into main 2026-08-05 18:31:28 +00:00
Collaborator

Что это даёт и чего НЕ даёт

Сразу, чтобы не читалось сильнее, чем есть: это аудит-сигнал плюс трение, а не потолок темпа. Счётчик инкрементируется до сна, попытки по одному имени ничем не сериализуются, а await asyncio.sleep отпускает событийный цикл — значит атакующий, которому безразлична латентность, держит сотни соединений и отсыпает свои задержки параллельно. Фактический потолок равен числу его соединений, делённому на задержку, то есть выбирается им, а не нами. Настоящий потолок обсуждается отдельно в #2665 (там же — про то, что сегодня единственный реальный ограничитель темпа побочный: синхронный bcrypt блокирует событийный цикл на ~3-4 попытки в секунду ценой остановки всего приложения).

Что PR даёт по-настоящему: распределённый перебор становится видимым в user_events (раньше выглядел как россыпь одиночных неудач с разных адресов) и дороже для атакующего, которому важна латентность.

Модель угрозы

Секция /trade-in/* в боевом Caddy вынесена выше импорта basic_auth (так и задумано в #2558 — у трейд-ина своя форма входа поверх RBAC), поэтому POST /trade-in/api/v1/auth/login уже сейчас доступен из интернета без единого крeда. Единственный лимит на нём ключевался парой (username, IP): 5 попыток / 300с. Против человека, долбящего с одного адреса, это работает; против credential stuffing — нет вообще. Злоумышленник с ботнета или пула резидентных прокси получает свежие 5 попыток с КАЖДОГО нового адреса. Список имён при этом угадывается тривиально (admin, manager, user1user10 — реальные аккаунты из seed-миграции эпика #2549).

Второй, менее очевидный вектор — перечисление учёток. Логин уже защищён от него на двух уровнях: все ветки отказа отдают один и тот же generic 401, а verify_password вызывается безусловно (для несуществующего имени — против статичного dummy-хеша), чтобы bcrypt не выдал существование аккаунта разницей во времени ответа. Любая новая логика на пути отказа обязана этот инвариант сохранить, иначе она сама становится оракулом — что и определило форму этого PR.

Что выбрано и почему

Глобальный счётчик неудач на ИМЯ, без IP в ключе (_USERNAME_FAIL_LIMITER, дефолт 20 неудач / час) — поверх существующего per-IP лимита, не вместо. Оба — один и тот же примитив SlidingWindowLimiter; у него теперь record() возвращает число попыток в окне (раньше None, существующие вызывающие возврат игнорируют).

Замедление, а не блокировка. Жёсткая блокировка учётки после N неудач лечится злоумышленником в свою пользу: не зная ни одного пароля, он гарантированно выключает вход конкретному человеку — отказ в обслуживании дешевле и надёжнее того, от чего блокировка защищает. Задержка не отнимает доступ ни у кого: владелец пароля входит с первой попытки, медленнее приходит ответ только на очередную НЕУДАЧУ. Рост — удвоением от 1с, с обязательным потолком (дефолт 8с). Порог, окно и потолок — в Settings (LOGIN_USERNAME_FAIL_THRESHOLD, LOGIN_USERNAME_FAIL_WINDOW_S, LOGIN_USERNAME_THROTTLE_MAX_DELAY_S), дефолты работают без изменения .env.runtime.

Где живёт счётчик. В памяти процесса — сознательно. Прод-бэкенд запущен ОДНИМ uvicorn-воркером (docker-compose.prod.yml, комментарий над command), значит in-process счётчик и есть глобальный. Redis в проекте есть (app/services/cache.py), но в auth-пути он добавил бы сетевую зависимость, падение которой даёт выбор из двух плохих вариантов: fail-open (дыра ровно там, где защита) или fail-closed (вход лежит, потому что лежит кэш). При нескольких воркерах (--workers N) потолок поделится на N и счётчик придётся переносить в Redis; тот же потолок у соседнего _LOGIN_LIMITER, перезапуск процесса обнуляет оба.

Оракул существования учётки. Замедление, применённое только к существующим именам, само становится способом перечислить живые логины по времени ответа. Решено структурно: все ветки отказа по кредам (нет такого имени / неверный пароль / password_hash NULL / доступ закрыт) сведены в ОДИН хвост _reject_invalid_credentials — счётчик, аудит, задержка, 401. Счётчик ведётся по ПРИСЛАННОМУ имени, без проверки в реестре. Обойти регистром нельзя — get_user_by_username сверяет username = :username по обычной text-колонке без нормализации.

Аудит. login_failed в user_events писался и раньше; теперь событие несёт payload с состоянием счётчика (username_fails_in_window, throttle_delay_s) — это и есть главная ценность PR. Raw-пароль по-прежнему не попадает ни в лог, ни в payload. _client_ip не тронут (правый hop XFF) и закреплён тестом.

Правки по ревью (второй коммит)

MAJOR-1, подтверждён расчётом. min() вычисляет оба аргумента, поэтому float(2 ** (excess - 1)) при excess >= 1025 падал с OverflowError. Проверено: fails=1044 → 8.0, fails=1045 → OverflowError. С дефолтами это 1045 неудач по имени за час, то есть 0.29 rps — с этой попытки и до конца окна вход отдавал 500 мгновенно, без задержки и без аудита, теряя обе ценности PR именно тогда, когда атака идёт всерьёз. Показатель степени зажат min(excess - 1, 16). Мой прежний тест щупал _throttle_delay_s(1000) = float(2**996) — впритык под обрывом; добавлены 5000 и 10**6.

MAJOR-2, подтверждён замером. Проверил всю цепочку сам, а не поверил на слово:

  • identity_store по умолчанию 'tradein', и get_identity_db в этом режиме отдаёт ту же сессию, что get_db (в коде это заявлено явным «⚠️ отдаётся РОВНО ТОТ ЖЕ объект Session»);
  • пул реального движка: QueuePool, pool_size=5, max_overflow=1015 соединений, pool_timeout=30.0 — снято с живого engine.pool, а не из документации;
  • транзакция действительно висит открытой: после SELECT в сессии с autocommit=Falsepool.checkedout() == 1, db.in_transaction() is True; close() возвращает в пул (checkedout() == 0).

Арифметика ревьюера сходится: чтобы держать 15 спящих попыток одновременно, нужно ~15/8 ≈ 2 неудачных логина в секунду — достижимо, потолок bcrypt (~4/с) выше. Соединение теперь возвращается в пул перед сном; сессия дальше не используется (сразу raise), повторный close() в зависимости идемпотентен.

Мелочи. username ограничен max_length=64 — ровно верх CHECK'а реестра, живое имя отсечь нельзя; паттерн и минимум длины НЕ дублирую, потому что в режиме identity_store="tradein" CHECK'а нет и живут не-ASCII имена (есть тест на кириллицу — проверил перед правкой). Про limit у счётчика на имя написан явный комментарий, что это не порог. Добавлен тест на спад счётчика по истечении окна.

Про маленький семафор на имя: не тащу и не советую сюда. Он превращает латентность в реальный потолок, но ровно тем же механизмом создаёт очередь, в которой легитимный владелец имени ждёт за спинами атакующих — то есть возвращает DoS против конкретного человека, от которого issue сознательно уходит, только в менее заметной форме. Это решение уровня #2665, вместе с выбором про bcrypt.

Что НЕ входит

  • CAPTCHA / PoW — отдельное продуктовое решение, в issue помечено опциональным.
  • Разблокировка менеджером — не нужна: учётка не блокируется, снимать нечего.
  • Миграции — новых полей/таблиц нет, user_events.payload уже jsonb.
  • Настоящий потолок темпа / судьба синхронного bcrypt#2665.

Test plan

tradein-mvp/backend/tests/test_auth_api.py, 10 новых тестов (43 в файле, все зелёные):

  • DoD 1 — распределённый перебор: 6 попыток по одному имени с 6 РАЗНЫХ адресов, ни одного 429, счётчик на имя растёт 1→6.
  • DoD 2 — опечатка: 3 неудачи при пороге 20/час → задержки нет, верный пароль сразу пускает; плюс спад счётчика по истечении окна.
  • DoD 3 — аудит: login_failed с username / ip / user-agent / path и состоянием счётчика; raw-пароль не утёк.
  • Нет оракула существования: последовательности задержек и ответы для живого и несуществующего имени совпадают.
  • Заблокированная учётка (верный пароль, disabled) идёт тем же хвостом.
  • Задержка реально выжидается (замер времени ответа).
  • Соединение БД отдано до сна — по порядку событий: между close() и концом ответа лежит вся задержка.
  • Формула: 0 до порога, удвоение, потолок, и отсутствие OverflowError на 5000 / 106**.
  • Длина имени ограничена (65 → 422, 64 → обычный 401).
  • _client_ip берёт правый hop XFF при подделанном левом.

Фальсификация, честно. Первый заход: с откаченной реализацией 7/7 новых тестов красные, 33 существующих зелёные. Мутанты, каждый пойман ровно целевым тестом: убрать await asyncio.sleep; тормозить только существующие имена; вернуть IP в ключ счётчика (падают три, включая DoD-1); вернуть float(2 ** (excess - 1))OverflowError в тесте формулы; убрать db.close() перед сном → падает тест на возврат соединения. Полный прогон бэкенда: 3358 passed, 1 failed — test_search_api.py::test_search_cache_hit, воспроизводится на чистом origin/main в отдельном worktree, к этому PR отношения не имеет.

Refs #2571

## Что это даёт и чего НЕ даёт Сразу, чтобы не читалось сильнее, чем есть: **это аудит-сигнал плюс трение, а не потолок темпа.** Счётчик инкрементируется до сна, попытки по одному имени ничем не сериализуются, а `await asyncio.sleep` отпускает событийный цикл — значит атакующий, которому безразлична латентность, держит сотни соединений и отсыпает свои задержки параллельно. Фактический потолок равен числу его соединений, делённому на задержку, то есть выбирается им, а не нами. Настоящий потолок обсуждается отдельно в #2665 (там же — про то, что сегодня единственный реальный ограничитель темпа побочный: синхронный bcrypt блокирует событийный цикл на ~3-4 попытки в секунду ценой остановки всего приложения). Что PR даёт по-настоящему: распределённый перебор становится **видимым** в `user_events` (раньше выглядел как россыпь одиночных неудач с разных адресов) и **дороже** для атакующего, которому важна латентность. ## Модель угрозы Секция `/trade-in/*` в боевом Caddy вынесена выше импорта basic_auth (так и задумано в #2558 — у трейд-ина своя форма входа поверх RBAC), поэтому `POST /trade-in/api/v1/auth/login` уже сейчас доступен из интернета без единого крeда. Единственный лимит на нём ключевался парой (username, IP): 5 попыток / 300с. Против человека, долбящего с одного адреса, это работает; против credential stuffing — нет вообще. Злоумышленник с ботнета или пула резидентных прокси получает свежие 5 попыток с КАЖДОГО нового адреса. Список имён при этом угадывается тривиально (`admin`, `manager`, `user1`…`user10` — реальные аккаунты из seed-миграции эпика #2549). Второй, менее очевидный вектор — перечисление учёток. Логин уже защищён от него на двух уровнях: все ветки отказа отдают один и тот же generic 401, а `verify_password` вызывается безусловно (для несуществующего имени — против статичного dummy-хеша), чтобы bcrypt не выдал существование аккаунта разницей во времени ответа. Любая новая логика на пути отказа обязана этот инвариант сохранить, иначе она сама становится оракулом — что и определило форму этого PR. ## Что выбрано и почему **Глобальный счётчик неудач на ИМЯ, без IP в ключе** (`_USERNAME_FAIL_LIMITER`, дефолт 20 неудач / час) — поверх существующего per-IP лимита, не вместо. Оба — один и тот же примитив `SlidingWindowLimiter`; у него теперь `record()` возвращает число попыток в окне (раньше `None`, существующие вызывающие возврат игнорируют). **Замедление, а не блокировка.** Жёсткая блокировка учётки после N неудач лечится злоумышленником в свою пользу: не зная ни одного пароля, он гарантированно выключает вход конкретному человеку — отказ в обслуживании дешевле и надёжнее того, от чего блокировка защищает. Задержка не отнимает доступ ни у кого: владелец пароля входит с первой попытки, медленнее приходит ответ только на очередную НЕУДАЧУ. Рост — удвоением от 1с, с обязательным потолком (дефолт 8с). Порог, окно и потолок — в `Settings` (`LOGIN_USERNAME_FAIL_THRESHOLD`, `LOGIN_USERNAME_FAIL_WINDOW_S`, `LOGIN_USERNAME_THROTTLE_MAX_DELAY_S`), дефолты работают без изменения `.env.runtime`. **Где живёт счётчик.** В памяти процесса — сознательно. Прод-бэкенд запущен ОДНИМ uvicorn-воркером (`docker-compose.prod.yml`, комментарий над `command`), значит in-process счётчик и есть глобальный. Redis в проекте есть (`app/services/cache.py`), но в auth-пути он добавил бы сетевую зависимость, падение которой даёт выбор из двух плохих вариантов: fail-open (дыра ровно там, где защита) или fail-closed (вход лежит, потому что лежит кэш). **При нескольких воркерах** (`--workers N`) потолок поделится на N и счётчик придётся переносить в Redis; тот же потолок у соседнего `_LOGIN_LIMITER`, перезапуск процесса обнуляет оба. **Оракул существования учётки.** Замедление, применённое только к существующим именам, само становится способом перечислить живые логины по времени ответа. Решено структурно: все ветки отказа по кредам (нет такого имени / неверный пароль / `password_hash` NULL / доступ закрыт) сведены в ОДИН хвост `_reject_invalid_credentials` — счётчик, аудит, задержка, 401. Счётчик ведётся по ПРИСЛАННОМУ имени, без проверки в реестре. Обойти регистром нельзя — `get_user_by_username` сверяет `username = :username` по обычной `text`-колонке без нормализации. **Аудит.** `login_failed` в `user_events` писался и раньше; теперь событие несёт `payload` с состоянием счётчика (`username_fails_in_window`, `throttle_delay_s`) — это и есть главная ценность PR. Raw-пароль по-прежнему не попадает ни в лог, ни в payload. `_client_ip` не тронут (правый hop XFF) и закреплён тестом. ## Правки по ревью (второй коммит) **MAJOR-1, подтверждён расчётом.** `min()` вычисляет оба аргумента, поэтому `float(2 ** (excess - 1))` при `excess >= 1025` падал с `OverflowError`. Проверено: `fails=1044 → 8.0`, `fails=1045 → OverflowError`. С дефолтами это 1045 неудач по имени за час, то есть 0.29 rps — с этой попытки и до конца окна вход отдавал **500 мгновенно, без задержки и без аудита**, теряя обе ценности PR именно тогда, когда атака идёт всерьёз. Показатель степени зажат `min(excess - 1, 16)`. Мой прежний тест щупал `_throttle_delay_s(1000)` = `float(2**996)` — впритык под обрывом; добавлены 5000 и 10**6. **MAJOR-2, подтверждён замером.** Проверил всю цепочку сам, а не поверил на слово: - `identity_store` по умолчанию `'tradein'`, и `get_identity_db` в этом режиме отдаёт **ту же** сессию, что `get_db` (в коде это заявлено явным «⚠️ отдаётся РОВНО ТОТ ЖЕ объект `Session`»); - пул реального движка: `QueuePool`, `pool_size=5`, `max_overflow=10` → **15 соединений**, `pool_timeout=30.0` — снято с живого `engine.pool`, а не из документации; - транзакция действительно висит открытой: после `SELECT` в сессии с `autocommit=False` — `pool.checkedout() == 1`, `db.in_transaction() is True`; `close()` возвращает в пул (`checkedout() == 0`). Арифметика ревьюера сходится: чтобы держать 15 спящих попыток одновременно, нужно ~15/8 ≈ 2 неудачных логина в секунду — достижимо, потолок bcrypt (~4/с) выше. Соединение теперь возвращается в пул **перед** сном; сессия дальше не используется (сразу `raise`), повторный `close()` в зависимости идемпотентен. **Мелочи.** `username` ограничен `max_length=64` — ровно верх CHECK'а реестра, живое имя отсечь нельзя; паттерн и минимум длины НЕ дублирую, потому что в режиме `identity_store="tradein"` CHECK'а нет и живут не-ASCII имена (есть тест на кириллицу — проверил перед правкой). Про `limit` у счётчика на имя написан явный комментарий, что это не порог. Добавлен тест на спад счётчика по истечении окна. Про маленький семафор на имя: **не тащу и не советую сюда**. Он превращает латентность в реальный потолок, но ровно тем же механизмом создаёт очередь, в которой легитимный владелец имени ждёт за спинами атакующих — то есть возвращает DoS против конкретного человека, от которого issue сознательно уходит, только в менее заметной форме. Это решение уровня #2665, вместе с выбором про bcrypt. ## Что НЕ входит - **CAPTCHA / PoW** — отдельное продуктовое решение, в issue помечено опциональным. - **Разблокировка менеджером** — не нужна: учётка не блокируется, снимать нечего. - **Миграции** — новых полей/таблиц нет, `user_events.payload` уже `jsonb`. - **Настоящий потолок темпа / судьба синхронного bcrypt** — #2665. ## Test plan `tradein-mvp/backend/tests/test_auth_api.py`, 10 новых тестов (43 в файле, все зелёные): - [x] DoD 1 — распределённый перебор: 6 попыток по одному имени с 6 РАЗНЫХ адресов, ни одного 429, счётчик на имя растёт 1→6. - [x] DoD 2 — опечатка: 3 неудачи при пороге 20/час → задержки нет, верный пароль сразу пускает; плюс спад счётчика по истечении окна. - [x] DoD 3 — аудит: `login_failed` с username / ip / user-agent / path и состоянием счётчика; raw-пароль не утёк. - [x] Нет оракула существования: последовательности задержек и ответы для живого и несуществующего имени совпадают. - [x] Заблокированная учётка (верный пароль, `disabled`) идёт тем же хвостом. - [x] Задержка реально выжидается (замер времени ответа). - [x] **Соединение БД отдано до сна** — по порядку событий: между `close()` и концом ответа лежит вся задержка. - [x] Формула: 0 до порога, удвоение, потолок, **и отсутствие OverflowError на 5000 / 10**6**. - [x] Длина имени ограничена (65 → 422, 64 → обычный 401). - [x] `_client_ip` берёт правый hop XFF при подделанном левом. **Фальсификация, честно.** Первый заход: с откаченной реализацией 7/7 новых тестов красные, 33 существующих зелёные. Мутанты, каждый пойман ровно целевым тестом: убрать `await asyncio.sleep`; тормозить только существующие имена; вернуть IP в ключ счётчика (падают три, включая DoD-1); **вернуть `float(2 ** (excess - 1))`** → `OverflowError` в тесте формулы; **убрать `db.close()` перед сном** → падает тест на возврат соединения. Полный прогон бэкенда: 3358 passed, 1 failed — `test_search_api.py::test_search_cache_hit`, воспроизводится на чистом `origin/main` в отдельном worktree, к этому PR отношения не имеет. Refs #2571
bot-backend added 1 commit 2026-08-05 17:41:00 +00:00
feat(tradein/auth): глобальный потолок попыток входа на имя пользователя (#2571)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 6s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m50s
7d154de1f7
Лимит на логине ключевался парой (username, IP), поэтому распределённый
перебор одного имени с тысячи адресов получал по 5 попыток с каждого
источника и не упирался ни во что. После снятия Caddy basic_auth с
/trade-in (#2558) POST /auth/login — единственная ручка, доступная из
интернета без кредов, так что дыра открыта прямо сейчас.

Поверх существующего per-IP лимита добавлен глобальный счётчик неудач
на ИМЯ, без IP в ключе. Превышение порога не блокирует учётку, а растит
задержку ответа (удвоение от 1с до потолка): блокировка по имени была бы
вектором отказа в обслуживании против конкретного человека — не зная
пароля, злоумышленник гарантированно выключал бы чужой вход.

Задержка применяется по ПРИСЛАННОМУ имени, без проверки его в реестре, и
из одного места — общего хвоста всех отказов по кредам. Иначе «быстрый
401» для несуществующего имени стал бы оракулом существования учётки, то
есть ровно той user-enumeration, от которой уже защищают одинаковый
generic-ответ и безусловный bcrypt.
Light1YT added 1 commit 2026-08-05 18:14:22 +00:00
fix(tradein/auth): не ронять и не занимать пул на замедлении входа (#2571)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m56s
40fdf11f19
Ревью нашло два способа положить сервис ровно под той нагрузкой, ради
которой писалась защита.

Первый: `min()` вычисляет оба аргумента, поэтому `float(2 ** (excess - 1))`
при 1045 неудачах по имени за окно падал с OverflowError. Счётчик ничем
не ограничен сверху — `record()` только копит метки и на лимит не смотрит.
С этой попытки и до конца окна вход отдавал 500 мгновенно, без задержки и
без записи в аудит: терялись обе ценности PR, и трение, и сигнал. Показатель
степени зажат; 2**16 заведомо выше любого разумного потолка, поэтому видимое
поведение не меняется.

Второй: сон шёл внутри области жизни сессии БД. В дефолтном режиме
`get_identity_db` отдаёт ту же сессию, что `get_db`, а SELECT в
`get_user_by_username` оставляет её в открытой транзакции — соединение
висело занятым все восемь секунд. Пятнадцати одновременных неудач хватало,
чтобы выбрать QueuePool целиком и уронить любой другой эндпоинт по
pool_timeout. Отказ в обслуживании против всех сразу — хуже той блокировки
учётки, ради ухода от которой замедление и выбиралось. Соединение теперь
возвращается в пул перед сном.

Заодно: длина имени ограничена 64 (верх CHECK'а реестра) — сырое имя
становится ключом обоих лимитеров, а их словарь при часовом окне не
подчищается; и явно записано, что `limit` у счётчика на имя не порог.
Author
Collaborator

Порог 20 неудач в час был назван автором как «угадан, не измерен» — измерил на проде (только SELECT).

всего событий login_failed:            55
пар «пользователь × час» с неудачами:  35
максимум неудач за час у одного имени:  6
p95:                                    5
p99:                                    6
пар выше порога 20:                     0

То есть у самого неудачливого живого пользователя шесть промахов за час — втрое ниже порога. Ни один легитимный сценарий в имеющейся истории замедления не поймает.

Честная оговорка: выборка маленькая (55 событий за всё время, продукт до публичного запуска), так что это «противопоказаний не найдено», а не сильная валидация. Когда пойдёт живой трафик, распределение стоит пересмотреть — запрос выше воспроизводимый.

Про остальные две неизвестности из отчёта

db.close() в режиме identity_store="auth" не снят эмпирически, но там get_identity_db отдаёт отдельную сессию auth-БД, и после закрытия она не используется — сразу raise. Логика та же, риск ниже, чем в основном режиме (который как раз и проверен).

Исчерпание пула нагрузочно не воспроизводилось — подтверждено по звеньям: пул снят с живого движка (QueuePool, 5+10, таймаут 30), открытая транзакция после SELECT показана эмпирически, тот же объект сессии — по коду и дефолту настройки. Для решения о мерже этого достаточно: чинится всё равно закрытием сессии перед задержкой, а оно теперь есть и покрыто тестом, который проверяет порядок, а не факт вызова.

Отдельно — про отказ от семафора

Автор отказался и обосновал так: семафор превращает латентность в настоящий потолок, но тем же механизмом создаёт очередь, где легитимный владелец имени ждёт за спинами атакующих. Это возвращает отказ в обслуживании против конкретного человека — тот самый, от которого задача уходила, просто в менее заметной форме: не «вход отключён», а «вход не открывается».

Согласен. Настоящий потолок — это #2665 вместе с судьбой синхронного bcrypt, и решать их надо одним заходом.

Порог 20 неудач в час был назван автором как «угадан, не измерен» — **измерил на проде** (только SELECT). ``` всего событий login_failed: 55 пар «пользователь × час» с неудачами: 35 максимум неудач за час у одного имени: 6 p95: 5 p99: 6 пар выше порога 20: 0 ``` То есть у самого неудачливого живого пользователя шесть промахов за час — втрое ниже порога. **Ни один легитимный сценарий в имеющейся истории замедления не поймает.** Честная оговорка: выборка маленькая (55 событий за всё время, продукт до публичного запуска), так что это «противопоказаний не найдено», а не сильная валидация. Когда пойдёт живой трафик, распределение стоит пересмотреть — запрос выше воспроизводимый. ## Про остальные две неизвестности из отчёта **`db.close()` в режиме `identity_store="auth"`** не снят эмпирически, но там `get_identity_db` отдаёт отдельную сессию auth-БД, и после закрытия она не используется — сразу `raise`. Логика та же, риск ниже, чем в основном режиме (который как раз и проверен). **Исчерпание пула нагрузочно не воспроизводилось** — подтверждено по звеньям: пул снят с живого движка (`QueuePool`, 5+10, таймаут 30), открытая транзакция после SELECT показана эмпирически, тот же объект сессии — по коду и дефолту настройки. Для решения о мерже этого достаточно: чинится всё равно закрытием сессии перед задержкой, а оно теперь есть и покрыто тестом, который проверяет **порядок**, а не факт вызова. ## Отдельно — про отказ от семафора Автор отказался и обосновал так: семафор превращает латентность в настоящий потолок, но тем же механизмом создаёт очередь, где легитимный владелец имени ждёт за спинами атакующих. Это возвращает отказ в обслуживании против конкретного человека — тот самый, от которого задача уходила, просто в менее заметной форме: не «вход отключён», а «вход не открывается». Согласен. Настоящий потолок — это #2665 вместе с судьбой синхронного bcrypt, и решать их надо одним заходом.
bot-backend merged commit c9f71da484 into main 2026-08-05 18:31:28 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2663
No description provided.