diff --git a/tradein-mvp/backend/app/api/v1/auth.py b/tradein-mvp/backend/app/api/v1/auth.py index a839d936..96b4cd90 100644 --- a/tradein-mvp/backend/app/api/v1/auth.py +++ b/tradein-mvp/backend/app/api/v1/auth.py @@ -28,18 +28,24 @@ 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 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 @@ -63,6 +69,32 @@ _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`; перезапуск процесса обнуляет оба. +# +# ⚠️ `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, +) + # Timing-oracle защита (см. module docstring): bcrypt-хеш случайного пароля, # сгенерированный ОДИН РАЗ на импорте модуля — используется вместо # password_hash, когда юзер не найден/деактивирован/без пароля, чтобы @@ -84,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 @@ -92,6 +132,99 @@ 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`: + первые перебранные попытки почти незаметны, а сотни — упираются в потолок. + Потолок обязателен: без него задержка становится той же блокировкой, только + растянутой во времени. + + Показатель степени зажат (`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, 2.0 ** min(excess - 1, 16)) + + +async def _reject_invalid_credentials( + db: Session, 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` над возвращённым значением. + + *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) + + 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, + ) + # Соединение — в пул ДО сна (см. docstring). Сессия дальше не нужна: + # вызывающий немедленно делает raise, а повторный close() в самой + # зависимости идемпотентен. + db.close() + # 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 +257,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(db, body.username, ip, user_agent) access_state = user["access_state"] if access_state is AccessState.TRIAL_EXPIRED: @@ -156,17 +281,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(db, body.username, ip, user_agent) token = create_session(db, user_id=user["user_id"], ip=ip, user_agent=user_agent) diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index e8d888ca..60ee2d8e 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -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) и diff --git a/tradein-mvp/backend/app/core/ratelimit.py b/tradein-mvp/backend/app/core/ratelimit.py index 2b809ddb..f5f3fe04 100644 --- a/tradein-mvp/backend/app/core/ratelimit.py +++ b/tradein-mvp/backend/app/core/ratelimit.py @@ -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 за один вызов) — diff --git a/tradein-mvp/backend/tests/test_auth_api.py b/tradein-mvp/backend/tests/test_auth_api.py index 6f3d3da1..52341829 100644 --- a/tradein-mvp/backend/tests/test_auth_api.py +++ b/tradein-mvp/backend/tests/test_auth_api.py @@ -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,306 @@ 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 + # Счётчик ничем не ограничен сверху (`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( + 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_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: + """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_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: + """Замедление не должно стать оракулом существования учётки. + + Если тормозить только существующие имена, злоумышленник перечисляет живые + логины по времени ответа, не зная ни одного пароля — та же 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 # ---------------------------------------------------------------------------