gendesign/tradein-mvp/backend/app/api/v1/auth.py
bot-backend eccb895db1
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m3s
CI Trade-In / backend-tests (pull_request) Successful in 2m40s
feat(tradein): переключаемый реестр людей — подготовка переезда «Меры» в БД auth [PR-2b/6]
Дефолт не меняет ничего: IDENTITY_STORE="tradein" — это сегодняшний прод,
tradein_users/tradein_sessions, соединение с БД auth не открывается вообще.
Переключение делается одной переменной окружения ПОСЛЕ того, как на проде
появится пароль auth_app и будут скопированы данные. Так сделано намеренно:
мерж, который зависит от невыполненного ручного шага, — это мерж, который
ломает прод в момент невнимательности.

Ядро. app/services/identity_store.py — единственное место, знающее, в какой БД
и в каких таблицах живёт реестр. Имена таблиц берутся из фиксированного словаря
по значению флага, не конкатенацией с вводом. app/core/auth_db.py — ЛЕНИВЫЙ
engine БД auth (core/db.py создаёт свой на импорте; такое же для auth роняло бы
старт без DSN).

Одно понятие состояния доступа вместо двух. В tradein_users состояние — булев
is_active, в auth.users — access_state из трёх значений. Конверсия живёт в одной
функции to_access_state(): True→active, False→disabled, а неизвестная строка,
NULL или чужой тип → disabled с WARNING. Fail-closed выбран сознательно: если
следующая миграция добавит четвёртое состояние, оно по умолчанию НЕ будет
пускать. Проверка доступа — свойство can_sign_in, а не сравнение со строкой.

Логин в режиме auth. Пароль проверяется ВСЕГДА и ДО ветвления по состоянию —
иначе появляется timing-oracle и перечисление логинов. Верный пароль +
trial_expired → 403 с машиночитаемым code="access_expired", сессия НЕ создаётся.
Верный пароль + disabled → тот же generic 401, что и при неверном пароле.
Резолв уже выданной сессии пропускает только active — блокировка обрывает
сессию немедленно, а не по истечении sliding-refresh.

Старт падает явно, если IDENTITY_STORE=auth, а DSN не задан. Без этого ошибка
конфигурации не похожа на аварию: продуктовая БД жива, приложение работает, а
rbac_guard ловит исключение резолва вместе с любым другим сбоем и падает в
legacy trusted-header ветку — то есть сутками раздаёт права из roles.yaml мимо
реестра, включая аккаунты с disabled.

Форма входа понимает новый код ответа. Ветвление по detail.code, а не по тексту:
текст бэк вправе менять, код — нет.

Гранты соблюдены, а не обойдены: auth_app не имеет UPDATE на role/manager_id и
не имеет DELETE на users (миграция 004, column-level).

Тесты: 2996 passed (+59). Единственный красный — test_search_cache_hit —
предсуществующий: проверен контрольным полным прогоном на чистом main
(2937 passed, тот же красный).
2026-08-01 02:50:14 +03:00

205 lines
10 KiB
Python
Raw Permalink 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 недостаточно — паразитная граница возможна с обеих сторон).
- Raw-пароль НИКОГДА не логируется и не попадает в user_events payload —
только username/ip/user_agent/path/method (см. schedule_event ниже).
"""
from __future__ import annotations
import logging
import secrets
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.password import hash_password, verify_password
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
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,
)
# 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):
username: str
password: str
class LoginResponse(BaseModel):
ok: bool = True
@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)},
)
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
# держит время ответа одинаковым независимо от существования аккаунта.
password_ok = verify_password(body.password, hash_to_check)
# Пароль проверен ВЫШЕ и безусловно — только теперь смотрим на состояние
# доступа. Порядок несущий, а не стилистический: см. модульный 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)
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 и то же событие, что при неверном пароле:
# заблокированный аккаунт неотличим от несуществующего.
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)
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="/",
)
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}