gendesign/tradein-mvp/backend/app/api/v1/auth.py
bot-backend 690f1ef5d2
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
feat(metrics): продуктовые счётчики Prometheus для Меры и Птицы + дашборд
Владелец попросил вывести продукт в Графану — до этого там были только
технические панели (запросы/латентность/память). Список счётчиков взят из
реально пишущихся событий, а не выдуман:

Мера (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
2026-09-12 14:22:33 +03:00

480 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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}