import hashlib
import secrets
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.config import get_settings
_bearer = HTTPBearer(auto_error=False)
def generate_server_key() -> str:
"""Plaintext-ключ агента. Показывается один раз при создании сервера."""
return "ghm_" + secrets.token_urlsafe(24)
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 auth_mode_error() -> HTTPException:
"""401/403, когда браузер не залогинен в gnexus-auth (GHARD_AUTH_CLIENT_ID задан)."""
return HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="not authenticated")
async def require_admin(
request: Request,
creds: HTTPAuthorizationCredentials | None = Depends(_bearer),
) -> None:
"""Доступ к API панели (данные, CRUD, MCP).
Порядок:
1. Bearer GHARD_ADMIN_TOKEN (если задан) — скрипты, MCP, curl.
2. Агенты: заголовок X-Server-Key проверяется только в ingest-роутере.
3. GHARD_AUTH_CLIENT_ID задан → браузер с валидной сессионной cookie.
4. auth выключен и admin_token пуст → API открыт (личная панель).
"""
settings = get_settings()
token = settings.admin_token
if token and creds is not None and creds.scheme.lower() == "bearer" and creds.credentials == token:
return
if settings.auth_client_id:
from app.auth import SESSION_COOKIE, get_session
session = await get_session(request.cookies.get(SESSION_COOKIE))
if session is None:
raise auth_mode_error()
return
if not token:
return
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid admin token",
)
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): персональный mcp_* ключ | статический админ-токен | cookie.
Порядок:
1. Bearer mcp_… — hash-lookup в mcp_tokens: отозванный или ключ владельца
с users.blocked → тот же 401 (канон mcp.md); валидный → last_used_at.
2. Bearer GHARD_ADMIN_TOKEN — статический супер-токен (совместимость).
3. Cookie-сессия gnexus-auth — как в require_admin.
4. auth выключен и admin_token пуст → открытый режим.
Это dependency, а не ASGI-гард как в Synapse: /mcp здесь один APIRoute,
dependency решает то же самое без middleware-обёртки.
"""
settings = get_settings()
bearer = creds.credentials if creds is not None and creds.scheme.lower() == "bearer" else ""
if bearer.startswith("mcp_"):
from app.auth import _now, get_user_row
from app.db import get_db
db = get_db()
cursor = await db.execute(
"SELECT id, user_id, 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 = await get_user_row(row["user_id"])
if user and user["blocked"]:
raise _mcp_401()
await db.execute(
"UPDATE mcp_tokens SET last_used_at = ? WHERE id = ?",
(_now().isoformat(), row["id"]),
)
await db.commit()
request.state.mcp_token = dict(row)
return
if settings.admin_token and bearer and bearer == settings.admin_token:
request.state.mcp_token = None
return
if settings.auth_client_id:
from app.auth import SESSION_COOKIE, get_session
session = await get_session(request.cookies.get(SESSION_COOKIE))
if session is None:
raise _mcp_401()
request.state.mcp_token = None
return
if not settings.admin_token:
request.state.mcp_token = None
return
raise _mcp_401()