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
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.
134 lines
4.8 KiB
Python
134 lines
4.8 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 защита).
|
||
- 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}
|