diff --git a/mcp-auth/backend/app/routers/services.py b/mcp-auth/backend/app/routers/services.py index b9c3fb5..24a9be4 100644 --- a/mcp-auth/backend/app/routers/services.py +++ b/mcp-auth/backend/app/routers/services.py @@ -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, diff --git a/mcp-auth/backend/app/routers/tokens.py b/mcp-auth/backend/app/routers/tokens.py index 497b21c..51b860b 100644 --- a/mcp-auth/backend/app/routers/tokens.py +++ b/mcp-auth/backend/app/routers/tokens.py @@ -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, diff --git a/mcp-auth/frontend/src/api/index.ts b/mcp-auth/frontend/src/api/index.ts index 62d865e..5b38b9d 100644 --- a/mcp-auth/frontend/src/api/index.ts +++ b/mcp-auth/frontend/src/api/index.ts @@ -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; diff --git a/mcp-auth/frontend/src/pages/Services/index.tsx b/mcp-auth/frontend/src/pages/Services/index.tsx index b94f6ef..e05f314 100644 --- a/mcp-auth/frontend/src/pages/Services/index.tsx +++ b/mcp-auth/frontend/src/pages/Services/index.tsx @@ -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 = [ { 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) => ( + + + {key} + + - ) : row.status === 'revoked' ? ( - + + + + ) : ( - ), @@ -181,25 +219,31 @@ export default function Services() { - {/* 注册结果:明文 API Key 仅此一次 */} + {/* 注册结果:API Key */} setCreated(null)} - footer={} + footer={} > - - 请立即复制并保存,此 API Key 仅显示一次,之后无法再次查看。 - - - - 配置到 MCP 服务的 MCP_AUTH_API_KEY 环境变量。 + + 配置到 MCP 服务的 MCP_AUTH_API_KEY 环境变量: + + + + ); diff --git a/mcp-auth/frontend/src/pages/Tokens/index.tsx b/mcp-auth/frontend/src/pages/Tokens/index.tsx index 14d7bff..9c924fc 100644 --- a/mcp-auth/frontend/src/pages/Tokens/index.tsx +++ b/mcp-auth/frontend/src/pages/Tokens/index.tsx @@ -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 = [ { 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' ? ( - ) : row.status === 'revoked' ? ( - + + + + ) : ( - ), diff --git a/mcp-auth/sql/init.sql b/mcp-auth/sql/init.sql index 1bf214c..174572b 100644 --- a/mcp-auth/sql/init.sql +++ b/mcp-auth/sql/init.sql @@ -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(), diff --git a/mcp-auth/src/seed.py b/mcp-auth/src/seed.py index e09b42f..68283d4 100644 --- a/mcp-auth/src/seed.py +++ b/mcp-auth/src/seed.py @@ -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")