Формулировка уточнена в докстрингах `services/session_audit.py` и `api/dependencies/auth.py`, README «Безопасность dev-режима» и CLAUDE.md; тест событий входа дополнен запросом со схемой Basic. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
124 lines
7.0 KiB
Python
124 lines
7.0 KiB
Python
"""Аудит входа: LOGIN / LOGIN_FAILED (OQ-64, issue #9 п.5; spec audit-trail — запись только через `AuditService`).
|
||
|
||
Что считается входом (вход внешний — JWT РАИСА, отдельного «логина» в АРМ нет):
|
||
- LOGIN — успешная выдача dev-токена (`POST /api/v1/dev/token`, пишется в транзакции запроса) и первый успешный
|
||
запрос пользователя с токеном после старта процесса (`first_request`; множество `logged_in` живёт в памяти процесса
|
||
и сбрасывается при рестарте, у каждого экземпляра приложения — своё);
|
||
- LOGIN_FAILED — 401 (токен не принят: `user_id` пуст, в `description` причина без самого токена) и 403 аутентификации
|
||
(пользователь не найден, отключён, неоднозначен: email в `description`, `user_id` — если пользователь найден).
|
||
Запрос без заголовка `Authorization` или со схемой не `Bearer` попыткой входа не считается (токен не предъявлен);
|
||
403 «недостаточно прав» — авторизация, не вход.
|
||
|
||
Раздел `SESSION`, `object_type = "app_user"`, `object_id = user_id`; IP и user-agent — из запроса. Поля, которые
|
||
присылает клиент, обрезаются: `user_agent` — до 512 символов, `description` — до 1000.
|
||
LOGIN_FAILED и LOGIN первого запроса пишутся в ОТДЕЛЬНОЙ короткой транзакции (`transaction`, в проде —
|
||
`src.db.db.session_transaction`): транзакция запроса при 401/403 откатывается (R-1), а запись об отказе должна остаться.
|
||
Ошибка записи аудита входа ответ не меняет: она логируется, LOGIN первого запроса будет повторён следующим запросом.
|
||
"""
|
||
|
||
import uuid
|
||
from collections.abc import Callable
|
||
from contextlib import AbstractAsyncContextManager
|
||
|
||
from loguru import logger
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from starlette.requests import HTTPConnection
|
||
|
||
from src.models.enums import AuditAction, AuditSection
|
||
from src.services.audit import AuditService
|
||
|
||
OBJECT_TYPE = "app_user"
|
||
#: пределы длины полей события входа: заголовок User-Agent и email из токена присылает клиент (ревью R-1)
|
||
USER_AGENT_MAX = 512
|
||
DESCRIPTION_MAX = 1000
|
||
FIRST_REQUEST_DESCRIPTION = "первый запрос с токеном"
|
||
DEV_TOKEN_DESCRIPTION = "выдан dev-токен"
|
||
|
||
#: фабрика отдельной транзакции: `async with transaction() as session` — сессия в открытой транзакции, commit на выходе
|
||
TransactionFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]]
|
||
|
||
|
||
def clip(text: str | None, limit: int) -> str | None:
|
||
"""Строка не длиннее `limit` символов (`None` остаётся `None`)."""
|
||
return text if text is None else text[:limit]
|
||
|
||
|
||
def request_client(request: HTTPConnection) -> tuple[str | None, str | None]:
|
||
"""IP клиента (за прокси — из `ProxyHeadersMiddleware`) и заголовок `User-Agent` запроса."""
|
||
return (request.client.host if request.client else None, request.headers.get("user-agent"))
|
||
|
||
|
||
async def record_login( # noqa: PLR0913 - поля события входа без значений по умолчанию
|
||
session: AsyncSession,
|
||
*,
|
||
action: AuditAction,
|
||
user_id: uuid.UUID | None,
|
||
description: str,
|
||
ip_address: str | None,
|
||
user_agent: str | None,
|
||
) -> None:
|
||
"""Событие входа в транзакции `session` (раздел SESSION, объект — пользователь); длинные поля обрезаются."""
|
||
await AuditService(session).record(
|
||
action=action,
|
||
section=AuditSection.SESSION,
|
||
object_type=OBJECT_TYPE,
|
||
object_id=user_id,
|
||
user_id=user_id,
|
||
description=clip(description, DESCRIPTION_MAX),
|
||
ip_address=ip_address,
|
||
user_agent=clip(user_agent, USER_AGENT_MAX),
|
||
)
|
||
|
||
|
||
class SessionAudit:
|
||
"""LOGIN первого запроса и LOGIN_FAILED в отдельной транзакции; хранится в `app.state.session_audit`."""
|
||
|
||
def __init__(self, transaction: TransactionFactory) -> None:
|
||
self.transaction = transaction
|
||
#: пользователи, для которых LOGIN «первого запроса» уже записан этим процессом
|
||
self.logged_in: set[uuid.UUID] = set()
|
||
|
||
async def first_request(self, user_id: uuid.UUID, *, ip_address: str | None, user_agent: str | None) -> None:
|
||
"""LOGIN при первом успешном запросе пользователя после старта процесса.
|
||
|
||
Id попадает в `logged_in` до записи (без `await` между проверкой и добавлением), поэтому параллельные первые
|
||
запросы пишут одно событие; при ошибке записи id убирается — LOGIN попробует записать следующий запрос.
|
||
"""
|
||
if user_id in self.logged_in:
|
||
return
|
||
self.logged_in.add(user_id)
|
||
if not await self._record(
|
||
AuditAction.LOGIN, user_id, FIRST_REQUEST_DESCRIPTION, ip_address=ip_address, user_agent=user_agent
|
||
):
|
||
self.logged_in.discard(user_id)
|
||
|
||
async def login_failed(
|
||
self, user_id: uuid.UUID | None, description: str, *, ip_address: str | None, user_agent: str | None
|
||
) -> None:
|
||
"""LOGIN_FAILED (401/403 аутентификации); вызывается обработчиком исключений после отката запроса."""
|
||
await self._record(AuditAction.LOGIN_FAILED, user_id, description, ip_address=ip_address, user_agent=user_agent)
|
||
|
||
async def _record(
|
||
self,
|
||
action: AuditAction,
|
||
user_id: uuid.UUID | None,
|
||
description: str,
|
||
*,
|
||
ip_address: str | None,
|
||
user_agent: str | None,
|
||
) -> bool:
|
||
try:
|
||
async with self.transaction() as session:
|
||
await record_login(
|
||
session,
|
||
action=action,
|
||
user_id=user_id,
|
||
description=description,
|
||
ip_address=ip_address,
|
||
user_agent=user_agent,
|
||
)
|
||
except Exception:
|
||
logger.exception("Не удалось записать событие входа в audit_event. action={action}", action=action)
|
||
return False
|
||
return True
|