All checks were successful
CI / backend-tests (pull_request) Successful in 17m59s
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m18s
CI Trade-In / backend-tests (pull_request) Successful in 5m59s
Владелец попросил вывести продукт в Графану — до этого там были только
технические панели (запросы/латентность/память). Список счётчиков взят из
реально пишущихся событий, а не выдуман:
Мера (tradein-mvp/backend/app/observability/metrics.py):
- mera_estimates_total{outcome=ok|insufficient_data} — POST /estimate,
зеркалит user_events.event_type=estimate_request (294 строки в БД),
insufficient_data — не ошибка, а исход без аналогов.
- mera_address_suggestions_total{found=yes|no} — GET /geocode/suggest,
своего user_events-события у ручки не было.
- mera_reports_exported_total (без лейблов) — GET /estimate/{id}/pdf.
- mera_leads_total (без лейблов) — POST /trade-in/lead.
- mera_support_messages_total{channel=web|anon} — POST /support/messages
и /support/anon/messages, счётчик после успешной доставки в Telegram.
- mera_logins_total{result=success|failed} — рядом с user_events
login_success/login_failed в auth.py (97/453 строк в БД).
Птица (backend/app/observability/metrics.py):
- sitefinder_reports_exported_total{format} — GET .../forecast/export
(md/json/tg/docx/pptx/pdf) и POST .../best-layouts/pdf.
Метки везде — фиксированный литерал из места вызова (outcome/found/channel/
result/format), никогда username/адрес/estimate_id/кадастровый номер —
это ровно то, что взрывает кардинальность ряда у Prometheus.
Дашборд ops/metrics/grafana/dashboards/product.json ("Продуктовые метрики",
uid gendesign-product) — воронка Меры (оценки/подсказки/лиды/отчёты/входы/
поддержка) + экспорт форматов Птицы, часовые increase()-панели без
стекирования (на соседней панели оно уже давало ложную тревогу, PR #3474).
Provisioning тот же, что у apps.json — сканирует директорию, отдельного
конфига не нужно.
ops/metrics/alloy/alloy-apps.alloy проверен: у job "apps" нет relabel-
фильтра по __name__ (в отличие от cadvisor) — новые счётчики уходят в
remote_write как есть, правки не потребовалось.
Refs #3471
480 lines
34 KiB
Python
480 lines
34 KiB
Python
"""POST /api/v1/auth/login + /logout — DB-backed session auth (#2552, эпик #2549).
|
||
|
||
Переходный механизм, параллельный legacy Caddy trusted-header auth (roles.yaml).
|
||
См. `app.core.rbac.rbac_guard` (dual-mode resolver) и `app.services.auth_session`
|
||
(session CRUD). Mounted at `/api/v1/auth`; через Caddy `uri strip_prefix /trade-in`
|
||
это `/trade-in/api/v1/auth/*` снаружи.
|
||
|
||
Security:
|
||
- Неверные creds (неизвестный username / доступ закрыт / password_hash NULL /
|
||
неверный пароль) → ОДИНАКОВЫЙ 401 с generic сообщением — не раскрываем,
|
||
существует ли username (user-enumeration защита).
|
||
- Состояние доступа проверяется ТОЛЬКО ПОСЛЕ проверки пароля, и осмысленный
|
||
ответ (403 «пробный доступ закончился») получает исключительно тот, кто
|
||
пароль уже доказал. Ветвление ДО пароля превратило бы отдельный статус в
|
||
оракул существования логина: перебором можно было бы перечислить аккаунты,
|
||
не зная ни одного пароля (миграция data/sql/auth/004, WHY-2).
|
||
- #2552 post-review Medium 2: `verify_password` ВСЕГДА вызывается ровно
|
||
один раз — для несуществующего username / NULL password_hash сверяем
|
||
против статичного dummy-хеша (`_DUMMY_PASSWORD_HASH`, сгенерирован один
|
||
раз на импорте модуля), результат игнорируется. Без этого короткое
|
||
замыкание (`user is None → сразу 401`) давало наблюдаемую разницу во
|
||
времени ответа (~1мс без bcrypt vs ~100-300мс с ним) — классический
|
||
timing-oracle для user-enumeration, даже при одинаковом detail-сообщении.
|
||
- Rate-limit по (username, IP) — ЖЁСТЧЕ общего `RateLimitMiddleware`
|
||
(`/api/*`), т.к. login — типичная brute-force поверхность. Использует
|
||
`SlidingWindowLimiter` (тот же примитив, что и общий rate-limit). Ключ
|
||
length-prefixed (`len(username):username:ip`) — без этого произвольный
|
||
username с `:` внутри мог бы схлопнуть бюджет с другой (username, ip)
|
||
парой (IPv6-адреса тоже содержат `:`, так что просто эскейпить разделитель
|
||
в username недостаточно — паразитная граница возможна с обеих сторон).
|
||
- Настоящий ПОТОЛОК ТЕМПА — `verify_password_bounded` (#2665): bcrypt считает
|
||
282 мс, и ровно столько же он раньше держал заблокированным единственный
|
||
событийный цикл, кладя вместе с логином ВЕСЬ API. Теперь bcrypt крутится в
|
||
пуле из `login_password_verify_workers` потоков, а число потоков и есть
|
||
потолок (проверок/с не больше workers/282мс). Убрать одно без другого
|
||
нельзя: вынос без потолка ускорил бы перебор вчетверо, потолок без выноса
|
||
оставил бы отказ в обслуживании. Сверх очереди — 429, не ожидание.
|
||
Слоты делятся ПО АДРЕСУ (#2714): один источник не занимает больше половины,
|
||
иначе потолок бил и по своим — легитимный вход с верным паролем во время
|
||
флуда получал 429 столько раз, сколько пытался. Ключ — IP, поэтому защита
|
||
поднимает стоимость атаки, но не закрывает её (подделка за вторым прокси,
|
||
общий адрес за NAT, ротация через ботнет) — см. docstring той же функции.
|
||
Отказ по насыщению выдаётся ДО выборки из реестра (#2715): иначе на этом
|
||
пути оставалась бы единственная работа, время которой зависит от того,
|
||
существует ли имя, — а bcrypt, который эту разницу ровняет, до него уже не
|
||
доходит. След инцидента — агрегированный, `_saturated_429`.
|
||
- Поверх него — ГЛОБАЛЬНЫЙ счётчик неудач на ИМЯ, без IP в ключе (#2571):
|
||
лимит по паре (username, IP) распределённый перебор обходит целиком, просто
|
||
меняя адрес. Превышение порога не блокирует вход, а замедляет ответ
|
||
(`_throttle_delay_s`) — см. развёрнутое обоснование там же.
|
||
- Raw-пароль НИКОГДА не логируется и не попадает в user_events payload —
|
||
только username/ip/user_agent/path/method и (для неудач) состояние
|
||
счётчика попыток: сколько их за окно и какая задержка применена.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
import secrets
|
||
import time
|
||
from typing import Annotated
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||
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,
|
||
verify_slots_saturated,
|
||
)
|
||
from app.core.ratelimit import SlidingWindowLimiter, _client_ip
|
||
from app.observability.metrics import LOGINS
|
||
from app.services.auth_session import create_session, get_user_by_username, revoke_session
|
||
from app.services.identity_store import AccessState, get_identity_db
|
||
from app.services.user_events import schedule_event
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter()
|
||
|
||
# Отдельный, более узкий бюджет чем общий per-user/per-IP `/api/*` лимит
|
||
# (см. app.core.ratelimit.SlidingWindowLimiter docstring — designed именно для
|
||
# такого случая). Ключ = username+IP: не даёт распределённому brute-force по
|
||
# ОДНОМУ аккаунту с разных IP уйти от лимита целиком (per-IP было бы недостаточно),
|
||
# и не блокирует ВЕСЬ IP из-за перебора чужих логинов одним же клиентом.
|
||
_LOGIN_LIMITER = SlidingWindowLimiter(
|
||
limit=settings.login_rate_limit,
|
||
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, когда юзер не найден/деактивирован/без пароля, чтобы
|
||
# `verify_password` (доминирующая по времени операция, ~100-300мс) всегда
|
||
# отрабатывала полный bcrypt-компар, независимо от того, существует ли аккаунт.
|
||
_DUMMY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16))
|
||
|
||
_INVALID_CREDENTIALS_DETAIL = "неверный логин или пароль"
|
||
|
||
# Единственный ответ логина, который НЕ generic 401: пароль верный, но пробный
|
||
# период истёк. `code` — машиночитаемый контракт для фронта (текст можно менять,
|
||
# ветку по нему — нет). Потребитель: `loginErrorMessage` в
|
||
# tradein-mvp/frontend/src/app/login/page.tsx — читает `detail.code` из
|
||
# `HTTPError.body` (frontend/src/lib/api.ts отдаёт тело ответа как есть) и
|
||
# показывает экран про пробный период вместо generic «Проверьте подключение».
|
||
# Меняешь значение здесь — меняй и там.
|
||
_ACCESS_EXPIRED_CODE = "access_expired"
|
||
_ACCESS_EXPIRED_MESSAGE = "Пробный доступ закончился"
|
||
|
||
|
||
class LoginRequest(BaseModel):
|
||
# 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
|
||
|
||
|
||
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))
|
||
|
||
|
||
# Не чаще одной записи в это окно на ВСЕ отказы по насыщению (#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:
|
||
"""Единый хвост ЛЮБОГО отказа по кредам: счётчик → аудит → задержка → 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)
|
||
|
||
LOGINS.labels(result="failed").inc()
|
||
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,
|
||
request: Request,
|
||
response: Response,
|
||
db: Annotated[Session, Depends(get_identity_db)],
|
||
) -> LoginResponse:
|
||
ip = _client_ip(request)
|
||
user_agent = request.headers.get("user-agent")
|
||
rate_key = f"{len(body.username)}:{body.username}:{ip}"
|
||
|
||
retry_after = _LOGIN_LIMITER.check(rate_key)
|
||
if retry_after is not None:
|
||
raise HTTPException(
|
||
status_code=429,
|
||
detail="слишком много попыток входа, попробуйте позже",
|
||
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"]
|
||
if user is not None and user["password_hash"] is not None
|
||
else _DUMMY_PASSWORD_HASH
|
||
)
|
||
# ВСЕГДА вызывается — dummy-хеш при отсутствующем юзере/NULL password_hash
|
||
# держит время ответа одинаковым независимо от существования аккаунта.
|
||
try:
|
||
# key=ip — доля слотов на адрес (#2714): один источник не занимает больше
|
||
# половины ёмкости, и вход остаётся открыт тем, кто приходит с других
|
||
# адресов. Ключ — ИМЕННО адрес, не имя: имя присылает клиент, и перебор
|
||
# менял бы его каждую попытку, получая полную долю на каждое. Границы
|
||
# применимости (IP подделывается за вторым прокси, разделяется за NAT,
|
||
# ротируется ботнетом) — в docstring `verify_password_bounded`.
|
||
password_ok = await verify_password_bounded(body.password, hash_to_check, key=ip)
|
||
except PasswordVerifyOverloadedError:
|
||
# Настоящий потолок темпа (#2665): слоты проверки заняты, ждать нельзя —
|
||
# ждущий держит соединение к БД. Предчек выше сюда почти всё и отсекает,
|
||
# но авторитетен ИМЕННО ЭТОТ отказ, поэтому ветка остаётся. Ответ —
|
||
# тот же самый и с той же аргументацией, что у предчека: один helper,
|
||
# чтобы две ветки не разъехались (одинаковость 429 — часть защиты).
|
||
raise _saturated_429(ip) from None
|
||
|
||
# Пароль проверен ВЫШЕ и безусловно — только теперь смотрим на состояние
|
||
# доступа. Порядок несущий, а не стилистический: см. модульный docstring.
|
||
if user is None or not password_ok:
|
||
raise await _reject_invalid_credentials(db, body.username, ip, user_agent)
|
||
|
||
access_state = user["access_state"]
|
||
if access_state is AccessState.TRIAL_EXPIRED:
|
||
# Пароль верный, сессия НЕ создаётся. Единственный не-generic ответ:
|
||
# аккаунт существует и владелец это уже доказал паролем, так что
|
||
# осмысленный текст ничего не раскрывает постороннему.
|
||
# В режиме identity_store="tradein" эта ветка недостижима: булев
|
||
# is_active даёт только active/disabled (identity_store.to_access_state).
|
||
schedule_event(
|
||
event_type="login_blocked_expired",
|
||
username=user["username"],
|
||
ip=ip,
|
||
user_agent=user_agent,
|
||
path="/api/v1/auth/login",
|
||
method="POST",
|
||
)
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail={"code": _ACCESS_EXPIRED_CODE, "message": _ACCESS_EXPIRED_MESSAGE},
|
||
)
|
||
|
||
if not access_state.can_sign_in:
|
||
# disabled (и любое нераспознанное состояние — to_access_state fail-closed)
|
||
# → ТОТ ЖЕ 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)
|
||
|
||
response.set_cookie(
|
||
key=settings.session_cookie_name,
|
||
value=token,
|
||
max_age=settings.session_ttl_hours * 3600,
|
||
httponly=True,
|
||
secure=True,
|
||
samesite="lax",
|
||
path="/",
|
||
)
|
||
|
||
LOGINS.labels(result="success").inc()
|
||
schedule_event(
|
||
event_type="login_success",
|
||
username=user["username"],
|
||
ip=ip,
|
||
user_agent=user_agent,
|
||
path="/api/v1/auth/login",
|
||
method="POST",
|
||
)
|
||
|
||
return LoginResponse(ok=True)
|
||
|
||
|
||
@router.post("/logout")
|
||
async def logout(
|
||
request: Request,
|
||
response: Response,
|
||
db: Annotated[Session, Depends(get_identity_db)],
|
||
) -> dict[str, bool]:
|
||
token = request.cookies.get(settings.session_cookie_name)
|
||
if token:
|
||
revoke_session(db, token)
|
||
response.delete_cookie(key=settings.session_cookie_name, path="/")
|
||
return {"ok": True}
|