优化注册服务及token管理

This commit is contained in:
2026-08-31 13:44:05 +08:00
parent 6fe61ff094
commit 3f6dabb634
7 changed files with 188 additions and 63 deletions
+29 -9
View File
@@ -21,7 +21,7 @@ def _row_to_dict(row) -> dict:
return { return {
"service_id": row["service_id"], "service_id": row["service_id"],
"service_name": row["service_name"], "service_name": row["service_name"],
"api_key_prefix": row["api_key_prefix"], "api_key": row["api_key"],
"description": row["description"], "description": row["description"],
"status": row["status"], "status": row["status"],
"created_at": row["created_at"].isoformat() if row["created_at"] else None, "created_at": row["created_at"].isoformat() if row["created_at"] else None,
@@ -59,24 +59,22 @@ async def register_service(req: ServiceCreate, admin: dict = Depends(current_adm
if existing: if existing:
raise HTTPException(400, f"服务 {req.service_name} 已存在且处于 active 状态") raise HTTPException(400, f"服务 {req.service_name} 已存在且处于 active 状态")
# 生成 API Key(明文仅此一次返回) # 生成 API Key
plain = secrets.token_urlsafe(32) plain = secrets.token_urlsafe(32)
api_key_hash = hashlib.sha256(plain.encode()).hexdigest() api_key_hash = hashlib.sha256(plain.encode()).hexdigest()
api_key_prefix = plain[:12] + "…"
row = await pool.fetchrow( row = await pool.fetchrow(
"""INSERT INTO mcp_service (service_name, api_key_hash, api_key_prefix, description, created_by) """INSERT INTO mcp_service (service_name, api_key, api_key_hash, description, created_by)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5)
RETURNING service_id, service_name, api_key_prefix, description, created_at, created_by""", RETURNING service_id, service_name, api_key, description, created_at, created_by""",
req.service_name, api_key_hash, api_key_prefix, req.description, admin.get("username", "admin"), req.service_name, plain, api_key_hash, req.description, admin.get("username", "admin"),
) )
return { return {
"api_key": plain, # 明文仅此一次 "api_key": plain,
"service_id": row["service_id"], "service_id": row["service_id"],
"service_name": row["service_name"], "service_name": row["service_name"],
"api_key_prefix": row["api_key_prefix"], "message": "请保存此 API Key,配置到 MCP 服务的 MCP_AUTH_API_KEY 环境变量",
"message": "请立即保存此 API Key,之后无法再次查看。配置到 MCP 服务的 MCP_AUTH_API_KEY 环境变量",
} }
@@ -101,6 +99,28 @@ async def revoke_service(
return {"success": True, "service_id": service_id, "status": "revoked"} 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 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,
)
return {"success": True, "service_id": service_id, "status": "active"}
@router.delete("/{service_id}") @router.delete("/{service_id}")
async def delete_service( async def delete_service(
service_id: int, service_id: int,
+34
View File
@@ -85,6 +85,14 @@ async def create_token(req: TokenCreate, admin: dict = Depends(current_admin)):
if not exists: if not exists:
raise HTTPException(400, f"service_scope '{req.service_scope}' 不是已注册的活跃服务") raise HTTPException(400, f"service_scope '{req.service_scope}' 不是已注册的活跃服务")
# client_id 唯一校验:同一 client_id 不允许重复签发
existing = await pool.fetchval(
"SELECT 1 FROM mcp_token WHERE client_id = $1 AND status = 'active'",
req.client_id,
)
if existing:
raise HTTPException(400, f"client_id '{req.client_id}' 已存在活跃 Token,请先吊销旧 Token")
# 生成明文 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()
@@ -173,6 +181,32 @@ async def revoke_token(
return {"success": True, "token_id": token_id, "status": "revoked"} 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 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")}),
)
return {"success": True, "token_id": token_id, "status": "active"}
@router.delete("/{token_id}") @router.delete("/{token_id}")
async def delete_token( async def delete_token(
token_id: int, token_id: int,
+11 -2
View File
@@ -56,6 +56,11 @@ export async function revokeToken(token_id: number, reason?: string) {
return data; return data;
} }
export async function enableToken(token_id: number) {
const { data } = await api.put(`/tokens/${token_id}/enable`);
return data;
}
export async function deleteToken(token_id: number) { export async function deleteToken(token_id: number) {
const { data } = await api.delete(`/tokens/${token_id}`); const { data } = await api.delete(`/tokens/${token_id}`);
return data; return data;
@@ -107,7 +112,7 @@ export async function getStats() {
export interface ServiceRow { export interface ServiceRow {
service_id: number; service_id: number;
service_name: string; service_name: string;
api_key_prefix: string; api_key: string;
description: string | null; description: string | null;
status: string; status: string;
created_at: string | null; created_at: string | null;
@@ -125,7 +130,6 @@ export interface ServiceCreateResult {
api_key: string; api_key: string;
service_id: number; service_id: number;
service_name: string; service_name: string;
api_key_prefix: string;
message: string; message: string;
} }
@@ -144,6 +148,11 @@ export async function revokeService(service_id: number) {
return data; return data;
} }
export async function enableService(service_id: number) {
const { data } = await api.patch(`/services/${service_id}/enable`);
return data;
}
export async function deleteService(service_id: number) { export async function deleteService(service_id: number) {
const { data } = await api.delete(`/services/${service_id}`); const { data } = await api.delete(`/services/${service_id}`);
return data; return data;
+72 -28
View File
@@ -10,13 +10,14 @@ import {
Typography, Typography,
message, message,
} from 'antd'; } from 'antd';
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons'; import { PlusOutlined, ReloadOutlined, CopyOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { import {
listServices, listServices,
registerService, registerService,
revokeService, revokeService,
enableService,
deleteService, deleteService,
type ServiceCreateResult, type ServiceCreateResult,
type ServiceRow, type ServiceRow,
@@ -63,6 +64,12 @@ export default function Services() {
}); });
}; };
const onEnable = async (id: number) => {
await enableService(id);
message.success('已启用');
load();
};
const onDelete = async (id: number) => { const onDelete = async (id: number) => {
Modal.confirm({ Modal.confirm({
title: '删除此服务?', title: '删除此服务?',
@@ -79,20 +86,46 @@ export default function Services() {
}; };
const onCreate = async () => { const onCreate = async () => {
const values = await form.validateFields(); try {
const res = await registerService({ const values = await form.validateFields();
service_name: values.service_name, const res = await registerService({
description: values.description, service_name: values.service_name,
description: values.description,
});
setCreated(res);
setCreateOpen(false);
form.resetFields();
load();
} catch (err: any) {
const detail = err?.response?.data?.detail;
if (detail) {
message.error(detail);
}
}
};
const onCopy = (text: string) => {
navigator.clipboard.writeText(text).then(() => {
message.success('已复制');
}); });
setCreated(res);
setCreateOpen(false);
form.resetFields();
load();
}; };
const columns: ColumnsType<ServiceRow> = [ const columns: ColumnsType<ServiceRow> = [
{ title: '服务名', dataIndex: 'service_name', key: 'service_name', width: 120 }, { title: '服务名', dataIndex: 'service_name', key: 'service_name', width: 120 },
{ title: 'Key 前缀', dataIndex: 'api_key_prefix', key: 'api_key_prefix', width: 140 }, {
title: 'API Key',
dataIndex: 'api_key',
key: 'api_key',
width: 300,
render: (key: string) => (
<Space size="small">
<Typography.Text style={{ fontFamily: 'monospace', fontSize: 13 }} ellipsis>
{key}
</Typography.Text>
<Button type="text" size="small" icon={<CopyOutlined />} onClick={() => onCopy(key)} />
</Space>
),
},
{ title: '说明', dataIndex: 'description', key: 'description' }, { title: '说明', dataIndex: 'description', key: 'description' },
{ {
title: '状态', title: '状态',
@@ -118,21 +151,26 @@ export default function Services() {
{ {
title: '操作', title: '操作',
key: 'action', key: 'action',
width: 140, width: 180,
render: (_, row: ServiceRow) => render: (_, row: ServiceRow) =>
row.status === 'active' ? ( row.status === 'active' ? (
<Space size="small"> <Space size="small">
<Button type="link" danger size="small" onClick={() => onRevoke(row.service_id)}> <Button type="link" danger size="small" onClick={() => onRevoke(row.service_id)}>
吊销 吊销
</Button> </Button>
<Button type="link" size="small" disabled onClick={() => onDelete(row.service_id)}> <Button type="link" size="small" disabled>
删除 删除
</Button> </Button>
</Space> </Space>
) : row.status === 'revoked' ? ( ) : row.status === 'revoked' ? (
<Button type="link" danger size="small" onClick={() => onDelete(row.service_id)}> <Space size="small">
删除 <Button type="link" size="small" onClick={() => onEnable(row.service_id)}>
</Button> 启用
</Button>
<Button type="link" danger size="small" onClick={() => onDelete(row.service_id)}>
删除
</Button>
</Space>
) : ( ) : (
<Typography.Text type="secondary">-</Typography.Text> <Typography.Text type="secondary">-</Typography.Text>
), ),
@@ -181,25 +219,31 @@ export default function Services() {
</Form> </Form>
</Modal> </Modal>
{/* 注册结果:明文 API Key 仅此一次 */} {/* 注册结果:API Key */}
<Modal <Modal
title="API Key 已生成" title="API Key 已生成"
open={created !== null} open={created !== null}
onCancel={() => setCreated(null)} onCancel={() => setCreated(null)}
footer={<Button type="primary" onClick={() => setCreated(null)}>我已保存</Button>} footer={<Button type="primary" onClick={() => setCreated(null)}>关闭</Button>}
> >
<Typography.Paragraph type="warning"> <Typography.Paragraph type="secondary">
请立即复制并保存,此 API Key 仅显示一次,之后无法再次查看。 配置到 MCP 服务的 <code>MCP_AUTH_API_KEY</code> 环境变量:
</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> </Typography.Paragraph>
<Space.Compact style={{ width: '100%' }}>
<Input.TextArea
rows={2}
value={created?.api_key || ''}
readOnly
style={{ fontFamily: 'monospace' }}
/>
<Button
type="primary"
icon={<CopyOutlined />}
onClick={() => created?.api_key && onCopy(created.api_key)}
>
复制
</Button>
</Space.Compact>
</Modal> </Modal>
</div> </div>
); );
+36 -17
View File
@@ -19,6 +19,7 @@ import {
listTokens, listTokens,
listServices, listServices,
revokeToken, revokeToken,
enableToken,
deleteToken, deleteToken,
type CreateResult, type CreateResult,
type TokenRow, type TokenRow,
@@ -78,6 +79,12 @@ export default function Tokens() {
}); });
}; };
const onEnable = async (id: number) => {
await enableToken(id);
message.success('已启用');
load();
};
const onDelete = async (id: number) => { const onDelete = async (id: number) => {
Modal.confirm({ Modal.confirm({
title: '删除此 Token?', title: '删除此 Token?',
@@ -94,22 +101,29 @@ export default function Tokens() {
}; };
const onCreate = async () => { const onCreate = async () => {
const values = await form.validateFields(); try {
const res = await createToken({ const values = await form.validateFields();
client_id: values.client_id, const res = await createToken({
service_scope: values.service_scope, client_id: values.client_id,
description: values.description, service_scope: values.service_scope,
expires_at: values.expires_at ? values.expires_at.toISOString() : null, description: values.description,
}); expires_at: values.expires_at ? values.expires_at.toISOString() : null,
setCreated(res); });
setCreateOpen(false); setCreated(res);
form.resetFields(); setCreateOpen(false);
load(); form.resetFields();
load();
} catch (err: any) {
const detail = err?.response?.data?.detail;
if (detail) {
message.error(detail);
}
}
}; };
const columns: ColumnsType<TokenRow> = [ const columns: ColumnsType<TokenRow> = [
{ title: '前缀', dataIndex: 'token_prefix', key: 'token_prefix', width: 140 }, { title: '前缀', dataIndex: 'token_prefix', key: 'token_prefix', width: 140 },
{ title: '客户端', dataIndex: 'client_id', key: 'client_id', width: 120 }, { title: '客户端', dataIndex: 'client_id', key: 'client_id', width: 240 },
{ {
title: '范围', title: '范围',
dataIndex: 'service_scope', dataIndex: 'service_scope',
@@ -143,21 +157,26 @@ export default function Tokens() {
{ {
title: '操作', title: '操作',
key: 'action', key: 'action',
width: 140, width: 180,
render: (_, row: TokenRow) => render: (_, row: TokenRow) =>
row.status === 'active' ? ( row.status === 'active' ? (
<Space size="small"> <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 type="link" size="small" disabled>
删除 删除
</Button> </Button>
</Space> </Space>
) : row.status === 'revoked' ? ( ) : row.status === 'revoked' ? (
<Button type="link" danger size="small" onClick={() => onDelete(row.token_id)}> <Space size="small">
删除 <Button type="link" size="small" onClick={() => onEnable(row.token_id)}>
</Button> 启用
</Button>
<Button type="link" danger size="small" onClick={() => onDelete(row.token_id)}>
删除
</Button>
</Space>
) : ( ) : (
<Typography.Text type="secondary">-</Typography.Text> <Typography.Text type="secondary">-</Typography.Text>
), ),
+2 -2
View File
@@ -55,8 +55,8 @@ CREATE TABLE IF NOT EXISTS admin_user (
CREATE TABLE IF NOT EXISTS mcp_service ( CREATE TABLE IF NOT EXISTS mcp_service (
service_id BIGSERIAL PRIMARY KEY, service_id BIGSERIAL PRIMARY KEY,
service_name VARCHAR(64) UNIQUE NOT NULL, -- erp / crm / ... service_name VARCHAR(64) UNIQUE NOT NULL, -- erp / crm / ...
api_key_hash VARCHAR(64) UNIQUE NOT NULL, -- sha256(明文 API Key) api_key VARCHAR(128) NOT NULL, -- 明文 API Key(管理后台展示用,MCP 服务用此值)
api_key_prefix VARCHAR(16) NOT NULL, -- 明文前 12 字符 + '…',前端识别用 api_key_hash VARCHAR(64) UNIQUE NOT NULL, -- sha256(明文 API Key),verify-token 校验用
description VARCHAR(200), description VARCHAR(200),
status VARCHAR(16) NOT NULL DEFAULT 'active', -- active/revoked status VARCHAR(16) NOT NULL DEFAULT 'active', -- active/revoked
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+4 -5
View File
@@ -97,11 +97,10 @@ async def main() -> None:
continue continue
plain_key = secrets.token_urlsafe(32) plain_key = secrets.token_urlsafe(32)
key_hash = hashlib.sha256(plain_key.encode()).hexdigest() key_hash = hashlib.sha256(plain_key.encode()).hexdigest()
key_prefix = plain_key[:12] + "…"
await conn.execute( await conn.execute(
"""INSERT INTO mcp_service (service_name, api_key_hash, api_key_prefix, description, created_by) """INSERT INTO mcp_service (service_name, api_key, api_key_hash, description, created_by)
VALUES ($1, $2, $3, $4, 'seed.py')""", VALUES ($1, $2, $3, $4, 'seed.py')""",
service_name, key_hash, key_prefix, desc, service_name, plain_key, key_hash, desc,
) )
print(f" {service_name}: API Key = {plain_key}") print(f" {service_name}: API Key = {plain_key}")
print(f" → 配置到 MCP 服务的 MCP_AUTH_API_KEY 环境变量") print(f" → 配置到 MCP 服务的 MCP_AUTH_API_KEY 环境变量")
@@ -115,11 +114,11 @@ async def main() -> None:
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( svc_rows = await conn.fetch(
"SELECT service_name, api_key_prefix, status FROM mcp_service ORDER BY service_id" "SELECT service_name, api_key, status FROM mcp_service ORDER BY service_id"
) )
print(f"\n[概览] mcp_service 表共 {len(svc_rows)} 条:") print(f"\n[概览] mcp_service 表共 {len(svc_rows)} 条:")
for r in svc_rows: for r in svc_rows:
print(f" {r['service_name']:<8} key_prefix={r['api_key_prefix']:<16} status={r['status']}") print(f" {r['service_name']:<8} api_key={r['api_key']:<44} status={r['status']}")
await conn.close() await conn.close()
print("\n[完成] MCP 服务 API Key 仅在本次输出,请立即保存到 .env.dev") print("\n[完成] MCP 服务 API Key 仅在本次输出,请立即保存到 .env.dev")