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
4 changed files with 471 additions and 25 deletions

View file

@ -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)

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,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
# ---------------------------------------------------------------------------