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