调整文档路径
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
"""配置:环境变量读取"""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
class Settings:
|
||||
AUTH_DB_HOST: str = os.getenv("AUTH_DB_HOST", "10.100.154.100")
|
||||
AUTH_DB_PORT: int = int(os.getenv("AUTH_DB_PORT", "5432"))
|
||||
AUTH_DB_USER: str = os.getenv("AUTH_DB_USER", "postgres")
|
||||
AUTH_DB_PASSWORD: str = os.getenv("AUTH_DB_PASSWORD", "digiwin")
|
||||
AUTH_DB_NAME: str = os.getenv("AUTH_DB_NAME", "mcp_auth")
|
||||
|
||||
# 鼎捷云 IAM 配置
|
||||
IAM_BASE_URL: str = os.getenv("IAM_BASE_URL", "https://iam.digiwincloud.com.cn")
|
||||
# 应用 apptoken(digi-middleware-auth-app)
|
||||
IAM_APP_TOKEN: str = os.getenv(
|
||||
"IAM_APP_TOKEN",
|
||||
"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImRhdGEtYnVzaW5lc3MtZGVtbyIsInNpZCI6MH0.Spo64LstbWxjYNefVFAbEbgfjzZoQGNcqKSGuYUOCRk",
|
||||
)
|
||||
# IAM token 校验缓存秒数
|
||||
IAM_CACHE_TTL: int = int(os.getenv("IAM_CACHE_TTL", "30"))
|
||||
|
||||
# 前端静态文件目录(Docker 构建后注入)
|
||||
STATIC_DIR: str = os.getenv("STATIC_DIR", "../frontend/dist")
|
||||
|
||||
# Redis 配置
|
||||
REDIS_HOST: str = os.getenv("REDIS_HOST", "10.100.154.100")
|
||||
REDIS_PORT: int = int(os.getenv("REDIS_PORT", "6379"))
|
||||
REDIS_DB: int = int(os.getenv("REDIS_DB", "0"))
|
||||
REDIS_PASSWORD: str = os.getenv("REDIS_PASSWORD", "digiwin")
|
||||
|
||||
# 反向代理路径前缀(让 Swagger / OpenAPI 在带前缀的代理后正确生成 URL)
|
||||
# 生产经外层 nginx 暴露在 https://<host>/mcp-auth-api/ 下时设为 /mcp-auth-api;直连 8003 时留空
|
||||
ROOT_PATH: str = os.getenv("ROOT_PATH", "")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,28 @@
|
||||
"""鉴权库连接池"""
|
||||
|
||||
import asyncpg
|
||||
from .config import settings
|
||||
|
||||
_pool: asyncpg.Pool | None = None
|
||||
|
||||
|
||||
async def get_pool() -> asyncpg.Pool:
|
||||
global _pool
|
||||
if _pool is None:
|
||||
_pool = await asyncpg.create_pool(
|
||||
host=settings.AUTH_DB_HOST,
|
||||
port=settings.AUTH_DB_PORT,
|
||||
user=settings.AUTH_DB_USER,
|
||||
password=settings.AUTH_DB_PASSWORD,
|
||||
database=settings.AUTH_DB_NAME,
|
||||
min_size=2,
|
||||
max_size=10,
|
||||
)
|
||||
return _pool
|
||||
|
||||
|
||||
async def close_pool() -> None:
|
||||
global _pool
|
||||
if _pool is not None:
|
||||
await _pool.close()
|
||||
_pool = None
|
||||
@@ -0,0 +1,33 @@
|
||||
"""FastAPI 依赖:IAM token 校验,提取当前管理员
|
||||
|
||||
从请求头 digi-middleware-auth-user / digi-middleware-auth-app 读取凭证,
|
||||
调用 IAM /api/iam/v2/identity/token/analyze 校验并解析用户信息。
|
||||
"""
|
||||
|
||||
from fastapi import Header, HTTPException, status
|
||||
|
||||
from . import iam
|
||||
|
||||
|
||||
async def current_admin(
|
||||
user_token: str | None = Header(None, alias="digi-middleware-auth-user"),
|
||||
app_token: str | None = Header(None, alias="digi-middleware-auth-app"),
|
||||
) -> dict:
|
||||
if not user_token:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "未提供 IAM userToken")
|
||||
|
||||
info = await iam.analyze_token(user_token, app_token)
|
||||
if info is None:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "IAM token 无效或已过期")
|
||||
|
||||
# 返回统一的管理员信息(兼容原有 admin.get("username") 调用)
|
||||
return {
|
||||
"username": info.get("name") or info.get("id") or "unknown",
|
||||
"userId": info.get("id"),
|
||||
"name": info.get("name"),
|
||||
"email": info.get("email"),
|
||||
"telephone": info.get("telephone"),
|
||||
"tenantId": info.get("tenantId"),
|
||||
"tenantName": info.get("tenantName"),
|
||||
"raw": info,
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"""鼎捷云 IAM token 鉴权服务
|
||||
|
||||
通过调用 IAM `/api/iam/v2/identity/token/analyze` 校验请求头中的
|
||||
digi-middleware-auth-user / digi-middleware-auth-app,解析出用户信息。
|
||||
|
||||
进程内 LRU 缓存(userToken -> userInfo),TTL 由 IAM_CACHE_TTL 控制,
|
||||
减少对 IAM 的重复调用。
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import settings
|
||||
|
||||
# httpx 异步客户端(进程级单例,复用连接池)
|
||||
_http_client: httpx.AsyncClient | None = None
|
||||
|
||||
# 缓存:userToken -> (user_info, fetched_at)
|
||||
_cache: dict[str, tuple[dict | None, float]] = {}
|
||||
|
||||
|
||||
def _get_http_client() -> httpx.AsyncClient:
|
||||
global _http_client
|
||||
if _http_client is None:
|
||||
_http_client = httpx.AsyncClient(timeout=5.0)
|
||||
return _http_client
|
||||
|
||||
|
||||
async def close_iam_client() -> None:
|
||||
"""关闭 httpx 客户端(进程退出时调用)。"""
|
||||
global _http_client
|
||||
if _http_client is not None:
|
||||
await _http_client.aclose()
|
||||
_http_client = None
|
||||
|
||||
|
||||
async def analyze_token(user_token: str, app_token: str | None = None) -> dict | None:
|
||||
"""校验 IAM userToken,返回用户信息 dict 或 None。
|
||||
|
||||
调用 POST {IAM_BASE_URL}/api/iam/v2/identity/token/analyze,
|
||||
请求头携带 digi-middleware-auth-app 与 digi-middleware-auth-user。
|
||||
成功时返回包含 id/name/tenantId/tenantName 等字段的 dict。
|
||||
"""
|
||||
# 1. 查缓存
|
||||
now = time.time()
|
||||
cached = _cache.get(user_token)
|
||||
if cached is not None and (now - cached[1]) < settings.IAM_CACHE_TTL:
|
||||
return cached[0]
|
||||
|
||||
# 2. 调用 IAM analyze
|
||||
headers = {
|
||||
"digi-middleware-auth-user": user_token,
|
||||
"digi-middleware-auth-app": app_token or settings.IAM_APP_TOKEN,
|
||||
}
|
||||
try:
|
||||
client = _get_http_client()
|
||||
resp = await client.post(
|
||||
f"{settings.IAM_BASE_URL}/api/iam/v2/identity/token/analyze",
|
||||
headers=headers,
|
||||
)
|
||||
except Exception as ex:
|
||||
# IAM 不可达,缓存短时间避免雪崩
|
||||
_cache[user_token] = (None, now)
|
||||
print(f"[IAM] analyze 请求异常: {ex}")
|
||||
return None
|
||||
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
# 兼容字段:id(用户标识)/ name(姓名)
|
||||
if data.get("id") or data.get("name"):
|
||||
_cache[user_token] = (data, now)
|
||||
return data
|
||||
# 返回体无用户标识,视为无效
|
||||
_cache[user_token] = (None, now)
|
||||
return None
|
||||
|
||||
# 非 200(token 无效/过期),缓存避免雪崩
|
||||
_cache[user_token] = (None, now)
|
||||
return None
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Redis 连接池 + 缓存读写封装
|
||||
|
||||
用于 verify-token 的服务/Token 缓存,减少数据库访问。
|
||||
缓存 Key 约定:
|
||||
mcp:svc:{api_key_hash} → 服务信息(TTL 120s)
|
||||
mcp:tok:{token_hash} → Token 信息(TTL 30s)
|
||||
mcp:tok:miss:{token_hash} → 无效标记,防穿透(TTL 30s)
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from .config import settings
|
||||
|
||||
_pool: aioredis.Redis | None = None
|
||||
|
||||
|
||||
async def get_redis() -> aioredis.Redis:
|
||||
"""获取 Redis 连接(进程级单例)。"""
|
||||
global _pool
|
||||
if _pool is None:
|
||||
_pool = aioredis.Redis(
|
||||
host=settings.REDIS_HOST,
|
||||
port=settings.REDIS_PORT,
|
||||
db=settings.REDIS_DB,
|
||||
password=settings.REDIS_PASSWORD,
|
||||
decode_responses=True,
|
||||
)
|
||||
return _pool
|
||||
|
||||
|
||||
async def close_redis() -> None:
|
||||
"""关闭 Redis 连接(进程退出时调用)。"""
|
||||
global _pool
|
||||
if _pool is not None:
|
||||
await _pool.aclose()
|
||||
_pool = None
|
||||
|
||||
|
||||
async def cache_get(key: str) -> dict | None:
|
||||
"""读取 JSON 缓存,返回 dict 或 None。Redis 不可用时返回 None(视为缓存未命中)。"""
|
||||
try:
|
||||
r = await get_redis()
|
||||
raw = await r.get(key)
|
||||
except Exception:
|
||||
return None
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def cache_set(key: str, value: dict, ttl: int) -> None:
|
||||
"""写入 JSON 缓存,带 TTL(秒)。Redis 不可用时静默跳过。"""
|
||||
try:
|
||||
r = await get_redis()
|
||||
await r.setex(key, ttl, json.dumps(value))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def cache_delete(*keys: str) -> None:
|
||||
"""删除缓存 key。Redis 不可用时静默跳过,缓存会自然过期。"""
|
||||
if not keys:
|
||||
return
|
||||
try:
|
||||
r = await get_redis()
|
||||
await r.delete(*keys)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,66 @@
|
||||
"""MCP Auth Admin — FastAPI 主入口
|
||||
|
||||
鉴权统一走鼎捷云 IAM:请求头 digi-middleware-auth-user / digi-middleware-auth-app
|
||||
经 IAM /api/iam/v2/identity/token/analyze 校验。
|
||||
静态文件由前端构建产物提供(STATIC_DIR 指向)。
|
||||
"""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.db import close_pool, get_pool
|
||||
from app.core.iam import close_iam_client
|
||||
from app.core.redis import close_redis
|
||||
from app.routers import auth, services, stats, tokens, verify
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await get_pool()
|
||||
yield
|
||||
await close_pool()
|
||||
await close_iam_client()
|
||||
await close_redis()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="MCP Auth Admin",
|
||||
description="MCP 服务 Bearer Token 动态鉴权管理后台",
|
||||
version="1.0.0",
|
||||
# 反向代理路径前缀:带前缀的外层 nginx(如 /mcp-auth-api)后,
|
||||
# OpenAPI servers 与 Swagger 加载的 openapi.json 会带上该前缀,文档才能正确渲染
|
||||
root_path=settings.ROOT_PATH,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# CORS(开发期前端 dev server 跨域;生产同源可不配)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# API 路由
|
||||
app.include_router(auth.router)
|
||||
app.include_router(tokens.router)
|
||||
app.include_router(services.router)
|
||||
app.include_router(stats.router)
|
||||
app.include_router(verify.router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# 静态文件(前端构建产物),必须在所有 API 路由之后挂载
|
||||
static_dir = Path(settings.STATIC_DIR)
|
||||
if static_dir.exists():
|
||||
app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")
|
||||
@@ -0,0 +1,20 @@
|
||||
"""auth 路由:当前登录用户信息(IAM 鉴权)"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from ..core.deps import current_admin
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me(admin: dict = Depends(current_admin)):
|
||||
"""返回当前 IAM 登录用户信息(由 deps.current_admin 从 IAM analyze 解析)。"""
|
||||
return {
|
||||
"userId": admin.get("userId"),
|
||||
"username": admin.get("username"),
|
||||
"name": admin.get("name"),
|
||||
"email": admin.get("email"),
|
||||
"tenantId": admin.get("tenantId"),
|
||||
"tenantName": admin.get("tenantName"),
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"""services 路由:MCP 服务注册 + per-service API Key 管理"""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..core.db import get_pool
|
||||
from ..core.deps import current_admin
|
||||
from ..core.redis import cache_delete
|
||||
|
||||
router = APIRouter(prefix="/api/services", tags=["services"])
|
||||
|
||||
|
||||
class ServiceCreate(BaseModel):
|
||||
service_name: str # erp / crm / ...
|
||||
service_url: str # MCP 服务地址(如 http://10.100.154.100:8001/mcp)
|
||||
description: str | None = None
|
||||
|
||||
|
||||
def _row_to_dict(row) -> dict:
|
||||
return {
|
||||
"service_id": row["service_id"],
|
||||
"service_name": row["service_name"],
|
||||
"service_url": row["service_url"],
|
||||
"api_key": row["api_key"],
|
||||
"description": row["description"],
|
||||
"status": row["status"],
|
||||
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
||||
"created_by": row["created_by"],
|
||||
"revoked_at": row["revoked_at"].isoformat() if row["revoked_at"] else None,
|
||||
"last_used_at": row["last_used_at"].isoformat() if row["last_used_at"] else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_services(
|
||||
status_filter: str | None = Query(None, alias="status"),
|
||||
admin: dict = Depends(current_admin),
|
||||
):
|
||||
pool = await get_pool()
|
||||
query = "SELECT * FROM mcp_service WHERE 1=1"
|
||||
params: list = []
|
||||
if status_filter:
|
||||
query += f" AND status = ${len(params)+1}"
|
||||
params.append(status_filter)
|
||||
query += " ORDER BY service_id DESC"
|
||||
rows = await pool.fetch(query, *params)
|
||||
return {"total": len(rows), "services": [_row_to_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def register_service(req: ServiceCreate, admin: dict = Depends(current_admin)):
|
||||
pool = await get_pool()
|
||||
|
||||
# 检查是否已存在同名 active 服务
|
||||
existing = await pool.fetchrow(
|
||||
"SELECT service_id FROM mcp_service WHERE service_name = $1 AND status = 'active'",
|
||||
req.service_name,
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(400, f"服务 {req.service_name} 已存在且处于 active 状态")
|
||||
|
||||
# MCP 服务地址唯一性校验
|
||||
url_existing = await pool.fetchval(
|
||||
"SELECT 1 FROM mcp_service WHERE service_url = $1",
|
||||
req.service_url.strip(),
|
||||
)
|
||||
if url_existing:
|
||||
raise HTTPException(400, f"MCP 服务地址 {req.service_url} 已被其他服务使用")
|
||||
|
||||
# 生成 API Key
|
||||
plain = secrets.token_urlsafe(32)
|
||||
api_key_hash = hashlib.sha256(plain.encode()).hexdigest()
|
||||
|
||||
row = await pool.fetchrow(
|
||||
"""INSERT INTO mcp_service (service_name, service_url, api_key, api_key_hash, description, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING service_id, service_name, service_url, api_key, description, created_at, created_by""",
|
||||
req.service_name, req.service_url.strip(), plain, api_key_hash, req.description, admin.get("username", "admin"),
|
||||
)
|
||||
|
||||
return {
|
||||
"api_key": plain,
|
||||
"service_id": row["service_id"],
|
||||
"service_name": row["service_name"],
|
||||
"message": "请保存此 API Key,配置到 MCP 服务的 MCP_AUTH_API_KEY 环境变量",
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{service_id}/revoke")
|
||||
async def revoke_service(
|
||||
service_id: int,
|
||||
admin: dict = Depends(current_admin),
|
||||
):
|
||||
pool = await get_pool()
|
||||
row = await pool.fetchrow(
|
||||
"SELECT service_id, status, api_key_hash FROM mcp_service WHERE service_id = $1", service_id
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(404, "服务不存在")
|
||||
if row["status"] == "revoked":
|
||||
raise HTTPException(400, "服务已吊销")
|
||||
|
||||
await pool.execute(
|
||||
"UPDATE mcp_service SET status = 'revoked', revoked_at = NOW() WHERE service_id = $1",
|
||||
service_id,
|
||||
)
|
||||
await cache_delete(f"mcp:svc:{row['api_key_hash']}")
|
||||
return {"success": True, "service_id": service_id, "status": "revoked"}
|
||||
|
||||
|
||||
@router.patch("/{service_id}/enable")
|
||||
async def enable_service(
|
||||
service_id: int,
|
||||
admin: dict = Depends(current_admin),
|
||||
):
|
||||
"""启用服务:将已吊销的服务恢复为 active。"""
|
||||
pool = await get_pool()
|
||||
row = await pool.fetchrow(
|
||||
"SELECT service_id, status, api_key_hash FROM mcp_service WHERE service_id = $1", service_id
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(404, "服务不存在")
|
||||
if row["status"] == "active":
|
||||
raise HTTPException(400, "服务已是启用状态")
|
||||
|
||||
await pool.execute(
|
||||
"UPDATE mcp_service SET status = 'active', revoked_at = NULL WHERE service_id = $1",
|
||||
service_id,
|
||||
)
|
||||
await cache_delete(f"mcp:svc:{row['api_key_hash']}")
|
||||
return {"success": True, "service_id": service_id, "status": "active"}
|
||||
|
||||
|
||||
@router.delete("/{service_id}")
|
||||
async def delete_service(
|
||||
service_id: int,
|
||||
admin: dict = Depends(current_admin),
|
||||
):
|
||||
pool = await get_pool()
|
||||
row = await pool.fetchrow(
|
||||
"SELECT service_id, status, api_key_hash FROM mcp_service WHERE service_id = $1", service_id
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(404, "服务不存在")
|
||||
if row["status"] != "revoked":
|
||||
raise HTTPException(400, "仅允许删除已吊销的服务,请先调用吊销端点")
|
||||
|
||||
await pool.execute("DELETE FROM mcp_service WHERE service_id = $1", service_id)
|
||||
await cache_delete(f"mcp:svc:{row['api_key_hash']}")
|
||||
return {"success": True, "service_id": service_id}
|
||||
@@ -0,0 +1,59 @@
|
||||
"""stats 路由:token 统计概览"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from ..core.db import get_pool
|
||||
from ..core.deps import current_admin
|
||||
|
||||
router = APIRouter(prefix="/api/stats", tags=["stats"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def stats(admin: dict = Depends(current_admin)):
|
||||
pool = await get_pool()
|
||||
|
||||
total = await pool.fetchval("SELECT COUNT(*) FROM mcp_token")
|
||||
active = await pool.fetchval("SELECT COUNT(*) FROM mcp_token WHERE status = 'active'")
|
||||
revoked = await pool.fetchval("SELECT COUNT(*) FROM mcp_token WHERE status = 'revoked'")
|
||||
|
||||
# 各 service_scope 活跃分布
|
||||
scope_rows = await pool.fetch(
|
||||
"SELECT service_scope, COUNT(*) AS cnt FROM mcp_token WHERE status = 'active' GROUP BY service_scope"
|
||||
)
|
||||
by_scope = {r["service_scope"]: r["cnt"] for r in scope_rows}
|
||||
|
||||
# 近 24h 调用次数(基于 last_used_at)
|
||||
calls_24h = await pool.fetchval(
|
||||
"SELECT COUNT(*) FROM mcp_token WHERE last_used_at > now() - interval '24 hours'"
|
||||
)
|
||||
|
||||
# 近 24h 活跃客户端
|
||||
clients_24h = await pool.fetchval(
|
||||
"SELECT COUNT(DISTINCT client_id) FROM mcp_token WHERE last_used_at > now() - interval '24 hours'"
|
||||
)
|
||||
|
||||
# 最近 10 条调用记录
|
||||
recent_rows = await pool.fetch(
|
||||
"""SELECT token_prefix, client_id, last_used_svc, last_used_at
|
||||
FROM mcp_token WHERE last_used_at IS NOT NULL
|
||||
ORDER BY last_used_at DESC LIMIT 10"""
|
||||
)
|
||||
recent = [
|
||||
{
|
||||
"token_prefix": r["token_prefix"],
|
||||
"client_id": r["client_id"],
|
||||
"service": r["last_used_svc"],
|
||||
"last_used_at": r["last_used_at"].isoformat() if r["last_used_at"] else None,
|
||||
}
|
||||
for r in recent_rows
|
||||
]
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"active": active,
|
||||
"revoked": revoked,
|
||||
"by_scope": by_scope,
|
||||
"calls_24h": calls_24h,
|
||||
"clients_24h": clients_24h,
|
||||
"recent": recent,
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
"""tokens 路由:token CRUD + 签发 + 吊销 + 日志"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..core.db import get_pool
|
||||
from ..core.deps import current_admin
|
||||
from ..core.redis import cache_delete
|
||||
|
||||
router = APIRouter(prefix="/api/tokens", tags=["tokens"])
|
||||
|
||||
|
||||
class TokenCreate(BaseModel):
|
||||
client_id: str
|
||||
service_scope: str # MCP 服务地址(如 http://10.100.154.100:8001/mcp),后端反查服务名入库
|
||||
description: str | None = None
|
||||
expires_at: datetime | None = None # null = 永不过期
|
||||
|
||||
|
||||
class TokenUpdate(BaseModel):
|
||||
description: str | None = None
|
||||
expires_at: datetime | None = None
|
||||
|
||||
|
||||
class RevokeReq(BaseModel):
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
def _row_to_dict(row) -> dict:
|
||||
return {
|
||||
"token_id": row["token_id"],
|
||||
"token_prefix": row["token_prefix"],
|
||||
"token_plain": row["token_plain"],
|
||||
"client_id": row["client_id"],
|
||||
"service_scope": row["service_scope"],
|
||||
"service_url": row["service_url"],
|
||||
"status": row["status"],
|
||||
"expires_at": row["expires_at"].isoformat() if row["expires_at"] else None,
|
||||
"description": row["description"],
|
||||
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
||||
"created_by": row["created_by"],
|
||||
"revoked_at": row["revoked_at"].isoformat() if row["revoked_at"] else None,
|
||||
"revoke_reason": row["revoke_reason"],
|
||||
"last_used_at": row["last_used_at"].isoformat() if row["last_used_at"] else None,
|
||||
"last_used_svc": row["last_used_svc"],
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_tokens(
|
||||
client_id: str | None = Query(None),
|
||||
status: str | None = Query(None),
|
||||
service_scope: str | None = Query(None),
|
||||
admin: dict = Depends(current_admin),
|
||||
):
|
||||
pool = await get_pool()
|
||||
query = "SELECT * FROM mcp_token WHERE 1=1"
|
||||
params: list = []
|
||||
if client_id:
|
||||
query += f" AND client_id = ${len(params)+1}"
|
||||
params.append(client_id)
|
||||
if status:
|
||||
query += f" AND status = ${len(params)+1}"
|
||||
params.append(status)
|
||||
if service_scope:
|
||||
query += f" AND service_scope = ${len(params)+1}"
|
||||
params.append(service_scope)
|
||||
query += " ORDER BY token_id DESC"
|
||||
rows = await pool.fetch(query, *params)
|
||||
return {"total": len(rows), "tokens": [_row_to_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_token(req: TokenCreate, admin: dict = Depends(current_admin)):
|
||||
pool = await get_pool()
|
||||
|
||||
# 动态校验 service_scope:传入 MCP 服务地址,反查服务名(必须是已注册的 active 服务)
|
||||
svc_row = await pool.fetchrow(
|
||||
"SELECT service_name, service_url FROM mcp_service WHERE service_url = $1 AND status = 'active'",
|
||||
req.service_scope.strip(),
|
||||
)
|
||||
if svc_row is None:
|
||||
raise HTTPException(400, f"service_scope '{req.service_scope}' 不是已注册的活跃服务")
|
||||
service_name = svc_row["service_name"]
|
||||
|
||||
# client_id + service_scope 唯一校验:同一客户端同一服务不允许重复签发
|
||||
existing = await pool.fetchval(
|
||||
"SELECT 1 FROM mcp_token WHERE client_id = $1 AND service_scope = $2 AND status = 'active'",
|
||||
req.client_id, service_name,
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(400, f"client_id '{req.client_id}' 已存在服务于 '{service_name}' 的活跃 Token,请先吊销旧 Token")
|
||||
|
||||
# 生成明文 token:仅此一次返回
|
||||
plain = secrets.token_urlsafe(32)
|
||||
token_hash = hashlib.sha256(plain.encode()).hexdigest()
|
||||
token_prefix = plain[:12] + "…"
|
||||
|
||||
row = await pool.fetchrow(
|
||||
"""INSERT INTO mcp_token (token_hash, token_plain, token_prefix, client_id, service_scope, service_url, status,
|
||||
expires_at, description, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'active', $7, $8, $9)
|
||||
RETURNING token_id, token_prefix, client_id, service_scope, service_url, status,
|
||||
expires_at, description, created_at, created_by""",
|
||||
token_hash, plain, token_prefix, req.client_id, service_name, svc_row["service_url"],
|
||||
req.expires_at, req.description, admin.get("username", "admin"),
|
||||
)
|
||||
|
||||
# 审计日志
|
||||
await pool.execute(
|
||||
"INSERT INTO mcp_token_log (token_id, event, detail) VALUES ($1, 'issued', $2)",
|
||||
row["token_id"],
|
||||
json.dumps({"client_id": req.client_id, "service_scope": service_name, "service_url": svc_row["service_url"]}),
|
||||
)
|
||||
|
||||
return {
|
||||
"token": plain, # 明文仅此一次
|
||||
"token_id": row["token_id"],
|
||||
"token_prefix": row["token_prefix"],
|
||||
"client_id": row["client_id"],
|
||||
"service_scope": row["service_scope"],
|
||||
"service_url": row["service_url"],
|
||||
"message": "请立即保存此 token,之后无法再次查看",
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{token_id}")
|
||||
async def update_token(
|
||||
token_id: int,
|
||||
req: TokenUpdate,
|
||||
admin: dict = Depends(current_admin),
|
||||
):
|
||||
pool = await get_pool()
|
||||
existing = await pool.fetchrow("SELECT token_id FROM mcp_token WHERE token_id = $1", token_id)
|
||||
if existing is None:
|
||||
raise HTTPException(404, "token 不存在")
|
||||
|
||||
updates, params = [], []
|
||||
if req.description is not None:
|
||||
updates.append(f"description = ${len(params)+1}")
|
||||
params.append(req.description)
|
||||
if req.expires_at is not None:
|
||||
updates.append(f"expires_at = ${len(params)+1}")
|
||||
params.append(req.expires_at)
|
||||
if not updates:
|
||||
raise HTTPException(400, "无更新字段")
|
||||
|
||||
params.append(token_id)
|
||||
await pool.execute(
|
||||
f"UPDATE mcp_token SET {', '.join(updates)} WHERE token_id = ${len(params)}",
|
||||
*params,
|
||||
)
|
||||
return {"success": True, "token_id": token_id}
|
||||
|
||||
|
||||
@router.put("/{token_id}/revoke")
|
||||
async def revoke_token(
|
||||
token_id: int,
|
||||
req: RevokeReq = RevokeReq(),
|
||||
admin: dict = Depends(current_admin),
|
||||
):
|
||||
"""吊销 token:软删除,status 改为 revoked。吊销后才可删除。"""
|
||||
pool = await get_pool()
|
||||
row = await pool.fetchrow(
|
||||
"SELECT token_id, status, token_hash FROM mcp_token WHERE token_id = $1", token_id
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(404, "token 不存在")
|
||||
if row["status"] == "revoked":
|
||||
raise HTTPException(400, "token 已吊销")
|
||||
|
||||
await pool.execute(
|
||||
"UPDATE mcp_token SET status = 'revoked', revoked_at = now(), revoke_reason = $2 WHERE token_id = $1",
|
||||
token_id, req.reason,
|
||||
)
|
||||
await pool.execute(
|
||||
"INSERT INTO mcp_token_log (token_id, event, detail) VALUES ($1, 'revoked', $2)",
|
||||
token_id, json.dumps({"reason": req.reason, "by": admin.get("username")}),
|
||||
)
|
||||
await cache_delete(f"mcp:tok:{row['token_hash']}", f"mcp:tok:miss:{row['token_hash']}")
|
||||
return {"success": True, "token_id": token_id, "status": "revoked"}
|
||||
|
||||
|
||||
@router.put("/{token_id}/enable")
|
||||
async def enable_token(
|
||||
token_id: int,
|
||||
admin: dict = Depends(current_admin),
|
||||
):
|
||||
"""启用 token:将已吊销的 token 恢复为 active。"""
|
||||
pool = await get_pool()
|
||||
row = await pool.fetchrow(
|
||||
"SELECT token_id, status, token_hash FROM mcp_token WHERE token_id = $1", token_id
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(404, "token 不存在")
|
||||
if row["status"] == "active":
|
||||
raise HTTPException(400, "token 已是启用状态")
|
||||
|
||||
await pool.execute(
|
||||
"UPDATE mcp_token SET status = 'active', revoked_at = NULL, revoke_reason = NULL WHERE token_id = $1",
|
||||
token_id,
|
||||
)
|
||||
await pool.execute(
|
||||
"INSERT INTO mcp_token_log (token_id, event, detail) VALUES ($1, 'enabled', $2)",
|
||||
token_id, json.dumps({"by": admin.get("username")}),
|
||||
)
|
||||
await cache_delete(f"mcp:tok:{row['token_hash']}", f"mcp:tok:miss:{row['token_hash']}")
|
||||
return {"success": True, "token_id": token_id, "status": "active"}
|
||||
|
||||
|
||||
@router.delete("/{token_id}")
|
||||
async def delete_token(
|
||||
token_id: int,
|
||||
admin: dict = Depends(current_admin),
|
||||
):
|
||||
"""删除 token:物理删除,仅允许删除已吊销的 token。"""
|
||||
pool = await get_pool()
|
||||
row = await pool.fetchrow(
|
||||
"SELECT token_id, status, token_hash FROM mcp_token WHERE token_id = $1", token_id
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(404, "token 不存在")
|
||||
if row["status"] != "revoked":
|
||||
raise HTTPException(400, "仅可删除已吊销的 token,请先吊销")
|
||||
|
||||
await pool.execute("DELETE FROM mcp_token_log WHERE token_id = $1", token_id)
|
||||
await pool.execute("DELETE FROM mcp_token WHERE token_id = $1", token_id)
|
||||
await cache_delete(f"mcp:tok:{row['token_hash']}", f"mcp:tok:miss:{row['token_hash']}")
|
||||
return {"success": True, "token_id": token_id}
|
||||
|
||||
|
||||
@router.get("/{token_id}/logs")
|
||||
async def token_logs(token_id: int, limit: int = 50, admin: dict = Depends(current_admin)):
|
||||
pool = await get_pool()
|
||||
rows = await pool.fetch(
|
||||
"""SELECT log_id, event, service, client_ip, occurred_at, detail
|
||||
FROM mcp_token_log WHERE token_id = $1
|
||||
ORDER BY occurred_at DESC LIMIT $2""",
|
||||
token_id, limit,
|
||||
)
|
||||
return {
|
||||
"total": len(rows),
|
||||
"logs": [
|
||||
{
|
||||
"log_id": r["log_id"],
|
||||
"event": r["event"],
|
||||
"service": r["service"],
|
||||
"client_ip": r["client_ip"],
|
||||
"occurred_at": r["occurred_at"].isoformat() if r["occurred_at"] else None,
|
||||
"detail": r["detail"],
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
"""内部 token 校验 API — 供 MCP 服务调用(不查库直连,走 HTTP)。
|
||||
|
||||
MCP 服务(ERP/CRM)通过此端点校验 Bearer Token,后端统一查 mcp_auth.mcp_token 表。
|
||||
用 per-service API Key(X-API-Key)鉴权,从 key 识别调用方服务,无需 MCP 服务自报 service。
|
||||
|
||||
Redis 缓存层:
|
||||
mcp:svc:{api_key_hash} → 服务信息(TTL 120s)
|
||||
mcp:tok:{token_hash} → Token 信息(TTL 30s)
|
||||
mcp:tok:miss:{token_hash} → 无效标记,防穿透(TTL 30s)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..core.db import get_pool
|
||||
from ..core.redis import cache_get, cache_set, cache_delete
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["verify"])
|
||||
|
||||
# 缓存 TTL
|
||||
_SVC_TTL = 120 # 服务缓存 120s(服务极少变化)
|
||||
_TOK_TTL = 30 # Token 缓存 30s(与 MCP 服务侧对齐)
|
||||
|
||||
|
||||
class VerifyReq(BaseModel):
|
||||
token: str # 明文 Bearer Token
|
||||
|
||||
|
||||
class VerifyResp(BaseModel):
|
||||
valid: bool
|
||||
client_id: str | None = None
|
||||
service_scope: str | None = None
|
||||
|
||||
|
||||
async def _resolve_service(x_api_key: str | None = Header(None, alias="X-API-Key")) -> str:
|
||||
"""校验 per-service API Key,返回 service_name;不匹配则 401。
|
||||
|
||||
Redis 缓存 mcp:svc:{api_key_hash},命中则跳过 DB。
|
||||
"""
|
||||
if not x_api_key:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "missing X-API-Key")
|
||||
api_key_hash = hashlib.sha256(x_api_key.encode()).hexdigest()
|
||||
cache_key = f"mcp:svc:{api_key_hash}"
|
||||
|
||||
# 1. 查缓存
|
||||
cached = await cache_get(cache_key)
|
||||
if cached is not None:
|
||||
if cached.get("status") != "active":
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid or revoked api key")
|
||||
# 异步更新 last_used_at
|
||||
pool = await get_pool()
|
||||
asyncio.create_task(_touch_service(pool, cached["service_id"]))
|
||||
return cached["service_name"]
|
||||
|
||||
# 2. 查 DB
|
||||
pool = await get_pool()
|
||||
row = await pool.fetchrow(
|
||||
"SELECT service_id, service_name, status FROM mcp_service WHERE api_key_hash = $1",
|
||||
api_key_hash,
|
||||
)
|
||||
if row is None or row["status"] != "active":
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid or revoked api key")
|
||||
|
||||
# 3. 写缓存
|
||||
await cache_set(cache_key, {
|
||||
"service_id": row["service_id"],
|
||||
"service_name": row["service_name"],
|
||||
"status": row["status"],
|
||||
}, _SVC_TTL)
|
||||
|
||||
# 异步更新 last_used_at
|
||||
asyncio.create_task(_touch_service(pool, row["service_id"]))
|
||||
return row["service_name"]
|
||||
|
||||
|
||||
@router.post("/verify-token", response_model=VerifyResp)
|
||||
async def verify_token(req: VerifyReq, service: str = Depends(_resolve_service)):
|
||||
"""校验 Bearer Token:sha256 比对 + 状态/过期/服务范围检查 + 更新 last_used。
|
||||
|
||||
service 由 X-API-Key 自动识别,MCP 服务无需在 body 中传 service。
|
||||
返回 valid=true 时附带 client_id 和 service_scope;校验失败返回 valid=false(非 401)。
|
||||
|
||||
Redis 缓存 mcp:tok:{token_hash},命中则跳过 DB。
|
||||
无效结果也缓存(mcp:tok:miss:),防穿透。
|
||||
"""
|
||||
token_hash = hashlib.sha256(req.token.encode()).hexdigest()
|
||||
cache_key = f"mcp:tok:{token_hash}"
|
||||
miss_key = f"mcp:tok:miss:{token_hash}"
|
||||
|
||||
# 1. 查无效标记(防穿透)
|
||||
miss_cached = await cache_get(miss_key)
|
||||
if miss_cached is not None:
|
||||
return VerifyResp(valid=False)
|
||||
|
||||
# 2. 查 Token 缓存
|
||||
cached = await cache_get(cache_key)
|
||||
if cached is not None:
|
||||
# 校验状态
|
||||
if cached.get("status") != "active":
|
||||
return VerifyResp(valid=False)
|
||||
# 校验过期
|
||||
if cached.get("expires_at") and cached["expires_at"] > 0:
|
||||
if cached["expires_at"] < datetime.now(timezone.utc).timestamp():
|
||||
return VerifyResp(valid=False)
|
||||
# 校验服务范围
|
||||
scope = cached.get("service_scope")
|
||||
if scope != service:
|
||||
return VerifyResp(valid=False)
|
||||
# 异步更新 last_used
|
||||
pool = await get_pool()
|
||||
asyncio.create_task(_touch_token(pool, cached["token_id"], service))
|
||||
return VerifyResp(valid=True, client_id=cached.get("client_id"), service_scope=scope)
|
||||
|
||||
# 3. 查 DB
|
||||
pool = await get_pool()
|
||||
row = await pool.fetchrow(
|
||||
"""SELECT token_id, client_id, status, expires_at, service_scope
|
||||
FROM mcp_token WHERE token_hash = $1""",
|
||||
token_hash,
|
||||
)
|
||||
|
||||
# 不存在 → 缓存无效标记
|
||||
if row is None:
|
||||
await cache_set(miss_key, {"valid": False}, _TOK_TTL)
|
||||
return VerifyResp(valid=False)
|
||||
|
||||
# 写缓存(expires_at 转为时间戳,便于序列化)
|
||||
cache_val = {
|
||||
"token_id": row["token_id"],
|
||||
"client_id": row["client_id"],
|
||||
"status": row["status"],
|
||||
"expires_at": row["expires_at"].timestamp() if row["expires_at"] else None,
|
||||
"service_scope": row["service_scope"],
|
||||
}
|
||||
await cache_set(cache_key, cache_val, _TOK_TTL)
|
||||
|
||||
# 已吊销
|
||||
if row["status"] != "active":
|
||||
return VerifyResp(valid=False)
|
||||
|
||||
# 已过期
|
||||
if row["expires_at"] is not None and row["expires_at"].timestamp() < datetime.now(timezone.utc).timestamp():
|
||||
return VerifyResp(valid=False)
|
||||
|
||||
# 服务范围校验:要求精确匹配
|
||||
scope = row["service_scope"]
|
||||
if scope != service:
|
||||
return VerifyResp(valid=False)
|
||||
|
||||
# 异步更新 last_used_at / last_used_svc
|
||||
asyncio.create_task(_touch_token(pool, row["token_id"], service))
|
||||
|
||||
return VerifyResp(valid=True, client_id=row["client_id"], service_scope=scope)
|
||||
|
||||
|
||||
async def _touch_service(pool, service_id: int) -> None:
|
||||
try:
|
||||
await pool.execute(
|
||||
"UPDATE mcp_service SET last_used_at = now() WHERE service_id = $1", service_id
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _touch_token(pool, token_id: int, service: str) -> None:
|
||||
try:
|
||||
await pool.execute(
|
||||
"UPDATE mcp_token SET last_used_at = now(), last_used_svc = $2 WHERE token_id = $1",
|
||||
token_id, service,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
Reference in New Issue
Block a user