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()