Newer
Older
tgclient-mcp / backend / app / security.py
import hashlib
import secrets
from datetime import datetime, timezone

from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

from app.config import get_settings

_bearer = HTTPBearer(auto_error=False)

# Служебный владелец в режиме auth-off (SSO не настроен): у деплоя без
# gnexus-auth личности нет, все аккаунты уходят этому юзеру; MCP — только
# по статическому админ-токену (у открытого режима нет mcp-пути).
LOCAL_USER_ID = "local"

ADMIN_ROLES = ("admin", "superadmin")


def generate_mcp_token() -> str:
    """Plaintext персонального MCP-ключа (канон handbook mcp.md, префикс mcp_).
    Показывается один раз на странице «MCP-ключи»; в БД — хэш hash_key()."""
    return "mcp_" + secrets.token_urlsafe(24)


def mcp_token_hint(token: str) -> str:
    """Хвост для опознания ключа в списке (plaintext никогда не возвращается)."""
    return token[-8:]


def hash_key(key: str) -> str:
    return hashlib.sha256(key.encode()).hexdigest()


def now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


def auth_mode_error() -> HTTPException:
    """401: браузер не залогинен в gnexus-auth (TGCLIENT_AUTH_CLIENT_ID задан)."""
    return HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="not authenticated")


def mcp_user(user_id: str, email: str, system_role: str) -> dict:
    """Личность mcp-вызова (Role — снейпшот выпуска для персональных ключей)."""
    return {"user_id": user_id, "email": email, "system_role": system_role}


SUPERADMIN = {"user_id": "mcp", "email": "", "system_role": "superadmin"}


async def _cookie_session(request: Request):
    """Cookie-сессия или None (auth включён)."""
    from app.auth import SESSION_COOKIE, get_session

    if not get_settings().auth_client_id:
        return None
    return await get_session(request.cookies.get(SESSION_COOKIE))


async def _load_user_row(user_id: str) -> dict | None:
    from app.auth import get_user_row

    return await get_user_row(user_id)


async def is_session_admin(request: Request) -> bool:
    session = await _cookie_session(request)
    if session is None:
        return False
    row = await _load_user_row(session["user_id"])
    return ((row["system_role"] if row and row["system_role"] else "") or "user") in ADMIN_ROLES


async def require_admin(
    request: Request,
    creds: HTTPAuthorizationCredentials | None = Depends(_bearer),
) -> dict:
    """Только админ gnexus-auth (мульти-юзер сервис): админка видит всех.

    Порядок:
    1. Bearer TGCLIENT_ADMIN_TOKEN (задан) — статический супер-токен.
    2. Cookie-сессия gnexus-auth c users.system_role ∈ (admin, superadmin).
    3. auth выключен → открытый режим (Bearer-токен не требуется).
    Возвращает dict личности (для ответов роутов).
    """
    settings = get_settings()
    bearer = creds.credentials if creds is not None and creds.scheme.lower() == "bearer" else ""
    if settings.admin_token and bearer == settings.admin_token:
        return dict(SUPERADMIN)
    if settings.auth_client_id:
        session = await _cookie_session(request)
        if session is None:
            raise auth_mode_error()
        row = await _load_user_row(session["user_id"])
        role = (row["system_role"] if row and row["system_role"] else "") or "user"
        if role not in ADMIN_ROLES:
            raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="admin role required")
        return mcp_user(session["user_id"], session["email"], role)
    if not settings.admin_token:
        return dict(SUPERADMIN)
    raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid admin token")


async def require_session(request: Request) -> dict:
    """Только cookie-сессия (личные вещи: выпуск MCP-ключей, PATCH /me,
    управление своими аккаунтами): у Bearer-токена нет личности."""
    settings = get_settings()
    if settings.auth_client_id:
        session = await _cookie_session(request)
        if session is None:
            raise auth_mode_error()
        return session
    if settings.admin_token:
        raise HTTPException(
            status_code=401, detail="personal actions require gnexus-auth sign-in"
        )
    # открытый режим: единственная личность деплоя
    return {"user_id": LOCAL_USER_ID, "email": "", "display_name": "local"}


def _mcp_401() -> HTTPException:
    # Причина (отозван / заблокирован / неверный) не раскрывается — канон mcp.md
    return HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="invalid MCP token",
        headers={"www-authenticate": "Bearer"},
    )


async def require_mcp(
    request: Request,
    creds: HTTPAuthorizationCredentials | None = Depends(_bearer),
) -> None:
    """Гард /mcp и /mcp-protocol/: персональный mcp_* | статический админ-токен |
    cookie-сессия. Это dependency, а не ASGI-гард как в Synapse: /mcp здесь один
    APIRoute, dependency решает то же самое без middleware-обёртки.

    Порядок:
    1. Bearer mcp_… — hash-lookup: отозванный или ключ владельца с users.blocked
       → тот же 401 (канон mcp.md); валидный → touch last_used_at.
    2. Bearer TGCLIENT_ADMIN_TOKEN — статический супер-токен (бэкдор; роль
       superadmin, user_id mcp: им можно всё, владение аккаунта — «mcp»).
    3. Cookie-сессия gnexus-auth — как в require_admin (роль из users).
    4. auth выключен и admin_token пуст → открытый режим.

    Личность кладётся в request.state.mcp_user; mcp_endpoint (app/mcp/server.py)
    переносит её в ContextVar, чтобы тулы читали current_mcp_user().
    """
    settings = get_settings()
    bearer = creds.credentials if creds is not None and creds.scheme.lower() == "bearer" else ""
    if not settings.auth_client_id and not settings.admin_token:
        # открытый режим (dev): единственная личность деплоя, любой bearer принят
        request.state.mcp_user = mcp_user(LOCAL_USER_ID, "", "superadmin")
        return
    if bearer.startswith("mcp_"):
        from app.db import get_db

        db = get_db()
        cursor = await db.execute(
            "SELECT id, user_id, user_email, system_role, revoked_at FROM mcp_tokens"
            " WHERE token_hash = ?",
            (hash_key(bearer),),
        )
        row = await cursor.fetchone()
        if row is None or row["revoked_at"]:
            raise _mcp_401()
        user_row = await _load_user_row(row["user_id"])
        if user_row and user_row["blocked"]:
            # флаг от вебхуков gnexus-auth; гард молчит тем же 401 —
            # не раскрывать статус блокировки (канон mcp.md)
            raise _mcp_401()
        await db.execute(
            "UPDATE mcp_tokens SET last_used_at = ? WHERE id = ?", (now_iso(), row["id"])
        )
        await db.commit()
        request.state.mcp_user = mcp_user(
            row["user_id"], row["user_email"] or "", row["system_role"] or "user"
        )
        return
    if settings.admin_token and bearer == settings.admin_token:
        request.state.mcp_user = dict(SUPERADMIN)
        return
    if settings.auth_client_id:
        session = await _cookie_session(request)
        if session is None:
            raise _mcp_401()
        row = await _load_user_row(session["user_id"])
        role = (row["system_role"] if row and row["system_role"] else "") or "user"
        request.state.mcp_user = mcp_user(session["user_id"], session["email"], role)
        return
    raise _mcp_401()