fix(tradein/auth): отказ по насыщению — до выборки из БД и с агрегированным следом (#2715) #2734

Merged
bot-backend merged 3 commits from fix/2715-auth-hardening-tail into main 2026-08-06 14:27:27 +00:00
5 changed files with 394 additions and 18 deletions

View file

@ -58,6 +58,12 @@ async def list_accounts(
count(*) FILTER (WHERE event_type = 'api_request') AS request_count,
count(*) FILTER (WHERE event_type = 'estimate_request') AS search_count
FROM user_events
-- Событие без имени не аккаунт (#2715: `login_verify_saturated`
-- пишется с пустым именем намеренно отказ случается ДО того, как
-- на имя посмотрели). Без фильтра строка встала бы ПЕРВОЙ (её
-- last_seen_at момент атаки), а её кнопка в UI раскрывалась бы в
-- /audit/accounts/{username} с `min_length=1`, то есть в ошибку.
WHERE username <> ''
GROUP BY username
ORDER BY last_seen_at DESC
"""
@ -182,12 +188,16 @@ async def analytics_dashboard(
db.execute(
text(
"""
-- NULLIF(username, ''): безымянные события (#2715) — СОБЫТИЯ, они
-- честно входят в total_events, но не люди: count(DISTINCT) их
-- игнорирует по NULL, иначе первая же атака навсегда добавила бы
-- фантомного пользователя в счётчик уникальных.
SELECT count(*) AS total_events,
count(DISTINCT username) AS distinct_users,
count(DISTINCT NULLIF(username, '')) AS distinct_users,
count(*) FILTER (
WHERE created_at >= now() - INTERVAL '24 hours'
) AS events_last_24h,
count(DISTINCT username) FILTER (
count(DISTINCT NULLIF(username, '')) FILTER (
WHERE created_at >= now() - INTERVAL '24 hours'
) AS active_users_last_24h
FROM user_events
@ -204,7 +214,7 @@ async def analytics_dashboard(
"""
SELECT date_trunc('day', created_at)::date AS day,
count(*) AS events,
count(DISTINCT username) AS users
count(DISTINCT NULLIF(username, '')) AS users -- см. выше (#2715)
FROM user_events
WHERE created_at >= now() - make_interval(days => CAST(:days AS int))
GROUP BY date_trunc('day', created_at)::date
@ -261,6 +271,7 @@ async def analytics_dashboard(
count(*) FILTER (WHERE event_type = 'estimate_request') AS searches,
max(created_at) AS last_seen
FROM user_events
WHERE username <> '' -- не аккаунт, см. /audit/accounts выше (#2715)
GROUP BY username
ORDER BY events DESC
LIMIT 50

View file

@ -40,6 +40,10 @@ Security:
флуда получал 429 столько раз, сколько пытался. Ключ IP, поэтому защита
поднимает стоимость атаки, но не закрывает её (подделка за вторым прокси,
общий адрес за NAT, ротация через ботнет) см. docstring той же функции.
Отказ по насыщению выдаётся ДО выборки из реестра (#2715): иначе на этом
пути оставалась бы единственная работа, время которой зависит от того,
существует ли имя, а bcrypt, который эту разницу ровняет, до него уже не
доходит. След инцидента агрегированный, `_saturated_429`.
- Поверх него ГЛОБАЛЬНЫЙ счётчик неудач на ИМЯ, без IP в ключе (#2571):
лимит по паре (username, IP) распределённый перебор обходит целиком, просто
меняя адрес. Превышение порога не блокирует вход, а замедляет ответ
@ -54,6 +58,7 @@ from __future__ import annotations
import asyncio
import logging
import secrets
import time
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, Response
@ -61,7 +66,12 @@ from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.password import PasswordVerifyOverloadedError, hash_password, verify_password_bounded
from app.core.password import (
PasswordVerifyOverloadedError,
hash_password,
verify_password_bounded,
verify_slots_saturated,
)
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
from app.services.auth_session import create_session, get_user_by_username, revoke_session
from app.services.identity_store import AccessState, get_identity_db
@ -174,6 +184,115 @@ def _throttle_delay_s(fails_in_window: int) -> float:
return min(settings.login_username_throttle_max_delay_s, 2.0 ** min(excess - 1, 16))
# Не чаще одной записи в это окно на ВСЕ отказы по насыщению (#2715). Окно, а не
# запись на запрос, потому что лог у бэкенда общий и ограниченный (docker
# json-file, max-size 20m × max-file 3): при флуде в сотни запросов в секунду
# строка на каждый отказ прокручивает 60 МБ за минуты и выселяет ВСЕ остальные
# логи ровно во время атаки — то есть в момент, когда они нужнее всего.
# Значение не в настройках намеренно: это не тюнинг, а «человек читает лог», и
# крутить его нечем — меньше секунды возвращает исходную проблему, больше
# ухудшает разрешение по времени, не давая взамен ничего.
_SATURATION_REPORT_WINDOW_S = 1.0
# Отказов с прошлой записи и когда была прошлая запись (monotonic; None — записи
# ещё не было). Обычные глобалы без лока — по той же причине, что и счётчик
# слотов в `app.core.password`: обе строчки исполняются в потоке событийного
# цикла и между чтением и записью нет `await`.
_saturation_rejected = 0
_saturation_reported_at: float | None = None
def _saturated_429(ip: str) -> HTTPException:
"""429 «слоты сверки заняты» + АГРЕГИРОВАННЫЙ след инцидента.
Событие неудачного входа тут не пишется и бюджет неудач по имени не
тратится сознательно (#2712): пароль не проверялся, это не попытка входа, а
трата бюджета означала бы, что насыщением можно заблокировать чужую учётку.
Но тогда весь инцидент виден ровно здесь, и до #2715 — только строкой в
логе на каждый отклонённый запрос (см. `_SATURATION_REPORT_WINDOW_S`).
Поэтому на окно приходится одна строка в лог И одно событие
`login_verify_saturated` в `user_events` с числом отказов, накопленных с
прошлой записи. Событие важнее строки: аудит переживает и ротацию логов, и
редеплой. Первый отказ отчитывается сразу, а не в конце окна: одиночная
аномалия обязана быть видна, даже если продолжения не будет.
`since_prev_s` в payload НЕ дубль `created_at`, а единственный способ
прочитать счётчик правильно. Хвост копится, пока не придёт следующий отказ:
атака кончилась в 03:00, 900 отказов остались неотчитанными и во вторник
одиночный 429 соседа по NAT унёс бы их все в запись, датированную вторником
и подписанную АДРЕСОМ СОСЕДА. С `since_prev_s` видно, что 901 отказ
накоплен за неделю, а не за секунду, и что читать `ip` в этой записи не
надо. `None` первая запись за жизнь процесса, сравнивать не с чем.
Уровень ERROR, а не WARNING, не косметика: бэкенд поднят с
`LoggingIntegration(level=INFO, event_level=ERROR)` (app/main.py), то есть
ровно с ERROR запись становится событием GlitchTip, а WARNING остаётся
строкой в docker-логе, которая умирает с ротацией и редеплоем. Цена
прецедента известна (#2674): монитор писал WARNING про протухшие куки — и
событий было ноль. Спама не будет: запись не чаще раза в окно, и все они
группируются в один issue (шаблон сообщения один).
Чего это НЕ делает: у GlitchTip-проекта нет ни правил, ни получателей
(#2673), так что уведомление никому не уйдёт — событие будет видно в
интерфейсе, но не в чьём-то телефоне. Проверить доставку поведенчески
сейчас не на чем, и утверждать её здесь было бы враньём.
`username=""` не заглушка: имя не пишем ПОТОМУ, что отказ случился до
того, как мы на него посмотрели. Записывай мы присланное, атакующий
наполнял бы аудит строками с любым именем на выбор. Пустое имя не аккаунт,
и списки аудита его отфильтровывают (`WHERE username <> ''` в
`app/api/v1/audit.py`), иначе оно встало бы первой строкой в списке
аккаунтов и фантомом в `count(DISTINCT username)`. `ip` адрес последнего
отклонённого запроса, то есть ОБРАЗЕЦ: при распределённом флуде адресов
много, и по одной записи их не восстановить (счётчик восстановит).
Потолок объёма: час непрерывной атаки это 3600 строк в `user_events`
(в таблице за всю её жизнь ~3.4 тысячи), сутки под 86 тысяч. Retention у
таблицы нет, а `GET /audit/accounts` делает полный `GROUP BY` без фильтра по
времени. То же давление уходит на квоту проекта в GlitchTip тот же
механизм вытеснения чужого сигнала, только в другом ведре. Дойдёт до этого
окно агрегации растёт с длительностью атаки (экспонента с потолком, как у
`_throttle_delay_s`), это следующий шаг, а не сегодняшний.
"""
global _saturation_rejected, _saturation_reported_at
_saturation_rejected += 1
now = time.monotonic()
since_prev = None if _saturation_reported_at is None else now - _saturation_reported_at
if since_prev is None or since_prev >= _SATURATION_REPORT_WINDOW_S:
rejected, _saturation_rejected = _saturation_rejected, 0
_saturation_reported_at = now
logger.error(
"login rejected: password verify saturated — %d отказов, "
"с прошлой записи %s с, последний ip=%s",
rejected,
"" if since_prev is None else f"{since_prev:.1f}",
ip,
)
schedule_event(
event_type="login_verify_saturated",
username="",
ip=ip,
path="/api/v1/auth/login",
method="POST",
payload={
"rejected": rejected,
# Считается ДО сдвига `_saturation_reported_at` — иначе всегда 0.
"since_prev_s": None if since_prev is None else round(since_prev, 1),
},
)
# Retry-After 1с — порядок времени одной сверки, не окно соседнего
# `_LOGIN_LIMITER`. Ответ ОДИН И ТОТ ЖЕ для любого имени: отказ приходит до
# сверки и потому ничего не сообщает о том, существует ли учётка.
return HTTPException(
status_code=429,
detail="слишком много попыток входа, попробуйте позже",
headers={"Retry-After": "1"},
)
async def _reject_invalid_credentials(
db: Session, username: str, ip: str, user_agent: str | None
) -> HTTPException:
@ -256,6 +375,16 @@ async def login(
headers={"Retry-After": str(int(retry_after) + 1)},
)
# Гейт насыщения — ДО выборки из реестра (#2715). Заведомо отклоняемый
# запрос не берёт соединение из пула и не делает SELECT по имени: под
# насыщением это была бы единственная работа на пути отказа, а значит и
# единственное, чьё время зависит от существования учётки — bcrypt, который
# эту разницу ровняет, до отказанного запроса не доходит вовсе. Решение
# всё равно остаётся за `verify_password_bounded` ниже (тот же предикат,
# `except` под ним никуда не делся) — здесь только экономия похода в базу.
if verify_slots_saturated(ip):
raise _saturated_429(ip)
user = get_user_by_username(db, body.username)
hash_to_check = (
user["password_hash"]
@ -274,17 +403,11 @@ async def login(
password_ok = await verify_password_bounded(body.password, hash_to_check, key=ip)
except PasswordVerifyOverloadedError:
# Настоящий потолок темпа (#2665): слоты проверки заняты, ждать нельзя —
# ждущий держит соединение к БД. Отказ ОДИНАКОВ для любого имени и
# случается ДО сверки, поэтому оракулом существования учётки не служит и
# бюджет неудач по имени не тратит (это не попытка входа: пароль не
# проверялся). Retry-After 1с — порядок времени одной проверки, не окно
# соседнего `_LOGIN_LIMITER`.
logger.warning("login rejected: password verify saturated ip=%s", ip)
raise HTTPException(
status_code=429,
detail="слишком много попыток входа, попробуйте позже",
headers={"Retry-After": "1"},
) from None
# ждущий держит соединение к БД. Предчек выше сюда почти всё и отсекает,
# но авторитетен ИМЕННО ЭТОТ отказ, поэтому ветка остаётся. Ответ —
# тот же самый и с той же аргументацией, что у предчека: один helper,
# чтобы две ветки не разъехались (одинаковость 429 — часть защиты).
raise _saturated_429(ip) from None
# Пароль проверен ВЫШЕ и безусловно — только теперь смотрим на состояние
# доступа. Порядок несущий, а не стилистический: см. модульный docstring.

View file

@ -123,6 +123,32 @@ def _per_key_slot_cap() -> int:
return max(1, settings.login_password_verify_max_inflight // 2)
def verify_slots_saturated(key: str) -> bool:
"""Тот же предикат, по которому отказывает `verify_password_bounded`, но БЕЗ взятия слота.
Нужен вызывающему ровно затем, чтобы отказать ДО похода в БД (#2715). Гейт
стоял ПОСЛЕ выборки пользователя, и каждый заведомо отклоняемый запрос всё
равно брал соединение из пула и делал SELECT по имени тогда, когда система
уже перегружена. Хуже того, под насыщением эта выборка оставалась
ЕДИНСТВЕННОЙ работой на пути отказа: bcrypt, который ровняет время ответа
для существующего и несуществующего имени, ниже по течению и до него не
доходит, так что разницу «строка найдена / не найдена» ничто не маскировало.
Предчек, а не решение: авторитетная проверка остаётся внутри
`verify_password_bounded` она зовёт ЭТУ ЖЕ функцию, так что разъехаться
двум условиям нечем, и инвариант «одна точка выноса = одна точка учёта»
цел (слот здесь не резервируется и не отдаётся).
Учитывает и общий потолок, и долю на ключ (#2714) — иначе предчек не
покрывал бы главный случай: при флуде с ОДНОГО адреса первым упирается
именно доля, и большинство отказов снова ходило бы в базу.
"""
return (
_verify_inflight >= settings.login_password_verify_max_inflight
or _verify_inflight_by_key.get(key, 0) >= _per_key_slot_cap()
)
async def verify_password_bounded(plain: str, hashed: str, *, key: str) -> bool:
"""`verify_password`, унесённая с событийного цикла И с сознательным потолком темпа (#2665).
@ -193,9 +219,10 @@ async def verify_password_bounded(plain: str, hashed: str, *, key: str) -> bool:
"""
global _verify_inflight
if _verify_inflight >= settings.login_password_verify_max_inflight:
raise PasswordVerifyOverloadedError
if _verify_inflight_by_key.get(key, 0) >= _per_key_slot_cap():
# АВТОРИТЕТНАЯ проверка. Вызывающий может спросить то же самое заранее
# (`verify_slots_saturated`, #2715), но решение принимается здесь и только
# здесь — предчек экономит поход в БД, а не заменяет этот отказ.
if verify_slots_saturated(key):
raise PasswordVerifyOverloadedError
loop = asyncio.get_running_loop()

View file

@ -53,6 +53,39 @@ def test_days_param_uses_cast_as_int() -> None:
assert "CAST(:days AS int)" in _AUDIT_SRC
def test_every_group_by_username_filters_out_the_nameless() -> None:
"""Каждая выборка «по аккаунтам» отбрасывает строки с пустым именем (#2715).
Пустое имя пишет `login_verify_saturated`: отказ по насыщению случается ДО
того, как мы посмотрели на присланное имя, и записать его нельзя иначе
атакующий набивал бы аудит строками с любым именем на выбор. Но аккаунтом
такая строка от этого не становится: без фильтра она встаёт ПЕРВОЙ в списке
(её `last_seen_at` момент атаки), даёт фантома в `count(DISTINCT
username)`, а раскрытие уходит в `/audit/accounts/{username}` с
`min_length=1` то есть в ошибку.
То же и со счётчиками уникальных: `count(DISTINCT username)` считал бы
безымянного за человека, и первая же атака НАВСЕГДА добавила бы +1 к числу
пользователей (строка остаётся в таблице). `NULLIF(username, '')` роняет её
в NULL, который `count(DISTINCT)` не считает. Сами события при этом из
`total_events` не исчезают они события, просто не люди.
Сравнение ЧИСЛОМ, а не поиском подстроки: так сторож ловит и НОВУЮ выборку,
добавленную без фильтра, а не только сегодняшние. На проде пустых имён
сейчас 0 из 3365 строк то есть это ново.
"""
grouped = _AUDIT_SRC.count("GROUP BY username")
filtered = _AUDIT_SRC.count("WHERE username <> ''")
assert grouped == filtered, (
f"{grouped} выборок GROUP BY username, из них с фильтром {filtered}"
"безымянная строка попадёт в список аккаунтов"
)
assert "count(DISTINCT username)" not in _AUDIT_SRC, (
"count(DISTINCT username) считает безымянные события за людей — "
"нужен count(DISTINCT NULLIF(username, ''))"
)
# ---------------------------------------------------------------------------
# Fakes — mirror the mocked-DB convention used across tests/test_user_events.py etc.
# ---------------------------------------------------------------------------

View file

@ -32,6 +32,7 @@ in-memory fake DB standing in for the identity registry:
from __future__ import annotations
import asyncio
import logging
import os
import re
import time
@ -259,6 +260,10 @@ 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()
# Агрегатор отказов по насыщению (#2715) — тоже глобал процесса: без сброса
# недосчитанные отказы одного теста всплывают в записи другого.
monkeypatch.setattr(auth_router, "_saturation_rejected", 0)
monkeypatch.setattr(auth_router, "_saturation_reported_at", None)
monkeypatch.setattr(config.settings, "auth_mode", "dual")
# Каждый тест стартует в ДЕФОЛТНОМ режиме реестра (сегодняшний прод), даже
# если предыдущий переключался на `auth`.
@ -958,6 +963,183 @@ async def test_flood_from_one_ip_leaves_login_open_for_another_ip(
)
# ---------------------------------------------------------------------------
# #2715 — отказ по насыщению: до похода в БД и со следом, который не выселяет лог
# ---------------------------------------------------------------------------
def _saturate_verify_slots(monkeypatch: pytest.MonkeyPatch) -> None:
"""Слоты сверки заняты — снаружи ровно то же, что живой флуд, но без гонок.
Именно счётчик, а не мок `verify_password_bounded`: проверяем настоящий
предикат отказа (`verify_slots_saturated` читает этот же глобал), а не
собственную заглушку.
"""
monkeypatch.setattr(password_mod, "_verify_inflight", 999)
def test_saturated_login_answers_before_touching_the_registry(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Под насыщением отказ приходит ДО выборки пользователя (#2715).
Гейт стоял после SELECT'а, и каждый заведомо отклоняемый запрос всё равно
брал соединение из пула тогда, когда система уже перегружена. Хуже того,
эта выборка оставалась ЕДИНСТВЕННОЙ работой на пути отказа: bcrypt, ровняющий
время ответа для существующего и несуществующего имени, до отказанного
запроса не доходит вовсе, так что разницу маскировать было нечем.
Мерим не тайминг (в CI флейкует), а сам факт похода в реестр и заодно
побайтовую одинаковость ответа для живого и выдуманного имени.
"""
store.add_user("alice", hash_password("Secret123!"), role="employee")
_capture_events(monkeypatch)
lookups: list[str] = []
real_lookup = auth_router.get_user_by_username
def _spy(db: Any, username: str) -> Any:
lookups.append(username)
return real_lookup(db, username)
monkeypatch.setattr(auth_router, "get_user_by_username", _spy)
_saturate_verify_slots(monkeypatch)
bodies = []
for name in ("alice", "ghost"):
resp = client.post(
"/api/v1/auth/login",
json={"username": name, "password": "x"},
headers={"x-forwarded-for": "203.0.113.5"},
)
assert resp.status_code == 429, resp.text
assert resp.headers["Retry-After"] == "1"
bodies.append(resp.text)
assert lookups == [], f"под насыщением всё-таки сходили в реестр: {lookups}"
# Существующее и несуществующее имя — неразличимы (#2571 на этом пути тоже).
assert bodies[0] == bodies[1]
def test_key_share_alone_also_answers_before_the_registry(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Долю на ключ предчек проверяет ТОЖЕ — и это главный случай, а не запасной.
Соседний тест занимает ОБЩИЙ счётчик, а `or` в `verify_slots_saturated`
коротит на первой половине: выброси вторую и тот тест останется зелёным.
Между тем при флуде с ОДНОГО адреса (#2714) общий потолок не выбирается
вовсе, первой упирается именно доля, и без неё в базу ходили бы почти все
отклонённые запросы.
"""
store.add_user("alice", hash_password("Secret123!"), role="employee")
_capture_events(monkeypatch)
lookups: list[str] = []
real_lookup = auth_router.get_user_by_username
def _spy(db: Any, username: str) -> Any:
lookups.append(username)
return real_lookup(db, username)
monkeypatch.setattr(auth_router, "get_user_by_username", _spy)
# Общий котёл (4) НЕ выбран: занято 2 из 4, и оба — одним адресом. Это ровно
# его доля (`_per_key_slot_cap` = 4 // 2), больше ему не дают.
monkeypatch.setattr(password_mod, "_verify_inflight", 2)
monkeypatch.setattr(password_mod, "_verify_inflight_by_key", {"203.0.113.5": 2})
assert password_mod._per_key_slot_cap() == 2 # исходные условия теста
flooder = client.post(
"/api/v1/auth/login",
json={"username": "alice", "password": "x"},
headers={"x-forwarded-for": "203.0.113.5"},
)
assert flooder.status_code == 429, flooder.text
assert lookups == [], f"доля исчерпана, а в реестр всё-таки сходили: {lookups}"
# И тут же — доказательство, что предчек не отказывает всем подряд: с
# ДРУГОГО адреса свободные слоты есть, запрос идёт дальше, в реестр.
other = client.post(
"/api/v1/auth/login",
json={"username": "alice", "password": "wrong"},
headers={"x-forwarded-for": "198.51.100.10"},
)
assert other.status_code == 401, other.text
assert lookups == ["alice"]
def test_saturation_is_reported_once_per_window_and_lands_in_audit(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch, caplog: Any
) -> None:
"""Двадцать отказов — одна запись в лог и одно событие в аудит, со счётчиком.
Строка на каждый отказ делила с остальным бэкендом `json-file max-size 20m ×
max-file 3`: при флуде в сотни запросов в секунду 60 МБ прокручиваются за
минуты и выселяют ВСЕ остальные логи ровно во время атаки. Поэтому окно.
А событие в `user_events` потому что до #2715 инцидент не оставлял в
аудите ни строчки: событие неудачного входа тут не пишется намеренно
(пароль не проверялся, и трата бюджета неудач дала бы блокировку чужой
учётки насыщением) значит нужен отдельный тип события, и он обязан
появляться независимо от того, ротировался лог или нет.
"""
# ЛИТЕРАЛ, а не арифметика от настройки: окно — компромисс «видно вовремя»
# против «не выселяет лог», и подъём его до минут прячет атаку целиком.
assert auth_router._SATURATION_REPORT_WINDOW_S == 1.0
events = _capture_events(monkeypatch)
_saturate_verify_slots(monkeypatch)
# Окно на весь тест — иначе медленный CI разбил бы 20 запросов на два окна
# и число записей стало бы функцией скорости раннера.
monkeypatch.setattr(auth_router, "_SATURATION_REPORT_WINDOW_S", 60.0)
caplog.set_level(logging.WARNING, logger="app.api.v1.auth")
for i in range(20):
resp = client.post(
"/api/v1/auth/login",
json={"username": f"ghost{i}", "password": "x"},
headers={"x-forwarded-for": "203.0.113.5"},
)
assert resp.status_code == 429, resp.text
lines = [r for r in caplog.records if "saturated" in r.getMessage()]
assert len(lines) == 1, f"20 отказов дали {len(lines)} строк в логе — агрегации нет"
# ERROR, а не WARNING: бэкенд поднят с LoggingIntegration(event_level=ERROR)
# (app/main.py), и только с ERROR запись становится событием GlitchTip.
# Понижение уровня выключило бы канал молча — прецедент #2674.
assert lines[0].levelno == logging.ERROR
saturated = [e for e in events if e["event_type"] == "login_verify_saturated"]
assert len(saturated) == 1, saturated
# Первый отказ отчитывается сразу (одиночная аномалия обязана быть видна),
# поэтому в первой записи он один — накопленное придёт следующей.
assert saturated[0]["payload"] == {"rejected": 1, "since_prev_s": None}
assert saturated[0]["ip"] == "203.0.113.5"
# Имя не пишем: отказ случился ДО того, как мы на него посмотрели, а запись
# присланного дала бы атакующему аудит-строки с любым именем на выбор.
assert saturated[0]["username"] == ""
# Бюджет неудач по имени не тронут — иначе насыщением блокируют чужой вход.
assert [e for e in events if e["event_type"] == "login_failed"] == []
assert not auth_router._USERNAME_FAIL_LIMITER._hits
# Окно прошло — следующий отказ приносит НАКОПЛЕННОЕ, а не единицу.
monkeypatch.setattr(auth_router, "_saturation_reported_at", time.monotonic() - 61.0)
resp = client.post(
"/api/v1/auth/login",
json={"username": "ghost-last", "password": "x"},
headers={"x-forwarded-for": "203.0.113.5"},
)
assert resp.status_code == 429
saturated = [e for e in events if e["event_type"] == "login_verify_saturated"]
assert len(saturated) == 2
assert saturated[1]["payload"]["rejected"] == 20, "счётчик за окно потерян"
# Без этого числа 20 отказов читались бы как «20 за секунду», хотя копились
# они минуту: хвост уезжает в запись, датированную моментом СЛЕДУЮЩЕГО
# отказа и подписанную ЕГО адресом — возможно, случайного соседа по NAT.
assert saturated[1]["payload"]["since_prev_s"] == pytest.approx(61.0, abs=1.0)
# ---------------------------------------------------------------------------
# POST /logout
# ---------------------------------------------------------------------------