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
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:
parent
0a001ee3f7
commit
7d154de1f7
4 changed files with 356 additions and 23 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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) и
|
||||
|
|
|
|||
|
|
@ -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 за один вызов) —
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue