优化注册服务及token管理
This commit is contained in:
@@ -21,7 +21,7 @@ def _row_to_dict(row) -> dict:
|
||||
return {
|
||||
"service_id": row["service_id"],
|
||||
"service_name": row["service_name"],
|
||||
"api_key_prefix": row["api_key_prefix"],
|
||||
"api_key": row["api_key"],
|
||||
"description": row["description"],
|
||||
"status": row["status"],
|
||||
"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:
|
||||
raise HTTPException(400, f"服务 {req.service_name} 已存在且处于 active 状态")
|
||||
|
||||
# 生成 API Key(明文仅此一次返回)
|
||||
# 生成 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)
|
||||
"""INSERT INTO mcp_service (service_name, api_key, api_key_hash, 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"),
|
||||
RETURNING service_id, service_name, api_key, description, created_at, created_by""",
|
||||
req.service_name, plain, api_key_hash, req.description, admin.get("username", "admin"),
|
||||
)
|
||||
|
||||
return {
|
||||
"api_key": plain, # 明文仅此一次
|
||||
"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 环境变量",
|
||||
"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"}
|
||||
|
||||
|
||||
@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}")
|
||||
async def delete_service(
|
||||
service_id: int,
|
||||
|
||||
@@ -85,6 +85,14 @@ async def create_token(req: TokenCreate, admin: dict = Depends(current_admin)):
|
||||
if not exists:
|
||||
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:仅此一次返回
|
||||
plain = secrets.token_urlsafe(32)
|
||||
token_hash = hashlib.sha256(plain.encode()).hexdigest()
|
||||
@@ -173,6 +181,32 @@ async def revoke_token(
|
||||
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}")
|
||||
async def delete_token(
|
||||
token_id: int,
|
||||
|
||||
@@ -56,6 +56,11 @@ export async function revokeToken(token_id: number, reason?: string) {
|
||||
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) {
|
||||
const { data } = await api.delete(`/tokens/${token_id}`);
|
||||
return data;
|
||||
@@ -107,7 +112,7 @@ export async function getStats() {
|
||||
export interface ServiceRow {
|
||||
service_id: number;
|
||||
service_name: string;
|
||||
api_key_prefix: string;
|
||||
api_key: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
created_at: string | null;
|
||||
@@ -125,7 +130,6 @@ export interface ServiceCreateResult {
|
||||
api_key: string;
|
||||
service_id: number;
|
||||
service_name: string;
|
||||
api_key_prefix: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
@@ -144,6 +148,11 @@ export async function revokeService(service_id: number) {
|
||||
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) {
|
||||
const { data } = await api.delete(`/services/${service_id}`);
|
||||
return data;
|
||||
|
||||
@@ -10,13 +10,14 @@ import {
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { PlusOutlined, ReloadOutlined, CopyOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
listServices,
|
||||
registerService,
|
||||
revokeService,
|
||||
enableService,
|
||||
deleteService,
|
||||
type ServiceCreateResult,
|
||||
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) => {
|
||||
Modal.confirm({
|
||||
title: '删除此服务?',
|
||||
@@ -79,20 +86,46 @@ export default function Services() {
|
||||
};
|
||||
|
||||
const onCreate = async () => {
|
||||
const values = await form.validateFields();
|
||||
const res = await registerService({
|
||||
service_name: values.service_name,
|
||||
description: values.description,
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const res = await registerService({
|
||||
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> = [
|
||||
{ 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: '状态',
|
||||
@@ -118,21 +151,26 @@ export default function Services() {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 140,
|
||||
width: 180,
|
||||
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 type="link" size="small" disabled>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
) : row.status === 'revoked' ? (
|
||||
<Button type="link" danger size="small" onClick={() => onDelete(row.service_id)}>
|
||||
删除
|
||||
</Button>
|
||||
<Space size="small">
|
||||
<Button type="link" size="small" onClick={() => onEnable(row.service_id)}>
|
||||
启用
|
||||
</Button>
|
||||
<Button type="link" danger size="small" onClick={() => onDelete(row.service_id)}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<Typography.Text type="secondary">-</Typography.Text>
|
||||
),
|
||||
@@ -181,25 +219,31 @@ export default function Services() {
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 注册结果:明文 API Key 仅此一次 */}
|
||||
{/* 注册结果:API Key */}
|
||||
<Modal
|
||||
title="API Key 已生成"
|
||||
open={created !== null}
|
||||
onCancel={() => setCreated(null)}
|
||||
footer={<Button type="primary" onClick={() => setCreated(null)}>我已保存</Button>}
|
||||
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 type="secondary">
|
||||
配置到 MCP 服务的 <code>MCP_AUTH_API_KEY</code> 环境变量:
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
listTokens,
|
||||
listServices,
|
||||
revokeToken,
|
||||
enableToken,
|
||||
deleteToken,
|
||||
type CreateResult,
|
||||
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) => {
|
||||
Modal.confirm({
|
||||
title: '删除此 Token?',
|
||||
@@ -94,22 +101,29 @@ export default function Tokens() {
|
||||
};
|
||||
|
||||
const onCreate = async () => {
|
||||
const values = await form.validateFields();
|
||||
const res = await createToken({
|
||||
client_id: values.client_id,
|
||||
service_scope: values.service_scope,
|
||||
description: values.description,
|
||||
expires_at: values.expires_at ? values.expires_at.toISOString() : null,
|
||||
});
|
||||
setCreated(res);
|
||||
setCreateOpen(false);
|
||||
form.resetFields();
|
||||
load();
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const res = await createToken({
|
||||
client_id: values.client_id,
|
||||
service_scope: values.service_scope,
|
||||
description: values.description,
|
||||
expires_at: values.expires_at ? values.expires_at.toISOString() : null,
|
||||
});
|
||||
setCreated(res);
|
||||
setCreateOpen(false);
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch (err: any) {
|
||||
const detail = err?.response?.data?.detail;
|
||||
if (detail) {
|
||||
message.error(detail);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<TokenRow> = [
|
||||
{ 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: '范围',
|
||||
dataIndex: 'service_scope',
|
||||
@@ -143,21 +157,26 @@ export default function Tokens() {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 140,
|
||||
width: 180,
|
||||
render: (_, row: TokenRow) =>
|
||||
row.status === 'active' ? (
|
||||
<Space size="small">
|
||||
<Button type="link" danger size="small" onClick={() => onRevoke(row.token_id)}>
|
||||
吊销
|
||||
</Button>
|
||||
<Button type="link" size="small" disabled onClick={() => onDelete(row.token_id)}>
|
||||
<Button type="link" size="small" disabled>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
) : row.status === 'revoked' ? (
|
||||
<Button type="link" danger size="small" onClick={() => onDelete(row.token_id)}>
|
||||
删除
|
||||
</Button>
|
||||
<Space size="small">
|
||||
<Button type="link" size="small" onClick={() => onEnable(row.token_id)}>
|
||||
启用
|
||||
</Button>
|
||||
<Button type="link" danger size="small" onClick={() => onDelete(row.token_id)}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<Typography.Text type="secondary">-</Typography.Text>
|
||||
),
|
||||
|
||||
@@ -55,8 +55,8 @@ CREATE TABLE IF NOT EXISTS admin_user (
|
||||
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 字符 + '…',前端识别用
|
||||
api_key VARCHAR(128) NOT NULL, -- 明文 API Key(管理后台展示用,MCP 服务用此值)
|
||||
api_key_hash VARCHAR(64) UNIQUE NOT NULL, -- sha256(明文 API Key),verify-token 校验用
|
||||
description VARCHAR(200),
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'active', -- active/revoked
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
@@ -97,11 +97,10 @@ async def main() -> None:
|
||||
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)
|
||||
"""INSERT INTO mcp_service (service_name, api_key, api_key_hash, description, created_by)
|
||||
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" → 配置到 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']}")
|
||||
|
||||
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)} 条:")
|
||||
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()
|
||||
print("\n[完成] MCP 服务 API Key 仅在本次输出,请立即保存到 .env.dev")
|
||||
|
||||
Reference in New Issue
Block a user