"""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 защита). - #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.db import get_db 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.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 = "неверный логин или пароль" 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"{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) credentials_ok = user is not None and user["is_active"] and password_ok 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}