调整文档路径

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
+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