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
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
Ревью нашло два способа положить сервис ровно под той нагрузкой, ради которой писалась защита. Первый: `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` у счётчика на имя не порог.
This commit is contained in:
parent
7d154de1f7
commit
40fdf11f19
2 changed files with 119 additions and 6 deletions
|
|
@ -45,7 +45,7 @@ import secrets
|
|||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
|
|
@ -83,6 +83,13 @@ _LOGIN_LIMITER = SlidingWindowLimiter(
|
|||
# Потолок: появятся воркеры (`--workers N`) — потолок делится на N, и его надо
|
||||
# переносить в Redis (`app.services.cache` уже держит там пул). Тот же ceiling
|
||||
# у соседнего `_LOGIN_LIMITER`; перезапуск процесса обнуляет оба.
|
||||
#
|
||||
# ⚠️ `limit` здесь НЕ ПОРОГ и ничего не режет: мы зовём только `record()`, а он
|
||||
# на лимит не смотрит — считает и отдаёт число попыток в окне. Настоящий порог
|
||||
# живёт в `_throttle_delay_s`, которая читает настройку на каждом вызове (и
|
||||
# потому подхватывает monkeypatch в тестах). Значение продублировано сюда ровно
|
||||
# для того, чтобы `retry_after()` на этом объекте — если его однажды позовут —
|
||||
# отвечал по тому же числу, а не по случайному.
|
||||
_USERNAME_FAIL_LIMITER = SlidingWindowLimiter(
|
||||
limit=settings.login_username_fail_threshold,
|
||||
window_s=settings.login_username_fail_window_s,
|
||||
|
|
@ -109,7 +116,15 @@ _ACCESS_EXPIRED_MESSAGE = "Пробный доступ закончился"
|
|||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
# max_length=64 — ровно верхняя граница CHECK'а реестра
|
||||
# (`users_username_ascii_ck`, data/sql/auth/001), так что живое имя отсечь
|
||||
# нельзя. Ограничение нужно не валидации ради: сырое имя становится ключом
|
||||
# ОБОИХ лимитеров, а их `defaultdict` подчищается только при >10000 ключей и
|
||||
# только от пустых корзин — при окне в час корзины непустые, освобождать
|
||||
# нечего. Без границы длины килобайтные имена растили бы память ключами.
|
||||
# Паттерн/минимум длины НЕ дублируем: в режиме `identity_store="tradein"`
|
||||
# CHECK'а нет и живут не-ASCII имена (см. тест на кириллицу).
|
||||
username: str = Field(max_length=64)
|
||||
password: str
|
||||
|
||||
|
||||
|
|
@ -132,15 +147,23 @@ def _throttle_delay_s(fails_in_window: int) -> float:
|
|||
первые перебранные попытки почти незаметны, а сотни — упираются в потолок.
|
||||
Потолок обязателен: без него задержка становится той же блокировкой, только
|
||||
растянутой во времени.
|
||||
|
||||
Показатель степени зажат (`min(..., 16)`) — это не косметика. `min()` считает
|
||||
ОБА аргумента до сравнения, поэтому наивный `float(2 ** (excess - 1))` при
|
||||
excess>=1025 падает с `OverflowError: int too large to convert to float` —
|
||||
то есть ровно под целевой нагрузкой (1045 неудач по имени за час = 0.3 rps)
|
||||
защита начинала отдавать 500 мгновенно и без аудита, вместо 401 с задержкой.
|
||||
2**16 = 65536с заведомо больше любого разумного потолка, так что зажим
|
||||
видимого поведения не меняет, а арифметику делает безусловно конечной.
|
||||
"""
|
||||
excess = fails_in_window - settings.login_username_fail_threshold
|
||||
if excess <= 0:
|
||||
return 0.0
|
||||
return min(settings.login_username_throttle_max_delay_s, float(2 ** (excess - 1)))
|
||||
return min(settings.login_username_throttle_max_delay_s, 2.0 ** min(excess - 1, 16))
|
||||
|
||||
|
||||
async def _reject_invalid_credentials(
|
||||
username: str, ip: str, user_agent: str | None
|
||||
db: Session, username: str, ip: str, user_agent: str | None
|
||||
) -> HTTPException:
|
||||
"""Единый хвост ЛЮБОГО отказа по кредам: счётчик → аудит → задержка → 401.
|
||||
|
||||
|
|
@ -157,6 +180,15 @@ async def _reject_invalid_credentials(
|
|||
|
||||
Возвращает `HTTPException`, а не бросает: `raise await …` не собирается, а
|
||||
`raise (await …)` читается хуже, чем `raise` над возвращённым значением.
|
||||
|
||||
*db* нужен ровно затем, чтобы ОТДАТЬ соединение перед сном. `get_identity_db`
|
||||
в дефолтном режиме (`identity_store="tradein"`, он же прод) отдаёт ту же
|
||||
сессию, что `get_db` — движок с QueuePool на 5+10 соединений. После SELECT в
|
||||
`get_user_by_username` сессия держит соединение в открытой транзакции, и сон
|
||||
внутри её области жизни превращал бы каждую спящую попытку в занятое
|
||||
соединение: ~15 одновременных неудач выбирают пул целиком, и тогда ЛЮБОЙ
|
||||
эндпоинт ждёт checkout 30с и падает. Отказ в обслуживании против всех сразу —
|
||||
хуже той блокировки учётки, ради отказа от которой всё это писалось.
|
||||
"""
|
||||
fails = _USERNAME_FAIL_LIMITER.record(username)
|
||||
delay_s = _throttle_delay_s(fails)
|
||||
|
|
@ -182,6 +214,10 @@ async def _reject_invalid_credentials(
|
|||
delay_s,
|
||||
ip,
|
||||
)
|
||||
# Соединение — в пул ДО сна (см. docstring). Сессия дальше не нужна:
|
||||
# вызывающий немедленно делает raise, а повторный close() в самой
|
||||
# зависимости идемпотентен.
|
||||
db.close()
|
||||
# await, не time.sleep: событийный цикл в это время обслуживает всех
|
||||
# остальных — тормозим перебор, а не сервис.
|
||||
await asyncio.sleep(delay_s)
|
||||
|
|
@ -221,7 +257,7 @@ async def login(
|
|||
# Пароль проверен ВЫШЕ и безусловно — только теперь смотрим на состояние
|
||||
# доступа. Порядок несущий, а не стилистический: см. модульный docstring.
|
||||
if user is None or not password_ok:
|
||||
raise await _reject_invalid_credentials(body.username, ip, user_agent)
|
||||
raise await _reject_invalid_credentials(db, body.username, ip, user_agent)
|
||||
|
||||
access_state = user["access_state"]
|
||||
if access_state is AccessState.TRIAL_EXPIRED:
|
||||
|
|
@ -247,7 +283,7 @@ async def login(
|
|||
# disabled (и любое нераспознанное состояние — to_access_state fail-closed)
|
||||
# → ТОТ ЖЕ generic 401, то же событие и та же задержка, что при неверном
|
||||
# пароле: заблокированный аккаунт неотличим от несуществующего.
|
||||
raise await _reject_invalid_credentials(body.username, ip, user_agent)
|
||||
raise await _reject_invalid_credentials(db, body.username, ip, user_agent)
|
||||
|
||||
token = create_session(db, user_id=user["user_id"], ip=ip, user_agent=user_agent)
|
||||
|
||||
|
|
|
|||
|
|
@ -445,6 +445,13 @@ def test_throttle_delay_grows_and_caps(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||
assert auth_router._throttle_delay_s(6) == 4.0
|
||||
assert auth_router._throttle_delay_s(7) == 4.0 # потолок
|
||||
assert auth_router._throttle_delay_s(1000) == 4.0
|
||||
# Счётчик ничем не ограничен сверху (`record()` только добавляет метку), а
|
||||
# `min()` вычисляет ОБА аргумента. Без зажатого показателя степени
|
||||
# `float(2 ** (excess - 1))` при ~1045 неудачах падает с OverflowError, и
|
||||
# защита начинает отдавать 500 без задержки и без аудита — ровно под той
|
||||
# нагрузкой, ради которой писалась. 1000 выше проходило впритык под обрывом.
|
||||
assert auth_router._throttle_delay_s(5_000) == 4.0
|
||||
assert auth_router._throttle_delay_s(10**6) == 4.0
|
||||
|
||||
|
||||
def test_distributed_bruteforce_one_username_many_ips_hits_global_ceiling(
|
||||
|
|
@ -500,6 +507,43 @@ def test_throttle_actually_delays_the_response(
|
|||
assert elapsed >= 1.0
|
||||
|
||||
|
||||
def test_db_connection_released_before_sleeping(
|
||||
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Соединение с БД возвращается в пул ДО сна, а не удерживается всю задержку.
|
||||
|
||||
На проде `get_identity_db` в дефолтном режиме отдаёт ту же сессию, что
|
||||
`get_db` (движок с QueuePool 5+10, pool_timeout=30), а `get_user_by_username`
|
||||
оставляет её в открытой транзакции. Сон внутри этой области жизни держал бы
|
||||
соединение занятым все 8с: ~15 одновременно спящих неудач выбирают пул
|
||||
целиком, и дальше ЛЮБОЙ эндпоинт ждёт checkout 30с и падает — отказ в
|
||||
обслуживании против всех, ради ухода от которого замедление и выбиралось
|
||||
вместо блокировки.
|
||||
|
||||
Проверяем порядком, а не мокой пула: если `close()` случился до сна, между
|
||||
ним и концом ответа лежит вся задержка; если бы сессию закрывала только
|
||||
зависимость (то есть после сна) — зазор был бы околонулевым.
|
||||
"""
|
||||
store.add_user("holder", hash_password("Secret123!"), role="employee")
|
||||
_throttle_settings(monkeypatch, threshold=0, max_delay_s=1.0)
|
||||
|
||||
closes: list[float] = []
|
||||
real_close = _FakeDB.close
|
||||
|
||||
def _spy_close(self: _FakeDB) -> None:
|
||||
closes.append(time.monotonic())
|
||||
real_close(self)
|
||||
|
||||
monkeypatch.setattr(_FakeDB, "close", _spy_close)
|
||||
|
||||
resp = client.post("/api/v1/auth/login", json={"username": "holder", "password": "wrong"})
|
||||
finished = time.monotonic()
|
||||
|
||||
assert resp.status_code == 401
|
||||
assert closes, "сессия не закрывалась вовсе"
|
||||
assert finished - closes[0] >= 1.0
|
||||
|
||||
|
||||
def test_typo_does_not_throttle_and_correct_password_still_works(
|
||||
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
|
|
@ -523,6 +567,39 @@ def test_typo_does_not_throttle_and_correct_password_still_works(
|
|||
assert config.settings.session_cookie_name in ok.cookies
|
||||
|
||||
|
||||
def test_counter_decays_when_window_passes(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Вторая половина DoD 2: наказание не накапливается вечно.
|
||||
|
||||
Окно скользящее, старые неудачи выпадают сами — снимать ничего вручную не
|
||||
нужно. Проверяем на самом счётчике, а не через HTTP: один вызов login стоит
|
||||
полного bcrypt (~0.25с), так что игрушечное окно истекало бы прямо посреди
|
||||
цикла запросов и тест мерил бы скорость хеширования, а не спад счётчика.
|
||||
"""
|
||||
_throttle_settings(monkeypatch, threshold=1, max_delay_s=4.0)
|
||||
limiter = auth_router._USERNAME_FAIL_LIMITER
|
||||
monkeypatch.setattr(limiter, "_window_s", 0.2)
|
||||
|
||||
assert [limiter.record("frank") for _ in range(3)] == [1, 2, 3]
|
||||
assert auth_router._throttle_delay_s(3) > 0
|
||||
|
||||
time.sleep(0.25) # окно прошло — прошлые неудачи больше не считаются
|
||||
|
||||
assert limiter.record("frank") == 1
|
||||
assert auth_router._throttle_delay_s(1) == 0.0
|
||||
|
||||
|
||||
def test_username_length_is_bounded(client: TestClient) -> None:
|
||||
"""Сырое имя становится ключом обоих лимитеров, а их словарь чистится только
|
||||
при >10000 ключей и только от пустых корзин — при окне в час чистить нечего.
|
||||
Границу длины держим на 64 (верх CHECK'а реестра), чтобы килобайтные имена
|
||||
не растили память ключами."""
|
||||
resp = client.post("/api/v1/auth/login", json={"username": "x" * 65, "password": "p"})
|
||||
assert resp.status_code == 422
|
||||
# 64 — всё ещё валидная длина, отвечаем обычным generic-отказом.
|
||||
ok_len = client.post("/api/v1/auth/login", json={"username": "x" * 64, "password": "p"})
|
||||
assert ok_len.status_code == 401
|
||||
|
||||
|
||||
def test_throttle_identical_for_existing_and_unknown_username(
|
||||
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue