添加服务注册及api鉴权

This commit is contained in:
2026-08-31 13:06:37 +08:00
parent cbff2283e2
commit 6fe61ff094
15 changed files with 764 additions and 218 deletions
+8 -14
View File
@@ -28,13 +28,10 @@ services:
- ERP_DB_NAME=${ERP_DB_NAME:-smart_quotation_auto} - ERP_DB_NAME=${ERP_DB_NAME:-smart_quotation_auto}
# 对外地址(用于 OAuth 资源元数据),按实际部署机 IP 调整 # 对外地址(用于 OAuth 资源元数据),按实际部署机 IP 调整
- MCP_PUBLIC_URL=${MCP_PUBLIC_URL_ERP:-http://192.168.1.119:8001} - MCP_PUBLIC_URL=${MCP_PUBLIC_URL_ERP:-http://192.168.1.119:8001}
# 鉴权库(mcp_auth),DbTokenVerifier 查 mcp_token 表校验 # 鉴权(调用 mcp-auth 后端 API 校验,不直连鉴权库)
- MCP_AUTH_DB_HOST=${MCP_AUTH_DB_HOST:-192.168.1.206} # per-service API Key:由 mcp-auth 后端签发,各服务独立
- MCP_AUTH_DB_PORT=${MCP_AUTH_DB_PORT:-5432} - MCP_AUTH_API_URL=${MCP_AUTH_API_URL:-http://192.168.1.119:8000}
- MCP_AUTH_DB_USER=${MCP_AUTH_DB_USER:-postgres} - MCP_AUTH_API_KEY=${ERP_MCP_AUTH_API_KEY:-change-me-erp-key}
- MCP_AUTH_DB_PASSWORD=${MCP_AUTH_DB_PASSWORD:-digiwin}
- MCP_AUTH_DB_NAME=${MCP_AUTH_DB_NAME:-mcp_auth}
- MCP_AUTH_SERVICE=erp
restart: unless-stopped restart: unless-stopped
mcp-for-crm: mcp-for-crm:
@@ -54,13 +51,10 @@ services:
- CRM_DB_NAME=${CRM_DB_NAME:-smart_quotation_auto} - CRM_DB_NAME=${CRM_DB_NAME:-smart_quotation_auto}
# 对外地址(用于 OAuth 资源元数据),按实际部署机 IP 调整 # 对外地址(用于 OAuth 资源元数据),按实际部署机 IP 调整
- MCP_PUBLIC_URL=${MCP_PUBLIC_URL_CRM:-http://192.168.1.119:8002} - MCP_PUBLIC_URL=${MCP_PUBLIC_URL_CRM:-http://192.168.1.119:8002}
# 鉴权库(mcp_auth),DbTokenVerifier 查 mcp_token 表校验 # 鉴权(调用 mcp-auth 后端 API 校验,不直连鉴权库)
- MCP_AUTH_DB_HOST=${MCP_AUTH_DB_HOST:-192.168.1.206} # per-service API Key:由 mcp-auth 后端签发,各服务独立
- MCP_AUTH_DB_PORT=${MCP_AUTH_DB_PORT:-5432} - MCP_AUTH_API_URL=${MCP_AUTH_API_URL:-http://192.168.1.119:8000}
- MCP_AUTH_DB_USER=${MCP_AUTH_DB_USER:-postgres} - MCP_AUTH_API_KEY=${CRM_MCP_AUTH_API_KEY:-change-me-crm-key}
- MCP_AUTH_DB_PASSWORD=${MCP_AUTH_DB_PASSWORD:-digiwin}
- MCP_AUTH_DB_NAME=${MCP_AUTH_DB_NAME:-mcp_auth}
- MCP_AUTH_SERVICE=crm
restart: unless-stopped restart: unless-stopped
# MCP Auth Admin — Bearer Token 动态鉴权管理后台(FastAPI + React) # MCP Auth Admin — Bearer Token 动态鉴权管理后台(FastAPI + React)
+3 -1
View File
@@ -15,7 +15,7 @@ from fastapi.staticfiles import StaticFiles
from app.core import security from app.core import security
from app.core.config import settings from app.core.config import settings
from app.core.db import close_pool, get_pool from app.core.db import close_pool, get_pool
from app.routers import auth, stats, tokens from app.routers import auth, services, stats, tokens, verify
async def _ensure_default_admin() -> None: async def _ensure_default_admin() -> None:
@@ -57,7 +57,9 @@ app.add_middleware(
# API 路由 # API 路由
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(tokens.router) app.include_router(tokens.router)
app.include_router(services.router)
app.include_router(stats.router) app.include_router(stats.router)
app.include_router(verify.router)
@app.get("/api/health") @app.get("/api/health")
+119
View File
@@ -0,0 +1,119 @@
"""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
router = APIRouter(prefix="/api/services", tags=["services"])
class ServiceCreate(BaseModel):
service_name: str # erp / crm / ...
description: str | None = None
def _row_to_dict(row) -> dict:
return {
"service_id": row["service_id"],
"service_name": row["service_name"],
"api_key_prefix": row["api_key_prefix"],
"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 状态")
# 生成 API Key(明文仅此一次返回)
plain = secrets.token_urlsafe(32)
api_key_hash = hashlib.sha256(plain.encode()).hexdigest()
api_key_prefix = plain[:12] + "…"
row = await pool.fetchrow(
"""INSERT INTO mcp_service (service_name, api_key_hash, api_key_prefix, description, created_by)
VALUES ($1, $2, $3, $4, $5)
RETURNING service_id, service_name, api_key_prefix, description, created_at, created_by""",
req.service_name, api_key_hash, api_key_prefix, req.description, admin.get("username", "admin"),
)
return {
"api_key": plain, # 明文仅此一次
"service_id": row["service_id"],
"service_name": row["service_name"],
"api_key_prefix": row["api_key_prefix"],
"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 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,
)
return {"success": True, "service_id": service_id, "status": "revoked"}
@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 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)
return {"success": True, "service_id": service_id}
+32 -6
View File
@@ -13,8 +13,6 @@ from ..core.deps import current_admin
router = APIRouter(prefix="/api/tokens", tags=["tokens"]) router = APIRouter(prefix="/api/tokens", tags=["tokens"])
VALID_SCOPES = ("erp", "crm", "both")
class TokenCreate(BaseModel): class TokenCreate(BaseModel):
client_id: str client_id: str
@@ -76,15 +74,22 @@ async def list_tokens(
@router.post("", status_code=status.HTTP_201_CREATED) @router.post("", status_code=status.HTTP_201_CREATED)
async def create_token(req: TokenCreate, admin: dict = Depends(current_admin)): async def create_token(req: TokenCreate, admin: dict = Depends(current_admin)):
if req.service_scope not in VALID_SCOPES: pool = await get_pool()
raise HTTPException(400, f"service_scope 必须是 {VALID_SCOPES} 之一")
# 动态校验 service_scope:both 始终有效,其他值必须是已注册的 active 服务
if req.service_scope != "both":
exists = await pool.fetchval(
"SELECT 1 FROM mcp_service WHERE service_name = $1 AND status = 'active'",
req.service_scope,
)
if not exists:
raise HTTPException(400, f"service_scope '{req.service_scope}' 不是已注册的活跃服务")
# 生成明文 token:仅此一次返回 # 生成明文 token:仅此一次返回
plain = secrets.token_urlsafe(32) plain = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(plain.encode()).hexdigest() token_hash = hashlib.sha256(plain.encode()).hexdigest()
token_prefix = plain[:12] + "…" token_prefix = plain[:12] + "…"
pool = await get_pool()
row = await pool.fetchrow( row = await pool.fetchrow(
"""INSERT INTO mcp_token (token_hash, token_prefix, client_id, service_scope, status, """INSERT INTO mcp_token (token_hash, token_prefix, client_id, service_scope, status,
expires_at, description, created_by) expires_at, description, created_by)
@@ -141,12 +146,13 @@ async def update_token(
return {"success": True, "token_id": token_id} return {"success": True, "token_id": token_id}
@router.delete("/{token_id}") @router.put("/{token_id}/revoke")
async def revoke_token( async def revoke_token(
token_id: int, token_id: int,
req: RevokeReq = RevokeReq(), req: RevokeReq = RevokeReq(),
admin: dict = Depends(current_admin), admin: dict = Depends(current_admin),
): ):
"""吊销 token:软删除,status 改为 revoked。吊销后才可删除。"""
pool = await get_pool() pool = await get_pool()
row = await pool.fetchrow( row = await pool.fetchrow(
"SELECT token_id, status FROM mcp_token WHERE token_id = $1", token_id "SELECT token_id, status FROM mcp_token WHERE token_id = $1", token_id
@@ -167,6 +173,26 @@ async def revoke_token(
return {"success": True, "token_id": token_id, "status": "revoked"} return {"success": True, "token_id": token_id, "status": "revoked"}
@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 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)
return {"success": True, "token_id": token_id}
@router.get("/{token_id}/logs") @router.get("/{token_id}/logs")
async def token_logs(token_id: int, limit: int = 50, admin: dict = Depends(current_admin)): async def token_logs(token_id: int, limit: int = 50, admin: dict = Depends(current_admin)):
pool = await get_pool() pool = await get_pool()
+104
View File
@@ -0,0 +1,104 @@
"""内部 token 校验 API — 供 MCP 服务调用(不查库直连,走 HTTP)。
MCP 服务(ERP/CRM)通过此端点校验 Bearer Token,后端统一查 mcp_auth.mcp_token 表。
用 per-service API Key(X-API-Key)鉴权,从 key 识别调用方服务,无需 MCP 服务自报 service。
"""
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
router = APIRouter(prefix="/api/auth", tags=["verify"])
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。
用 sha256 比对 mcp_service 表,避免明文存储。同时更新 last_used_at。
"""
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()
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")
# 异步更新 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)。
"""
token_hash = hashlib.sha256(req.token.encode()).hexdigest()
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:
return VerifyResp(valid=False)
# 已吊销
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)
# 服务范围校验:both 放行所有;否则要求精确匹配(由 API Key 识别的 service)
scope = row["service_scope"]
if scope != "both" and 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
+3
View File
@@ -2,6 +2,7 @@ import { Layout, Menu, theme } from 'antd';
import { useState } from 'react'; import { useState } from 'react';
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import Login from './pages/Login'; import Login from './pages/Login';
import Services from './pages/Services';
import Stats from './pages/Stats'; import Stats from './pages/Stats';
import Tokens from './pages/Tokens'; import Tokens from './pages/Tokens';
const { Header, Content, Sider } = Layout; const { Header, Content, Sider } = Layout;
@@ -35,6 +36,7 @@ function AppLayout() {
selectedKeys={[loc.pathname]} selectedKeys={[loc.pathname]}
items={[ items={[
{ key: '/tokens', label: 'Token 管理' }, { key: '/tokens', label: 'Token 管理' },
{ key: '/services', label: '服务管理' },
{ key: '/stats', label: '统计概览' }, { key: '/stats', label: '统计概览' },
]} ]}
onClick={({ key }) => nav(key)} onClick={({ key }) => nav(key)}
@@ -74,6 +76,7 @@ function AppLayout() {
> >
<Routes> <Routes>
<Route path="/tokens" element={<Tokens />} /> <Route path="/tokens" element={<Tokens />} />
<Route path="/services" element={<Services />} />
<Route path="/stats" element={<Stats />} /> <Route path="/stats" element={<Stats />} />
<Route path="*" element={<Navigate to="/tokens" replace />} /> <Route path="*" element={<Navigate to="/tokens" replace />} />
</Routes> </Routes>
+53 -1
View File
@@ -52,7 +52,12 @@ export async function createToken(payload: TokenCreate) {
} }
export async function revokeToken(token_id: number, reason?: string) { export async function revokeToken(token_id: number, reason?: string) {
const { data } = await api.delete(`/tokens/${token_id}`, { data: { reason } }); const { data } = await api.put(`/tokens/${token_id}/revoke`, { reason });
return data;
}
export async function deleteToken(token_id: number) {
const { data } = await api.delete(`/tokens/${token_id}`);
return data; return data;
} }
@@ -96,3 +101,50 @@ export async function getStats() {
}>; }>;
}; };
} }
// ==================== MCP 服务管理 ====================
export interface ServiceRow {
service_id: number;
service_name: string;
api_key_prefix: string;
description: string | null;
status: string;
created_at: string | null;
created_by: string;
revoked_at: string | null;
last_used_at: string | null;
}
export interface ServiceCreate {
service_name: string;
description?: string;
}
export interface ServiceCreateResult {
api_key: string;
service_id: number;
service_name: string;
api_key_prefix: string;
message: string;
}
export async function listServices(params?: { status?: string }) {
const { data } = await api.get('/services', { params });
return data as { total: number; services: ServiceRow[] };
}
export async function registerService(payload: ServiceCreate) {
const { data } = await api.post('/services', payload);
return data as ServiceCreateResult;
}
export async function revokeService(service_id: number) {
const { data } = await api.patch(`/services/${service_id}/revoke`);
return data;
}
export async function deleteService(service_id: number) {
const { data } = await api.delete(`/services/${service_id}`);
return data;
}
@@ -0,0 +1,206 @@
import { useEffect, useState } from 'react';
import {
Button,
Form,
Input,
Modal,
Space,
Table,
Tag,
Typography,
message,
} from 'antd';
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import type { ColumnsType } from 'antd/es/table';
import {
listServices,
registerService,
revokeService,
deleteService,
type ServiceCreateResult,
type ServiceRow,
} from '../../api';
const STATUS_COLOR: Record<string, string> = {
active: 'green',
revoked: 'red',
};
export default function Services() {
const [rows, setRows] = useState<ServiceRow[]>([]);
const [loading, setLoading] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [created, setCreated] = useState<ServiceCreateResult | null>(null);
const [form] = Form.useForm();
const load = async () => {
setLoading(true);
try {
const res = await listServices();
setRows(res.services);
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const onRevoke = async (id: number) => {
Modal.confirm({
title: '吊销此服务的 API Key?',
content: '吊销后该 MCP 服务将无法调用鉴权 API。吊销后可执行删除操作。',
okText: '吊销',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
await revokeService(id);
message.success('已吊销');
load();
},
});
};
const onDelete = async (id: number) => {
Modal.confirm({
title: '删除此服务?',
content: '删除后记录将永久移除,无法恢复。',
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
await deleteService(id);
message.success('已删除');
load();
},
});
};
const onCreate = async () => {
const values = await form.validateFields();
const res = await registerService({
service_name: values.service_name,
description: values.description,
});
setCreated(res);
setCreateOpen(false);
form.resetFields();
load();
};
const columns: ColumnsType<ServiceRow> = [
{ title: '服务名', dataIndex: 'service_name', key: 'service_name', width: 120 },
{ title: 'Key 前缀', dataIndex: 'api_key_prefix', key: 'api_key_prefix', width: 140 },
{ title: '说明', dataIndex: 'description', key: 'description' },
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 90,
render: (s: string) => <Tag color={STATUS_COLOR[s] || 'default'}>{s}</Tag>,
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 160,
render: (t: string) => (t ? dayjs(t).format('YYYY-MM-DD HH:mm') : '-'),
},
{
title: '最近调用',
dataIndex: 'last_used_at',
key: 'last_used_at',
width: 160,
render: (t: string) => (t ? dayjs(t).format('MM-DD HH:mm') : '-'),
},
{
title: '操作',
key: 'action',
width: 140,
render: (_, row: ServiceRow) =>
row.status === 'active' ? (
<Space size="small">
<Button type="link" danger size="small" onClick={() => onRevoke(row.service_id)}>
吊销
</Button>
<Button type="link" size="small" disabled onClick={() => onDelete(row.service_id)}>
删除
</Button>
</Space>
) : row.status === 'revoked' ? (
<Button type="link" danger size="small" onClick={() => onDelete(row.service_id)}>
删除
</Button>
) : (
<Typography.Text type="secondary">-</Typography.Text>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
注册服务
</Button>
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>
刷新
</Button>
</Space>
<Table
columns={columns}
dataSource={rows}
rowKey="service_id"
loading={loading}
pagination={{ pageSize: 15 }}
/>
{/* 注册服务表单 */}
<Modal
title="注册 MCP 服务"
open={createOpen}
onOk={onCreate}
onCancel={() => setCreateOpen(false)}
okText="注册"
cancelText="取消"
>
<Form form={form} layout="vertical">
<Form.Item
name="service_name"
label="服务标识"
rules={[{ required: true, message: '请输入服务标识' }]}
>
<Input placeholder="如 erp / crm" />
</Form.Item>
<Form.Item name="description" label="说明">
<Input.TextArea rows={2} placeholder="服务用途说明" />
</Form.Item>
</Form>
</Modal>
{/* 注册结果:明文 API Key 仅此一次 */}
<Modal
title="API Key 已生成"
open={created !== null}
onCancel={() => setCreated(null)}
footer={<Button type="primary" onClick={() => setCreated(null)}>我已保存</Button>}
>
<Typography.Paragraph type="warning">
请立即复制并保存,此 API Key 仅显示一次,之后无法再次查看。
</Typography.Paragraph>
<Input.TextArea
rows={2}
value={created?.api_key || ''}
readOnly
style={{ fontFamily: 'monospace' }}
/>
<Typography.Paragraph type="secondary" style={{ marginTop: 12 }}>
配置到 MCP 服务的 <code>MCP_AUTH_API_KEY</code> 环境变量。
</Typography.Paragraph>
</Modal>
</div>
);
}
+46 -6
View File
@@ -17,9 +17,12 @@ import type { ColumnsType } from 'antd/es/table';
import { import {
createToken, createToken,
listTokens, listTokens,
listServices,
revokeToken, revokeToken,
deleteToken,
type CreateResult, type CreateResult,
type TokenRow, type TokenRow,
type ServiceRow,
} from '../../api'; } from '../../api';
const STATUS_COLOR: Record<string, string> = { const STATUS_COLOR: Record<string, string> = {
@@ -33,6 +36,7 @@ export default function Tokens() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [created, setCreated] = useState<CreateResult | null>(null); const [created, setCreated] = useState<CreateResult | null>(null);
const [services, setServices] = useState<ServiceRow[]>([]);
const [form] = Form.useForm(); const [form] = Form.useForm();
const load = async () => { const load = async () => {
@@ -45,14 +49,24 @@ export default function Tokens() {
} }
}; };
const loadServices = async () => {
try {
const res = await listServices({ status: 'active' });
setServices(res.services);
} catch {
// 服务列表加载失败不影响 token 页面使用
}
};
useEffect(() => { useEffect(() => {
load(); load();
loadServices();
}, []); }, []);
const onRevoke = async (id: number) => { const onRevoke = async (id: number) => {
Modal.confirm({ Modal.confirm({
title: '吊销此 Token?', title: '吊销此 Token?',
content: '吊销后最长 30s 缓存过期生效,且无法恢复。', content: '吊销后该 Token 立即失效。吊销后可执行删除操作。',
okText: '吊销', okText: '吊销',
okType: 'danger', okType: 'danger',
cancelText: '取消', cancelText: '取消',
@@ -64,6 +78,21 @@ export default function Tokens() {
}); });
}; };
const onDelete = async (id: number) => {
Modal.confirm({
title: '删除此 Token?',
content: '删除后记录将永久移除,无法恢复。',
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
await deleteToken(id);
message.success('已删除');
load();
},
});
};
const onCreate = async () => { const onCreate = async () => {
const values = await form.validateFields(); const values = await form.validateFields();
const res = await createToken({ const res = await createToken({
@@ -114,12 +143,21 @@ export default function Tokens() {
{ {
title: '操作', title: '操作',
key: 'action', key: 'action',
width: 100, width: 140,
render: (_, row: TokenRow) => render: (_, row: TokenRow) =>
row.status === 'active' ? ( row.status === 'active' ? (
<Space size="small">
<Button type="link" danger size="small" onClick={() => onRevoke(row.token_id)}> <Button type="link" danger size="small" onClick={() => onRevoke(row.token_id)}>
吊销 吊销
</Button> </Button>
<Button type="link" size="small" disabled onClick={() => onDelete(row.token_id)}>
删除
</Button>
</Space>
) : row.status === 'revoked' ? (
<Button type="link" danger size="small" onClick={() => onDelete(row.token_id)}>
删除
</Button>
) : ( ) : (
<Typography.Text type="secondary">-</Typography.Text> <Typography.Text type="secondary">-</Typography.Text>
), ),
@@ -129,7 +167,7 @@ export default function Tokens() {
return ( return (
<div> <div>
<Space style={{ marginBottom: 16 }}> <Space style={{ marginBottom: 16 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}> <Button type="primary" icon={<PlusOutlined />} onClick={() => { loadServices(); setCreateOpen(true); }}>
签发 Token 签发 Token
</Button> </Button>
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}> <Button icon={<ReloadOutlined />} onClick={load} loading={loading}>
@@ -165,9 +203,11 @@ export default function Tokens() {
<Form.Item name="service_scope" label="服务范围"> <Form.Item name="service_scope" label="服务范围">
<Select <Select
options={[ options={[
{ value: 'both', label: 'ERP + CRM' }, { value: 'both', label: '全部服务' },
{ value: 'erp', label: '仅 ERP' }, ...services.map((s) => ({
{ value: 'crm', label: '仅 CRM' }, value: s.service_name,
label: `仅 ${s.service_name.toUpperCase()}`,
})),
]} ]}
/> />
</Form.Item> </Form.Item>
+16 -1
View File
@@ -51,7 +51,22 @@ CREATE TABLE IF NOT EXISTS admin_user (
last_login_at TIMESTAMPTZ last_login_at TIMESTAMPTZ
); );
-- 4. 默认管理员(密码: admin123,bcrypt $2b$12$... 由后端首次启动时注入,此处仅占位) -- 4. mcp_service:MCP 服务注册表(per-service API Key)
CREATE TABLE IF NOT EXISTS mcp_service (
service_id BIGSERIAL PRIMARY KEY,
service_name VARCHAR(64) UNIQUE NOT NULL, -- erp / crm / ...
api_key_hash VARCHAR(64) UNIQUE NOT NULL, -- sha256(明文 API Key)
api_key_prefix VARCHAR(16) NOT NULL, -- 明文前 12 字符 + '…',前端识别用
description VARCHAR(200),
status VARCHAR(16) NOT NULL DEFAULT 'active', -- active/revoked
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by VARCHAR(64) NOT NULL,
revoked_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_mcp_service_status ON mcp_service(status) WHERE status = 'active';
-- 5. 默认管理员(密码: admin123,bcrypt $2b$12$... 由后端首次启动时注入,此处仅占位)
-- 实际部署:python -m backend.scripts.seed_admin 或由后端首次启动自动建 -- 实际部署:python -m backend.scripts.seed_admin 或由后端首次启动自动建
-- 这里给出手工生成 bcrypt 的 SQL 模板(替换 $BCRYPT_HASH 为实际值): -- 这里给出手工生成 bcrypt 的 SQL 模板(替换 $BCRYPT_HASH 为实际值):
-- INSERT INTO admin_user (username, password_hash, display_name) -- INSERT INTO admin_user (username, password_hash, display_name)
+44 -7
View File
@@ -1,4 +1,4 @@
"""初始化 mcp_auth 库 + 灌入现有静态 token(一次性迁移脚本)。 """初始化 mcp_auth 库 + 灌入现有静态 token + 注册 MCP 服务(一次性迁移脚本)。
用法: 用法:
# 开发库(47.101.220.40) # 开发库(47.101.220.40)
@@ -7,13 +7,14 @@
# 生产库(192.168.1.206) # 生产库(192.168.1.206)
python seed.py --host 192.168.1.206 --user postgres --password digiwin python seed.py --host 192.168.1.206 --user postgres --password digiwin
幂等:可重复执行,已存在的 token 跳过。 幂等:可重复执行,已存在的 token / service 跳过。
""" """
import argparse import argparse
import asyncio import asyncio
import hashlib import hashlib
import os import os
import secrets
from pathlib import Path from pathlib import Path
import asyncpg import asyncpg
@@ -28,11 +29,17 @@ STATIC_TOKENS = [
("dev-token-trae", "trae", "both", "开发环境共用 token"), ("dev-token-trae", "trae", "both", "开发环境共用 token"),
] ]
# MCP 服务初始注册:service_name → description
INITIAL_SERVICES = [
("erp", "ERP 数据服务(汽车零部件智能报价)"),
("crm", "CRM 数据服务(汽车零部件智能报价)"),
]
INIT_SQL_PATH = Path(__file__).resolve().parent.parent / "sql" / "init.sql" INIT_SQL_PATH = Path(__file__).resolve().parent.parent / "sql" / "init.sql"
async def main() -> None: async def main() -> None:
parser = argparse.ArgumentParser(description="初始化 mcp_auth 库并迁移静态 token") parser = argparse.ArgumentParser(description="初始化 mcp_auth 库并迁移静态 token + 注册服务")
parser.add_argument("--host", default=os.getenv("MCP_AUTH_DB_HOST", "127.0.0.1")) parser.add_argument("--host", default=os.getenv("MCP_AUTH_DB_HOST", "127.0.0.1"))
parser.add_argument("--port", type=int, default=int(os.getenv("MCP_AUTH_DB_PORT", "5432"))) parser.add_argument("--port", type=int, default=int(os.getenv("MCP_AUTH_DB_PORT", "5432")))
parser.add_argument("--user", default=os.getenv("MCP_AUTH_DB_USER", "postgres")) parser.add_argument("--user", default=os.getenv("MCP_AUTH_DB_USER", "postgres"))
@@ -58,7 +65,7 @@ async def main() -> None:
) )
init_sql = INIT_SQL_PATH.read_text(encoding="utf-8") init_sql = INIT_SQL_PATH.read_text(encoding="utf-8")
await conn.execute(init_sql) await conn.execute(init_sql)
print(f"[建表] mcp_token / mcp_token_log / admin_user 已就绪") print(f"[建表] mcp_token / mcp_token_log / admin_user / mcp_service 已就绪")
# 3. 灌入静态 token(幂等:token_hash 已存在则跳过) # 3. 灌入静态 token(幂等:token_hash 已存在则跳过)
inserted = 0 inserted = 0
@@ -78,16 +85,46 @@ async def main() -> None:
skipped += 1 skipped += 1
print(f"[迁移] 静态 token:新增 {inserted} 条,跳过 {skipped} 条(已存在)") print(f"[迁移] 静态 token:新增 {inserted} 条,跳过 {skipped} 条(已存在)")
# 4. 列出当前 token 概览 # 4. 注册 MCP 服务 + 生成 per-service API Key(幂等:已存在则跳过)
print(f"[服务] 注册 MCP 服务:")
for service_name, desc in INITIAL_SERVICES:
existing = await conn.fetchrow(
"SELECT service_id FROM mcp_service WHERE service_name = $1 AND status = 'active'",
service_name,
)
if existing:
print(f" {service_name}: 已存在,跳过")
continue
plain_key = secrets.token_urlsafe(32)
key_hash = hashlib.sha256(plain_key.encode()).hexdigest()
key_prefix = plain_key[:12] + "…"
await conn.execute(
"""INSERT INTO mcp_service (service_name, api_key_hash, api_key_prefix, description, created_by)
VALUES ($1, $2, $3, $4, 'seed.py')""",
service_name, key_hash, key_prefix, desc,
)
print(f" {service_name}: API Key = {plain_key}")
print(f" → 配置到 MCP 服务的 MCP_AUTH_API_KEY 环境变量")
# 5. 列出当前 token 概览
rows = await conn.fetch( rows = await conn.fetch(
"SELECT token_prefix, client_id, service_scope, status FROM mcp_token ORDER BY token_id" "SELECT token_prefix, client_id, service_scope, status FROM mcp_token ORDER BY token_id"
) )
print(f"[概览] mcp_token 表共 {len(rows)} 条:") print(f"\n[概览] mcp_token 表共 {len(rows)} 条:")
for r in rows: for r in rows:
print(f" {r['token_prefix']:<16} client={r['client_id']:<12} scope={r['service_scope']:<6} status={r['status']}") print(f" {r['token_prefix']:<16} client={r['client_id']:<12} scope={r['service_scope']:<6} status={r['status']}")
svc_rows = await conn.fetch(
"SELECT service_name, api_key_prefix, status FROM mcp_service ORDER BY service_id"
)
print(f"\n[概览] mcp_service 表共 {len(svc_rows)} 条:")
for r in svc_rows:
print(f" {r['service_name']:<8} key_prefix={r['api_key_prefix']:<16} status={r['status']}")
await conn.close() await conn.close()
print("[完成] 下一步:删除 docker-compose.yml 里的 MCP_AUTH_TOKENS(已改用 DB)") print("\n[完成] MCP 服务 API Key 仅在本次输出,请立即保存到 .env.dev")
if __name__ == "__main__": if __name__ == "__main__":
+1
View File
@@ -1,3 +1,4 @@
mcp>=1.0.0 mcp>=1.0.0
pydantic>=2.0.0 pydantic>=2.0.0
asyncpg>=0.30.0 asyncpg>=0.30.0
httpx>=0.27.0
+62 -89
View File
@@ -1,73 +1,60 @@
"""动态 Bearer Token 鉴权(Authorization)。 """动态 Bearer Token 鉴权(通过 mcp-auth 后端 API 校验,不直连鉴权库)。
token 校验查 mcp_auth.mcp_token 表(sha256 hash 比对),进程内 LRU 缓存 30s。 token 校验由 MCP 服务调用 mcp-auth 后端 `POST /api/auth/verify-token` 完成,
token 表由独立管理后台(mcp-auth-admin)维护,MCP 服务只读。 后端统一查 mcp_auth.mcp_token 表。后端通过 per-service API Key 识别调用方服务,
MCP 服务无需自报 service。进程内 LRU 缓存 30s,减少 API 调用。
环境变量: 环境变量:
MCP_PUBLIC_URL 服务对外地址(OAuth 资源元数据),默认 http://localhost:{port} MCP_PUBLIC_URL 服务对外地址(OAuth 资源元数据),默认 http://localhost:{port}
MCP_AUTH_DB_HOST 鉴权库主机 MCP_AUTH_API_URL mcp-auth 后端地址,如 http://localhost:8000
MCP_AUTH_DB_PORT 鉴权库端口,默认 5432 MCP_AUTH_API_KEY 本服务的 API Key(由 mcp-auth 后端签发,per-service 唯一)
MCP_AUTH_DB_USER 鉴权库用户
MCP_AUTH_DB_PASSWORD 鉴权库密码
MCP_AUTH_DB_NAME 鉴权库名,默认 mcp_auth
MCP_AUTH_CACHE_TTL 缓存秒数,默认 30 MCP_AUTH_CACHE_TTL 缓存秒数,默认 30
MCP_AUTH_SERVICE 当前服务标识(erp/crm),用于 service_scope 校验和 last_used_svc
""" """
import asyncio
import hashlib import hashlib
import os import os
import time import time
import asyncpg import httpx
from mcp.server.auth.middleware.auth_context import get_access_token from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import AccessToken from mcp.server.auth.provider import AccessToken
from mcp.server.auth.settings import AuthSettings from mcp.server.auth.settings import AuthSettings
# 鉴权库连接池(独立于业务库,进程级单例)
_auth_pool: asyncpg.Pool | None = None
_CACHE_TTL = int(os.getenv("MCP_AUTH_CACHE_TTL", "30")) _CACHE_TTL = int(os.getenv("MCP_AUTH_CACHE_TTL", "30"))
# httpx 异步客户端(进程级单例,复用连接池)
async def get_auth_pool() -> asyncpg.Pool: _http_client: httpx.AsyncClient | None = None
"""获取鉴权库连接池(首次调用时惰性创建)。"""
global _auth_pool
if _auth_pool is None:
_auth_pool = await asyncpg.create_pool(
host=os.getenv("MCP_AUTH_DB_HOST", "127.0.0.1"),
port=int(os.getenv("MCP_AUTH_DB_PORT", "5432")),
user=os.getenv("MCP_AUTH_DB_USER", "postgres"),
password=os.getenv("MCP_AUTH_DB_PASSWORD", "digiwin"),
database=os.getenv("MCP_AUTH_DB_NAME", "mcp_auth"),
min_size=1,
max_size=5,
)
return _auth_pool
async def close_auth_pool() -> None: def _get_http_client() -> httpx.AsyncClient:
"""关闭鉴权库连接池(进程退出时调用)。""" global _http_client
global _auth_pool if _http_client is None:
if _auth_pool is not None: _http_client = httpx.AsyncClient(timeout=5.0)
await _auth_pool.close() return _http_client
_auth_pool = None
class DbTokenVerifier: async def close_auth_client() -> None:
"""查库校验 Bearer Token,命中返回 AccessToken,未命中/已吊销/已过期/服务范围不匹配返回 None(401)。 """关闭 httpx 客户端(进程退出时调用)。"""
global _http_client
if _http_client is not None:
await _http_client.aclose()
_http_client = None
进程内 LRU 缓存(token_hash -> (row|None, fetched_at)),TTL 由 MCP_AUTH_CACHE_TTL 控制。
吊销后最长 TTL 秒内仍可能命中旧状态;需要即时生效可调用 invalidate(token)。 class ApiTokenVerifier:
"""通过 mcp-auth 后端 API 校验 Bearer Token。
进程内 LRU 缓存(token_hash -> (valid, client_id, fetched_at)),TTL 由 MCP_AUTH_CACHE_TTL 控制。
""" """
def __init__(self, service: str): def __init__(self, api_url: str, api_key: str):
self._service = service # 'erp' / 'crm' self._api_url = api_url.rstrip("/")
self._cache: dict[str, tuple[asyncpg.Record | None, float]] = {} self._api_key = api_key
self._cache: dict[str, tuple[bool, str | None, float]] = {}
def invalidate(self, token: str | None = None) -> None: def invalidate(self, token: str | None = None) -> None:
"""清缓存:token=None 清全部,否则清单个。供管理后台通知后调用(可选)。""" """清缓存:token=None 清全部,否则清单个。"""
if token is None: if token is None:
self._cache.clear() self._cache.clear()
else: else:
@@ -77,66 +64,52 @@ class DbTokenVerifier:
token_hash = hashlib.sha256(token.encode()).hexdigest() token_hash = hashlib.sha256(token.encode()).hexdigest()
# 1. 查缓存 # 1. 查缓存
cached = self._cache.get(token_hash)
now = time.time() now = time.time()
if cached is not None and (now - cached[1]) < _CACHE_TTL: cached = self._cache.get(token_hash)
row = cached[0] if cached is not None and (now - cached[2]) < _CACHE_TTL:
else: valid, client_id, _ = cached
# 2. 查库 if not valid:
pool = await get_auth_pool()
row = await pool.fetchrow(
"""SELECT client_id, status, expires_at, service_scope
FROM mcp_token WHERE token_hash = $1""",
token_hash,
)
self._cache[token_hash] = (row, now)
# 3. 校验
if row is None:
return None
if row["status"] != "active":
return None
if row["expires_at"] is not None and row["expires_at"].timestamp() < now:
return None
# service_scope='both' 对所有服务放行;否则要求精确匹配当前服务
if row["service_scope"] != "both" and row["service_scope"] != self._service:
return None return None
return AccessToken(token=token, client_id=client_id, scopes=[], expires_at=None)
# 4. 异步更新 last_used_*,不阻塞响应;失败忽略 # 2. 调用后端校验 API(后端从 X-API-Key 识别 service,无需传 service)
asyncio.create_task(self._touch(token_hash))
return AccessToken(
token=token,
client_id=row["client_id"],
scopes=[],
expires_at=None,
)
async def _touch(self, token_hash: str) -> None:
"""异步更新 last_used_at/last_used_svc。"""
try: try:
pool = await get_auth_pool() client = _get_http_client()
await pool.execute( resp = await client.post(
"""UPDATE mcp_token f"{self._api_url}/api/auth/verify-token",
SET last_used_at = now(), last_used_svc = $2 headers={"X-API-Key": self._api_key},
WHERE token_hash = $1""", json={"token": token},
token_hash, self._service,
) )
except Exception: except Exception:
pass # 审计字段更新失败不影响鉴权 # 后端不可达,缓存短时间避免雪崩
self._cache[token_hash] = (False, None, now)
return None
if resp.status_code == 200:
data = resp.json()
valid = data.get("valid", False)
client_id = data.get("client_id")
self._cache[token_hash] = (valid, client_id, now)
if valid:
return AccessToken(token=token, client_id=client_id, scopes=[], expires_at=None)
return None
else:
# 非 200(含 401 API Key 错误),缓存避免雪崩
self._cache[token_hash] = (False, None, now)
return None
def get_auth(port: int) -> tuple[AuthSettings, DbTokenVerifier]: def get_auth(port: int) -> tuple[AuthSettings, ApiTokenVerifier]:
"""构建 MCPServer 的 (auth, token_verifier) 参数。 """构建 MCPServer 的 (auth, token_verifier) 参数。
连接池在首次 verify_token 时惰性创建,此处不连库。 service 由后端从 API Key 自动识别,MCP 服务无需配置 MCP_AUTH_SERVICE。
service 标识取自 MCP_AUTH_SERVICE 环境变量,默认按 port 推断(8001→erp / 8002→crm)。
""" """
url = os.getenv("MCP_PUBLIC_URL") or f"http://localhost:{port}" url = os.getenv("MCP_PUBLIC_URL") or f"http://localhost:{port}"
service = os.getenv("MCP_AUTH_SERVICE") or ("erp" if port == 8001 else "crm" if port == 8002 else "unknown") api_url = os.getenv("MCP_AUTH_API_URL", "http://localhost:8000")
api_key = os.getenv("MCP_AUTH_API_KEY", "")
return ( return (
AuthSettings(issuer_url=url, resource_server_url=url), AuthSettings(issuer_url=url, resource_server_url=url),
DbTokenVerifier(service=service), ApiTokenVerifier(api_url=api_url, api_key=api_key),
) )
+1
View File
@@ -1,3 +1,4 @@
mcp>=1.0.0 mcp>=1.0.0
pydantic>=2.0.0 pydantic>=2.0.0
asyncpg>=0.30.0 asyncpg>=0.30.0
httpx>=0.27.0
+62 -89
View File
@@ -1,73 +1,60 @@
"""动态 Bearer Token 鉴权(Authorization)。 """动态 Bearer Token 鉴权(通过 mcp-auth 后端 API 校验,不直连鉴权库)。
token 校验查 mcp_auth.mcp_token 表(sha256 hash 比对),进程内 LRU 缓存 30s。 token 校验由 MCP 服务调用 mcp-auth 后端 `POST /api/auth/verify-token` 完成,
token 表由独立管理后台(mcp-auth-admin)维护,MCP 服务只读。 后端统一查 mcp_auth.mcp_token 表。后端通过 per-service API Key 识别调用方服务,
MCP 服务无需自报 service。进程内 LRU 缓存 30s,减少 API 调用。
环境变量: 环境变量:
MCP_PUBLIC_URL 服务对外地址(OAuth 资源元数据),默认 http://localhost:{port} MCP_PUBLIC_URL 服务对外地址(OAuth 资源元数据),默认 http://localhost:{port}
MCP_AUTH_DB_HOST 鉴权库主机 MCP_AUTH_API_URL mcp-auth 后端地址,如 http://localhost:8000
MCP_AUTH_DB_PORT 鉴权库端口,默认 5432 MCP_AUTH_API_KEY 本服务的 API Key(由 mcp-auth 后端签发,per-service 唯一)
MCP_AUTH_DB_USER 鉴权库用户
MCP_AUTH_DB_PASSWORD 鉴权库密码
MCP_AUTH_DB_NAME 鉴权库名,默认 mcp_auth
MCP_AUTH_CACHE_TTL 缓存秒数,默认 30 MCP_AUTH_CACHE_TTL 缓存秒数,默认 30
MCP_AUTH_SERVICE 当前服务标识(erp/crm),用于 service_scope 校验和 last_used_svc
""" """
import asyncio
import hashlib import hashlib
import os import os
import time import time
import asyncpg import httpx
from mcp.server.auth.middleware.auth_context import get_access_token from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import AccessToken from mcp.server.auth.provider import AccessToken
from mcp.server.auth.settings import AuthSettings from mcp.server.auth.settings import AuthSettings
# 鉴权库连接池(独立于业务库,进程级单例)
_auth_pool: asyncpg.Pool | None = None
_CACHE_TTL = int(os.getenv("MCP_AUTH_CACHE_TTL", "30")) _CACHE_TTL = int(os.getenv("MCP_AUTH_CACHE_TTL", "30"))
# httpx 异步客户端(进程级单例,复用连接池)
async def get_auth_pool() -> asyncpg.Pool: _http_client: httpx.AsyncClient | None = None
"""获取鉴权库连接池(首次调用时惰性创建)。"""
global _auth_pool
if _auth_pool is None:
_auth_pool = await asyncpg.create_pool(
host=os.getenv("MCP_AUTH_DB_HOST", "127.0.0.1"),
port=int(os.getenv("MCP_AUTH_DB_PORT", "5432")),
user=os.getenv("MCP_AUTH_DB_USER", "postgres"),
password=os.getenv("MCP_AUTH_DB_PASSWORD", "digiwin"),
database=os.getenv("MCP_AUTH_DB_NAME", "mcp_auth"),
min_size=1,
max_size=5,
)
return _auth_pool
async def close_auth_pool() -> None: def _get_http_client() -> httpx.AsyncClient:
"""关闭鉴权库连接池(进程退出时调用)。""" global _http_client
global _auth_pool if _http_client is None:
if _auth_pool is not None: _http_client = httpx.AsyncClient(timeout=5.0)
await _auth_pool.close() return _http_client
_auth_pool = None
class DbTokenVerifier: async def close_auth_client() -> None:
"""查库校验 Bearer Token,命中返回 AccessToken,未命中/已吊销/已过期/服务范围不匹配返回 None(401)。 """关闭 httpx 客户端(进程退出时调用)。"""
global _http_client
if _http_client is not None:
await _http_client.aclose()
_http_client = None
进程内 LRU 缓存(token_hash -> (row|None, fetched_at)),TTL 由 MCP_AUTH_CACHE_TTL 控制。
吊销后最长 TTL 秒内仍可能命中旧状态;需要即时生效可调用 invalidate(token)。 class ApiTokenVerifier:
"""通过 mcp-auth 后端 API 校验 Bearer Token。
进程内 LRU 缓存(token_hash -> (valid, client_id, fetched_at)),TTL 由 MCP_AUTH_CACHE_TTL 控制。
""" """
def __init__(self, service: str): def __init__(self, api_url: str, api_key: str):
self._service = service # 'erp' / 'crm' self._api_url = api_url.rstrip("/")
self._cache: dict[str, tuple[asyncpg.Record | None, float]] = {} self._api_key = api_key
self._cache: dict[str, tuple[bool, str | None, float]] = {}
def invalidate(self, token: str | None = None) -> None: def invalidate(self, token: str | None = None) -> None:
"""清缓存:token=None 清全部,否则清单个。供管理后台通知后调用(可选)。""" """清缓存:token=None 清全部,否则清单个。"""
if token is None: if token is None:
self._cache.clear() self._cache.clear()
else: else:
@@ -77,66 +64,52 @@ class DbTokenVerifier:
token_hash = hashlib.sha256(token.encode()).hexdigest() token_hash = hashlib.sha256(token.encode()).hexdigest()
# 1. 查缓存 # 1. 查缓存
cached = self._cache.get(token_hash)
now = time.time() now = time.time()
if cached is not None and (now - cached[1]) < _CACHE_TTL: cached = self._cache.get(token_hash)
row = cached[0] if cached is not None and (now - cached[2]) < _CACHE_TTL:
else: valid, client_id, _ = cached
# 2. 查库 if not valid:
pool = await get_auth_pool()
row = await pool.fetchrow(
"""SELECT client_id, status, expires_at, service_scope
FROM mcp_token WHERE token_hash = $1""",
token_hash,
)
self._cache[token_hash] = (row, now)
# 3. 校验
if row is None:
return None
if row["status"] != "active":
return None
if row["expires_at"] is not None and row["expires_at"].timestamp() < now:
return None
# service_scope='both' 对所有服务放行;否则要求精确匹配当前服务
if row["service_scope"] != "both" and row["service_scope"] != self._service:
return None return None
return AccessToken(token=token, client_id=client_id, scopes=[], expires_at=None)
# 4. 异步更新 last_used_*,不阻塞响应;失败忽略 # 2. 调用后端校验 API(后端从 X-API-Key 识别 service,无需传 service)
asyncio.create_task(self._touch(token_hash))
return AccessToken(
token=token,
client_id=row["client_id"],
scopes=[],
expires_at=None,
)
async def _touch(self, token_hash: str) -> None:
"""异步更新 last_used_at/last_used_svc。"""
try: try:
pool = await get_auth_pool() client = _get_http_client()
await pool.execute( resp = await client.post(
"""UPDATE mcp_token f"{self._api_url}/api/auth/verify-token",
SET last_used_at = now(), last_used_svc = $2 headers={"X-API-Key": self._api_key},
WHERE token_hash = $1""", json={"token": token},
token_hash, self._service,
) )
except Exception: except Exception:
pass # 审计字段更新失败不影响鉴权 # 后端不可达,缓存短时间避免雪崩
self._cache[token_hash] = (False, None, now)
return None
if resp.status_code == 200:
data = resp.json()
valid = data.get("valid", False)
client_id = data.get("client_id")
self._cache[token_hash] = (valid, client_id, now)
if valid:
return AccessToken(token=token, client_id=client_id, scopes=[], expires_at=None)
return None
else:
# 非 200(含 401 API Key 错误),缓存避免雪崩
self._cache[token_hash] = (False, None, now)
return None
def get_auth(port: int) -> tuple[AuthSettings, DbTokenVerifier]: def get_auth(port: int) -> tuple[AuthSettings, ApiTokenVerifier]:
"""构建 MCPServer 的 (auth, token_verifier) 参数。 """构建 MCPServer 的 (auth, token_verifier) 参数。
连接池在首次 verify_token 时惰性创建,此处不连库。 service 由后端从 API Key 自动识别,MCP 服务无需配置 MCP_AUTH_SERVICE。
service 标识取自 MCP_AUTH_SERVICE 环境变量,默认按 port 推断(8001→erp / 8002→crm)。
""" """
url = os.getenv("MCP_PUBLIC_URL") or f"http://localhost:{port}" url = os.getenv("MCP_PUBLIC_URL") or f"http://localhost:{port}"
service = os.getenv("MCP_AUTH_SERVICE") or ("erp" if port == 8001 else "crm" if port == 8002 else "unknown") api_url = os.getenv("MCP_AUTH_API_URL", "http://localhost:8000")
api_key = os.getenv("MCP_AUTH_API_KEY", "")
return ( return (
AuthSettings(issuer_url=url, resource_server_url=url), AuthSettings(issuer_url=url, resource_server_url=url),
DbTokenVerifier(service=service), ApiTokenVerifier(api_url=api_url, api_key=api_key),
) )