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

Лимит на логине ключевался парой (username, IP), поэтому распределённый
перебор одного имени с тысячи адресов получал по 5 попыток с каждого
источника и не упирался ни во что. После снятия Caddy basic_auth с
/trade-in (#2558) POST /auth/login — единственная ручка, доступная из
интернета без кредов, так что дыра открыта прямо сейчас.

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

Задержка применяется по ПРИСЛАННОМУ имени, без проверки его в реестре, и
из одного места — общего хвоста всех отказов по кредам. Иначе «быстрый
401» для несуществующего имени стал бы оракулом существования учётки, то
есть ровно той user-enumeration, от которой уже защищают одинаковый
generic-ответ и безусловный bcrypt.
This commit is contained in:
bot-backend 2026-08-05 22:38:23 +05:00
parent 0a001ee3f7
commit 7d154de1f7
4 changed files with 356 additions and 23 deletions

View file

@ -28,12 +28,18 @@ Security:
username с `:` внутри мог бы схлопнуть бюджет с другой (username, ip) username с `:` внутри мог бы схлопнуть бюджет с другой (username, ip)
парой (IPv6-адреса тоже содержат `:`, так что просто эскейпить разделитель парой (IPv6-адреса тоже содержат `:`, так что просто эскейпить разделитель
в username недостаточно паразитная граница возможна с обеих сторон). в username недостаточно паразитная граница возможна с обеих сторон).
- Поверх него ГЛОБАЛЬНЫЙ счётчик неудач на ИМЯ, без IP в ключе (#2571):
лимит по паре (username, IP) распределённый перебор обходит целиком, просто
меняя адрес. Превышение порога не блокирует вход, а замедляет ответ
(`_throttle_delay_s`) см. развёрнутое обоснование там же.
- Raw-пароль НИКОГДА не логируется и не попадает в user_events payload - Raw-пароль НИКОГДА не логируется и не попадает в user_events payload
только username/ip/user_agent/path/method (см. schedule_event ниже). только username/ip/user_agent/path/method и (для неудач) состояние
счётчика попыток: сколько их за окно и какая задержка применена.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
import secrets import secrets
from typing import Annotated from typing import Annotated
@ -63,6 +69,25 @@ _LOGIN_LIMITER = SlidingWindowLimiter(
window_s=settings.login_rate_limit_window_s, window_s=settings.login_rate_limit_window_s,
) )
# Глобальный счётчик неудач НА ИМЯ (#2571) — ключ БЕЗ IP, поэтому попытки со
# всех адресов складываются в один бюджет. Дополняет `_LOGIN_LIMITER`, а не
# заменяет: тот режет частый перебор с одного адреса, этот — редкий, но с
# тысячи адресов (credential stuffing), от которого per-(username, IP) ключ не
# защищает вообще — каждый новый адрес получает свежие login_rate_limit попыток.
#
# Живёт В ПАМЯТИ ПРОЦЕССА — сознательно, а не по недосмотру. Прод-бэкенд
# запущен одним uvicorn-воркером (docker-compose.prod.yml, комментарий над
# `command`: «Single worker сохраняется для предсказуемости»), значит счётчик и
# так глобален, а Redis в auth-пути добавил бы сетевую зависимость там, где её
# падение = либо дыра (fail-open), либо отказ входа (fail-closed).
# Потолок: появятся воркеры (`--workers N`) — потолок делится на N, и его надо
# переносить в Redis (`app.services.cache` уже держит там пул). Тот же ceiling
# у соседнего `_LOGIN_LIMITER`; перезапуск процесса обнуляет оба.
_USERNAME_FAIL_LIMITER = SlidingWindowLimiter(
limit=settings.login_username_fail_threshold,
window_s=settings.login_username_fail_window_s,
)
# Timing-oracle защита (см. module docstring): bcrypt-хеш случайного пароля, # Timing-oracle защита (см. module docstring): bcrypt-хеш случайного пароля,
# сгенерированный ОДИН РАЗ на импорте модуля — используется вместо # сгенерированный ОДИН РАЗ на импорте модуля — используется вместо
# password_hash, когда юзер не найден/деактивирован/без пароля, чтобы # password_hash, когда юзер не найден/деактивирован/без пароля, чтобы
@ -92,6 +117,78 @@ class LoginResponse(BaseModel):
ok: bool = True ok: bool = True
def _throttle_delay_s(fails_in_window: int) -> float:
"""Насколько задержать ответ на неудачный вход при *fails_in_window* неудачах
по этому имени за окно. 0 пока порог не перебран.
Замедление, а НЕ блокировка намеренно. Жёсткая блокировка учётки после N
неудач лечится злоумышленником в свою пользу: не зная ни одного пароля, он
гарантированно выключает вход конкретному человеку (директору, админу)
отказ в обслуживании дешевле и надёжнее, чем то, от чего блокировка
защищает. Задержка же не отнимает доступ ни у кого: владелец пароля войдёт
с первой попытки, просто ответ на очередную НЕУДАЧУ придёт медленнее.
Рост удвоением от 1с с потолком `login_username_throttle_max_delay_s`:
первые перебранные попытки почти незаметны, а сотни упираются в потолок.
Потолок обязателен: без него задержка становится той же блокировкой, только
растянутой во времени.
"""
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)))
async def _reject_invalid_credentials(
username: str, ip: str, user_agent: str | None
) -> HTTPException:
"""Единый хвост ЛЮБОГО отказа по кредам: счётчик → аудит → задержка → 401.
Один код на все ветки отказа (нет такого имени / неверный пароль / доступ
закрыт / password_hash NULL) это не борьба с дублированием, а инвариант:
ветки обязаны быть неразличимы снаружи. Разъедься они по телу хендлера
и достаточно забыть задержку в одной, чтобы «быстрый 401» стал оракулом
существования учётки ровно в том же виде, что и разные сообщения об ошибке.
Поэтому счётчик ведётся по ПРИСЛАННОМУ имени, без проверки, есть ли такое
в реестре: несуществующее имя копит неудачи и тормозит так же, как живое.
(`get_user_by_username` сверяет `username = :username` по text-колонке без
нормализации, так что сырое имя тот же ключ, что и у поиска: регистром
счётчик не обойти.)
Возвращает `HTTPException`, а не бросает: `raise await ` не собирается, а
`raise (await )` читается хуже, чем `raise` над возвращённым значением.
"""
fails = _USERNAME_FAIL_LIMITER.record(username)
delay_s = _throttle_delay_s(fails)
schedule_event(
event_type="login_failed",
username=username,
ip=ip,
user_agent=user_agent,
path="/api/v1/auth/login",
method="POST",
# Состояние глобального счётчика — в аудит: по нему в user_events видно
# именно РАСПРЕДЕЛЁННЫЙ перебор (десятки неудач по одному имени с разных
# ip_address), который иначе выглядит как россыпь одиночных неудач.
payload={"username_fails_in_window": fails, "throttle_delay_s": delay_s},
)
if delay_s > 0:
logger.warning(
"login throttle: username=%r fails=%d delay=%.1fs ip=%s",
username,
fails,
delay_s,
ip,
)
# await, не time.sleep: событийный цикл в это время обслуживает всех
# остальных — тормозим перебор, а не сервис.
await asyncio.sleep(delay_s)
return HTTPException(status_code=401, detail=_INVALID_CREDENTIALS_DETAIL)
@router.post("/login", response_model=LoginResponse) @router.post("/login", response_model=LoginResponse)
async def login( async def login(
body: LoginRequest, body: LoginRequest,
@ -124,15 +221,7 @@ async def login(
# Пароль проверен ВЫШЕ и безусловно — только теперь смотрим на состояние # Пароль проверен ВЫШЕ и безусловно — только теперь смотрим на состояние
# доступа. Порядок несущий, а не стилистический: см. модульный docstring. # доступа. Порядок несущий, а не стилистический: см. модульный docstring.
if user is None or not password_ok: if user is None or not password_ok:
schedule_event( raise await _reject_invalid_credentials(body.username, ip, user_agent)
event_type="login_failed",
username=body.username,
ip=ip,
user_agent=user_agent,
path="/api/v1/auth/login",
method="POST",
)
raise HTTPException(status_code=401, detail=_INVALID_CREDENTIALS_DETAIL)
access_state = user["access_state"] access_state = user["access_state"]
if access_state is AccessState.TRIAL_EXPIRED: if access_state is AccessState.TRIAL_EXPIRED:
@ -156,17 +245,9 @@ async def login(
if not access_state.can_sign_in: if not access_state.can_sign_in:
# disabled (и любое нераспознанное состояние — to_access_state fail-closed) # disabled (и любое нераспознанное состояние — to_access_state fail-closed)
# → ТОТ ЖЕ generic 401 и то же событие, что при неверном пароле: # → ТОТ ЖЕ generic 401, то же событие и та же задержка, что при неверном
# заблокированный аккаунт неотличим от несуществующего. # пароле: заблокированный аккаунт неотличим от несуществующего.
schedule_event( raise await _reject_invalid_credentials(body.username, ip, user_agent)
event_type="login_failed",
username=body.username,
ip=ip,
user_agent=user_agent,
path="/api/v1/auth/login",
method="POST",
)
raise HTTPException(status_code=401, detail=_INVALID_CREDENTIALS_DETAIL)
token = create_session(db, user_id=user["user_id"], ip=ip, user_agent=user_agent) token = create_session(db, user_id=user["user_id"], ip=ip, user_agent=user_agent)

View file

@ -95,6 +95,27 @@ class Settings(BaseSettings):
login_rate_limit_window_s: int = Field( login_rate_limit_window_s: int = Field(
default=300, validation_alias="LOGIN_RATE_LIMIT_WINDOW_S" default=300, validation_alias="LOGIN_RATE_LIMIT_WINDOW_S"
) )
# Глобальный (независимый от IP) счётчик неудачных входов НА ИМЯ (#2571).
# Лимит выше по паре (username, IP) распределённый перебор обходит: с каждого
# нового адреса ему дают свежие login_rate_limit попыток. Здесь ключ — ТОЛЬКО
# имя, поэтому попытки со всех адресов складываются.
#
# Превышение порога НЕ блокирует учётку (это был бы вектор DoS против
# конкретного человека — злоумышленник выключал бы чужой вход по своему
# желанию), а растит задержку ответа: 1с, 2с, 4с… до потолка. Порог 20/час
# выбран так, чтобы живой человек с опечатками до него не доходил.
login_username_fail_threshold: int = Field(
default=20, validation_alias="LOGIN_USERNAME_FAIL_THRESHOLD"
)
login_username_fail_window_s: int = Field(
default=3600, validation_alias="LOGIN_USERNAME_FAIL_WINDOW_S"
)
# Потолок задержки одного ответа. Держим невысоким сознательно: задержка —
# это ещё и цена, которую платит легитимный владелец имени, пока его
# перебирают. 8с ощутимо режут перебор, но не выглядят как «сайт лёг».
login_username_throttle_max_delay_s: float = Field(
default=8.0, validation_alias="LOGIN_USERNAME_THROTTLE_MAX_DELAY_S"
)
# ── Эпик «единый вход»: общий реестр людей в БД `auth` ───────────────────── # ── Эпик «единый вход»: общий реестр людей в БД `auth` ─────────────────────
# DSN БД `auth` (роль auth_app) — единый реестр людей «Меры» (trade-in) и # DSN БД `auth` (роль auth_app) — единый реестр людей «Меры» (trade-in) и

View file

@ -114,8 +114,13 @@ class SlidingWindowLimiter:
return self._window_s - (now - bucket[0]) return self._window_s - (now - bucket[0])
return None return None
def record(self, key: str) -> None: def record(self, key: str) -> int:
"""Регистрирует одну успешную попытку под *key*.""" """Регистрирует одну попытку под *key* и возвращает их число в окне ПОСЛЕ неё.
Счётчик нужен вызывающим, которым мало булева «за лимитом / нет»: login
(#2571) по нему считает НАСКОЛЬКО перебран порог и растит задержку ответа
пропорционально. Значение можно игнорировать `check()` так и делает.
"""
now = time.monotonic() now = time.monotonic()
bucket = self._hits[key] bucket = self._hits[key]
self._prune(bucket, now) self._prune(bucket, now)
@ -125,6 +130,7 @@ class SlidingWindowLimiter:
if len(self._hits) > 10000: if len(self._hits) > 10000:
for k in [k for k, v in self._hits.items() if not v]: for k in [k for k, v in self._hits.items() if not v]:
del self._hits[k] del self._hits[k]
return len(bucket)
def check(self, key: str) -> float | None: def check(self, key: str) -> float | None:
"""Комбинированная проверка+регистрация (peek+record за один вызов) — """Комбинированная проверка+регистрация (peek+record за один вызов) —

View file

@ -33,6 +33,7 @@ from __future__ import annotations
import os import os
import re import re
import time
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from types import SimpleNamespace from types import SimpleNamespace
from typing import Annotated, Any from typing import Annotated, Any
@ -254,6 +255,7 @@ def _build_test_app(store: _Store) -> FastAPI:
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None: def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
auth_mod.reset_cache_for_tests() auth_mod.reset_cache_for_tests()
auth_router._LOGIN_LIMITER._hits.clear() auth_router._LOGIN_LIMITER._hits.clear()
auth_router._USERNAME_FAIL_LIMITER._hits.clear()
monkeypatch.setattr(config.settings, "auth_mode", "dual") monkeypatch.setattr(config.settings, "auth_mode", "dual")
# Каждый тест стартует в ДЕФОЛТНОМ режиме реестра (сегодняшний прод), даже # Каждый тест стартует в ДЕФОЛТНОМ режиме реестра (сегодняшний прод), даже
# если предыдущий переключался на `auth`. # если предыдущий переключался на `auth`.
@ -405,6 +407,229 @@ def test_login_rate_limit_429(client: TestClient, store: _Store) -> None:
assert "Retry-After" in resp.headers assert "Retry-After" in resp.headers
# ---------------------------------------------------------------------------
# #2571 — глобальный потолок попыток НА ИМЯ (распределённый перебор)
# ---------------------------------------------------------------------------
def _throttle_settings(
monkeypatch: pytest.MonkeyPatch, *, threshold: int, max_delay_s: float
) -> None:
"""Порог и потолок задержки под тест: реальные 20/час и 8с ждать некогда."""
monkeypatch.setattr(config.settings, "login_username_fail_threshold", threshold)
monkeypatch.setattr(config.settings, "login_username_throttle_max_delay_s", max_delay_s)
def _capture_events(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
events: list[dict[str, Any]] = []
monkeypatch.setattr(auth_router, "schedule_event", lambda **kw: events.append(kw))
return events
def _failed_login_delays(events: list[dict[str, Any]]) -> list[float]:
return [e["payload"]["throttle_delay_s"] for e in events if e["event_type"] == "login_failed"]
def test_throttle_delay_grows_and_caps(monkeypatch: pytest.MonkeyPatch) -> None:
"""Задержка = 0 до порога, дальше удваивается от 1с и упирается в потолок.
Потолок обязателен: без него растущая задержка вырождается в ту самую
блокировку учётки, которой #2571 сознательно избегает (DoS против человека).
"""
_throttle_settings(monkeypatch, threshold=3, max_delay_s=4.0)
assert auth_router._throttle_delay_s(1) == 0.0
assert auth_router._throttle_delay_s(3) == 0.0 # ровно порог — ещё не перебор
assert auth_router._throttle_delay_s(4) == 1.0
assert auth_router._throttle_delay_s(5) == 2.0
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
def test_distributed_bruteforce_one_username_many_ips_hits_global_ceiling(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
) -> None:
"""DoD 1: перебор одного имени с РАЗНЫХ адресов упирается в потолок на имя.
Каждая попытка приходит со своего IP, поэтому per-(username, IP) лимит
(`_LOGIN_LIMITER`, 5/300с) не срабатывает НИ РАЗУ ровно та дыра, из-за
которой issue заведена. Ловит её только глобальный счётчик на имя.
"""
store.add_user("victim", hash_password("Secret123!"), role="employee")
_throttle_settings(monkeypatch, threshold=3, max_delay_s=0.01)
events = _capture_events(monkeypatch)
for i in range(6):
resp = client.post(
"/api/v1/auth/login",
json={"username": "victim", "password": f"guess{i}"},
headers={"x-forwarded-for": f"10.0.0.{i}"},
)
# per-IP бюджет не исчерпан ни на одном адресе — никаких 429.
assert resp.status_code == 401, resp.text
delays = _failed_login_delays(events)
assert len(delays) == 6
# Первые три — в пределах порога, дальше замедление.
assert delays[:3] == [0.0, 0.0, 0.0]
assert all(d > 0 for d in delays[3:])
# Счётчик глобальный: считает попытки со ВСЕХ адресов, а не по каждому свой.
fails = [e["payload"]["username_fails_in_window"] for e in events]
assert fails == [1, 2, 3, 4, 5, 6]
def test_throttle_actually_delays_the_response(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Задержка именно ЖДЁТСЯ, а не только пишется в аудит.
Отдельный тест, потому что все остальные читают величину задержки из
события: без этой проверки реализация, которая посчитала задержку и забыла
`await`, оставила бы весь набор зелёным.
"""
store.add_user("slowpoke", hash_password("Secret123!"), role="employee")
# threshold=0 → тормозим уже первую неудачу; потолок 1с — цена одного теста.
_throttle_settings(monkeypatch, threshold=0, max_delay_s=1.0)
started = time.monotonic()
resp = client.post("/api/v1/auth/login", json={"username": "slowpoke", "password": "wrong"})
elapsed = time.monotonic() - started
assert resp.status_code == 401
assert elapsed >= 1.0
def test_typo_does_not_throttle_and_correct_password_still_works(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
) -> None:
"""DoD 2: живой человек с опечаткой не наказан.
Порог штатный (20/час), три опечатки до него не дотягивают задержки нет,
а верный пароль пускает сразу же. Замедление применяется ТОЛЬКО к неудаче,
поэтому даже перебранный порог не мешает владельцу пароля войти.
"""
store.add_user("erin", hash_password("Secret123!"), role="employee")
events = _capture_events(monkeypatch)
for _ in range(3):
resp = client.post("/api/v1/auth/login", json={"username": "erin", "password": "Secret123"})
assert resp.status_code == 401
assert _failed_login_delays(events) == [0.0, 0.0, 0.0]
ok = client.post("/api/v1/auth/login", json={"username": "erin", "password": "Secret123!"})
assert ok.status_code == 200, ok.text
assert config.settings.session_cookie_name in ok.cookies
def test_throttle_identical_for_existing_and_unknown_username(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Замедление не должно стать оракулом существования учётки.
Если тормозить только существующие имена, злоумышленник перечисляет живые
логины по времени ответа, не зная ни одного пароля та же user-enumeration,
от которой защищают одинаковый 401 и безусловный bcrypt (`_DUMMY_PASSWORD_HASH`),
только по другому каналу. Счётчик ведётся по присланному имени БЕЗ проверки
в реестре, поэтому обе последовательности обязаны совпасть.
"""
store.add_user("real", hash_password("Secret123!"), role="employee")
_throttle_settings(monkeypatch, threshold=2, max_delay_s=0.01)
events = _capture_events(monkeypatch)
real_responses = []
for i in range(4):
real_responses.append(
client.post(
"/api/v1/auth/login",
json={"username": "real", "password": "wrong"},
headers={"x-forwarded-for": f"10.1.0.{i}"},
)
)
real_delays = _failed_login_delays(events)
events.clear()
ghost_responses = []
for i in range(4):
ghost_responses.append(
client.post(
"/api/v1/auth/login",
json={"username": "ghost", "password": "wrong"},
headers={"x-forwarded-for": f"10.2.0.{i}"},
)
)
ghost_delays = _failed_login_delays(events)
assert real_delays == ghost_delays
assert real_delays[:2] == [0.0, 0.0] and all(d > 0 for d in real_delays[2:])
# И «наблюдаемая» часть ответа тоже неотличима.
assert [r.status_code for r in real_responses] == [r.status_code for r in ghost_responses]
assert {r.json()["detail"] for r in real_responses} == {
r.json()["detail"] for r in ghost_responses
}
def test_disabled_account_throttled_like_wrong_password(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Отказ по «доступ закрыт» идёт тем же хвостом: счётчик, аудит, задержка.
Иначе быстрый 401 у заблокированной учётки отличал бы её от несуществующей.
"""
store.add_user(
"blocked", hash_password("Secret123!"), role="employee", access_state=AccessState.DISABLED
)
_throttle_settings(monkeypatch, threshold=1, max_delay_s=0.01)
events = _capture_events(monkeypatch)
for i in range(2):
# Пароль ВЕРНЫЙ — отказ приходит именно из ветки состояния доступа.
resp = client.post(
"/api/v1/auth/login",
json={"username": "blocked", "password": "Secret123!"},
headers={"x-forwarded-for": f"10.3.0.{i}"},
)
assert resp.status_code == 401
assert _failed_login_delays(events) == [0.0, pytest.approx(0.01)]
def test_failed_login_events_reach_audit_with_counter_state(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
) -> None:
"""DoD 3: неудачные входы видны в аудите — с ip/ua и состоянием счётчика.
Именно счётчик отличает распределённый перебор от россыпи случайных
одиночных неудач: в `user_events` иначе видно только «по одной неудаче с
каждого адреса», что само по себе не тревожно.
"""
store.add_user("audited", hash_password("Secret123!"), role="employee")
events = _capture_events(monkeypatch)
resp = client.post(
"/api/v1/auth/login",
json={"username": "audited", "password": "s3cret-typo"},
# Левый хоп подделан клиентом, правый добавлен Caddy — в аудит обязан
# попасть ПРАВЫЙ (`ratelimit._client_ip`, допущение «ровно один прокси»).
# Иначе злоумышленник переписывал бы себе ip в журнале одним заголовком.
headers={
"x-forwarded-for": "198.51.100.9, 203.0.113.7",
"user-agent": "probe/1.0",
},
)
assert resp.status_code == 401
(failed,) = (e for e in events if e["event_type"] == "login_failed")
assert failed["username"] == "audited"
assert failed["ip"] == "203.0.113.7"
assert failed["user_agent"] == "probe/1.0"
assert failed["path"] == "/api/v1/auth/login"
assert failed["payload"]["username_fails_in_window"] == 1
# Raw-пароль не утекает ни в поля события, ни в payload.
assert "s3cret-typo" not in str(failed)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# POST /logout # POST /logout
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------