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
Дефолт не меняет ничего: 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, тот же красный).
205 lines
10 KiB
Python
205 lines
10 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 недостаточно — паразитная граница возможна с обеих сторон).
|
||
- 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}
|