gendesign/tradein-mvp/backend/app/api/v1/auth.py
bot-backend 0835266516
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
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 / backend-tests (pull_request) Successful in 1m19s
feat(tradein/auth): auth-core — login/logout, sessions, dual-mode rbac (#2552)
Foundation для эпика #2549: session-cookie auth поверх legacy Caddy
trusted-header. app.services.auth_session — CRUD для tradein_sessions
(create/get/revoke) + get_user_by_username для password-логина; opaque
secrets.token_urlsafe токены, sliding last_seen_at/expires_at refresh
(не чаще раза в 5 минут).

POST /api/v1/auth/login проверяет password_hash (bcrypt) через
app.core.password, ставит httponly+secure cookie, пишет
login_success/login_failed в user_events; per-username+IP rate-limit
(SlidingWindowLimiter) отдельно от общего RateLimitMiddleware. POST
/logout ревокает сессию и чистит cookie. Оба пути exempt из rbac_guard's
auth-required gate (иначе логин сам себя не пропустил бы).

rbac_guard теперь dual-mode: session-cookie резолвится первым (DB-роль
employee/manager/admin -> paths как у pilot/+team/admin), fallback на
legacy X-Authenticated-User + roles.yaml БЕЗ ИЗМЕНЕНИЙ когда auth_mode
== "dual"; auth_mode == "db_only" отключает legacy header полностью.
Резолвленный сессией username инжектится в ASGI scope headers (до
call_next) — RequestAuditMiddleware и downstream route-хендлеры видят
его прозрачно; RateLimitMiddleware (внешний относительно rbac_guard)
для session-запросов лимитирует по IP, не по username — документированный
trade-off, не регрессия.

GET /me — session-first: валидная cookie отдаёт scope из tradein_users
без похода в roles.yaml; без cookie — прежний legacy путь. session_secret
остаётся опциональным (opaque-токены не требуют подписи) — пустое
значение только logger.warning на старте, не startup-fail.

Полный набор тестов (tests/test_rbac.py, test_internal_auth_secret.py,
test_account_quota.py) проходит без правок — regression-safe.
2026-07-30 10:48:28 +03:00

134 lines
4.8 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 защита).
- Rate-limit по (username, IP) — ЖЁСТЧЕ общего `RateLimitMiddleware`
(`/api/*`), т.к. login — типичная brute-force поверхность. Использует
`SlidingWindowLimiter` (тот же примитив, что и общий rate-limit).
- Raw-пароль НИКОГДА не логируется и не попадает в user_events payload —
только username/ip/user_agent/path/method (см. schedule_event ниже).
"""
from __future__ import annotations
import logging
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.db import get_db
from app.core.password import 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.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,
)
_INVALID_CREDENTIALS_DETAIL = "неверный логин или пароль"
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_db)],
) -> LoginResponse:
ip = _client_ip(request)
user_agent = request.headers.get("user-agent")
rate_key = f"{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)
credentials_ok = (
user is not None
and user["is_active"]
and user["password_hash"] is not None
and verify_password(body.password, user["password_hash"])
)
if not credentials_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)
assert user is not None # narrowed by credentials_ok above
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_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}