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)
парой (IPv6-адреса тоже содержат `:`, так что просто эскейпить разделитель
в username недостаточно паразитная граница возможна с обеих сторон).
- Поверх него ГЛОБАЛЬНЫЙ счётчик неудач на ИМЯ, без IP в ключе (#2571):
лимит по паре (username, IP) распределённый перебор обходит целиком, просто
меняя адрес. Превышение порога не блокирует вход, а замедляет ответ
(`_throttle_delay_s`) см. развёрнутое обоснование там же.
- Raw-пароль НИКОГДА не логируется и не попадает в user_events payload
только username/ip/user_agent/path/method (см. schedule_event ниже).
только username/ip/user_agent/path/method и (для неудач) состояние
счётчика попыток: сколько их за окно и какая задержка применена.
"""
from __future__ import annotations
import asyncio
import logging
import secrets
from typing import Annotated
@ -63,6 +69,25 @@ _LOGIN_LIMITER = SlidingWindowLimiter(
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-хеш случайного пароля,
# сгенерированный ОДИН РАЗ на импорте модуля — используется вместо
# password_hash, когда юзер не найден/деактивирован/без пароля, чтобы
@ -92,6 +117,78 @@ class LoginResponse(BaseModel):
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)
async def login(
body: LoginRequest,
@ -124,15 +221,7 @@ async def login(
# Пароль проверен ВЫШЕ и безусловно — только теперь смотрим на состояние
# доступа. Порядок несущий, а не стилистический: см. модульный docstring.
if user is None or not password_ok:
schedule_event(
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)
raise await _reject_invalid_credentials(body.username, ip, user_agent)
access_state = user["access_state"]
if access_state is AccessState.TRIAL_EXPIRED:
@ -156,17 +245,9 @@ async def login(
if not access_state.can_sign_in:
# disabled (и любое нераспознанное состояние — to_access_state fail-closed)
# → ТОТ ЖЕ generic 401 и то же событие, что при неверном пароле:
# заблокированный аккаунт неотличим от несуществующего.
schedule_event(
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)
# → ТОТ ЖЕ generic 401, то же событие и та же задержка, что при неверном
# пароле: заблокированный аккаунт неотличим от несуществующего.
raise await _reject_invalid_credentials(body.username, ip, 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(
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` ─────────────────────
# DSN БД `auth` (роль auth_app) — единый реестр людей «Меры» (trade-in) и

View file

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

View file

@ -33,6 +33,7 @@ from __future__ import annotations
import os
import re
import time
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from typing import Annotated, Any
@ -254,6 +255,7 @@ def _build_test_app(store: _Store) -> FastAPI:
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
auth_mod.reset_cache_for_tests()
auth_router._LOGIN_LIMITER._hits.clear()
auth_router._USERNAME_FAIL_LIMITER._hits.clear()
monkeypatch.setattr(config.settings, "auth_mode", "dual")
# Каждый тест стартует в ДЕФОЛТНОМ режиме реестра (сегодняшний прод), даже
# если предыдущий переключался на `auth`.
@ -405,6 +407,229 @@ def test_login_rate_limit_429(client: TestClient, store: _Store) -> None:
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
# ---------------------------------------------------------------------------