调整文档路径

This commit is contained in:
2026-09-02 17:31:52 +08:00
parent 01b3798e02
commit 674ff09db1
43 changed files with 0 additions and 0 deletions
View File
View File
+37
View File
@@ -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()
+28
View File
@@ -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
+33
View File
@@ -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,
}
+80
View File
@@ -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
+73
View File
@@ -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
+66
View File
@@ -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")
View File
+20
View File
@@ -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"),
}
+153
View File
@@ -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}
+59
View File
@@ -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,
}
+259
View File
@@ -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
],
}
+176
View File
@@ -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