Merge pull request 'feat(tradein/auth): глобальный потолок попыток входа на имя пользователя (#2571)' (#2663) from feat/2571-login-throttle into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m49s
Deploy Trade-In / build-backend (push) Successful in 1m4s
Deploy Trade-In / deploy (push) Successful in 1m10s
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m49s
Deploy Trade-In / build-backend (push) Successful in 1m4s
Deploy Trade-In / deploy (push) Successful in 1m10s
This commit is contained in:
commit
c9f71da484
4 changed files with 471 additions and 25 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue