diff --git a/mcp-auth/backend/app/routers/tokens.py b/mcp-auth/backend/app/routers/tokens.py index d84ff45..7155b13 100644 --- a/mcp-auth/backend/app/routers/tokens.py +++ b/mcp-auth/backend/app/routers/tokens.py @@ -35,6 +35,7 @@ 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"], "status": row["status"], @@ -85,13 +86,13 @@ 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 不允许重复签发 + # client_id + service_scope 唯一校验:同一客户端同一服务不允许重复签发 existing = await pool.fetchval( - "SELECT 1 FROM mcp_token WHERE client_id = $1 AND status = 'active'", - req.client_id, + "SELECT 1 FROM mcp_token WHERE client_id = $1 AND service_scope = $2 AND status = 'active'", + req.client_id, req.service_scope, ) if existing: - raise HTTPException(400, f"client_id '{req.client_id}' 已存在活跃 Token,请先吊销旧 Token") + raise HTTPException(400, f"client_id '{req.client_id}' 已存在服务于 '{req.service_scope}' 的活跃 Token,请先吊销旧 Token") # 生成明文 token:仅此一次返回 plain = secrets.token_urlsafe(32) @@ -99,12 +100,12 @@ async def create_token(req: TokenCreate, admin: dict = Depends(current_admin)): token_prefix = plain[:12] + "…" row = await pool.fetchrow( - """INSERT INTO mcp_token (token_hash, token_prefix, client_id, service_scope, status, + """INSERT INTO mcp_token (token_hash, token_plain, token_prefix, client_id, service_scope, status, expires_at, description, created_by) - VALUES ($1, $2, $3, $4, 'active', $5, $6, $7) + VALUES ($1, $2, $3, $4, $5, 'active', $6, $7, $8) RETURNING token_id, token_prefix, client_id, service_scope, status, expires_at, description, created_at, created_by""", - token_hash, token_prefix, req.client_id, req.service_scope, + token_hash, plain, token_prefix, req.client_id, req.service_scope, req.expires_at, req.description, admin.get("username", "admin"), ) diff --git a/mcp-auth/frontend/src/api/index.ts b/mcp-auth/frontend/src/api/index.ts index d0802f4..323fa32 100644 --- a/mcp-auth/frontend/src/api/index.ts +++ b/mcp-auth/frontend/src/api/index.ts @@ -3,6 +3,7 @@ import api from './client'; export interface TokenRow { token_id: number; token_prefix: string; + token_plain: string | null; client_id: string; service_scope: string; status: string; diff --git a/mcp-auth/frontend/src/pages/Tokens/index.tsx b/mcp-auth/frontend/src/pages/Tokens/index.tsx index 62be88f..a456183 100644 --- a/mcp-auth/frontend/src/pages/Tokens/index.tsx +++ b/mcp-auth/frontend/src/pages/Tokens/index.tsx @@ -11,7 +11,7 @@ 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 { @@ -110,22 +110,37 @@ 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, + const onCopy = (text: string) => { + navigator.clipboard.writeText(text).then(() => { + message.success('已复制'); }); - setCreated(res); - setCreateOpen(false); - form.resetFields(); - load(); + }; + + const onCreate = async () => { + 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 (e: any) { + if (e?.response?.data?.detail) { + message.error(e.response.data.detail); + } else if (e?.errorFields) { + // 表单校验错误,antd 自动处理 + } else { + message.error('签发失败'); + } + } }; const columns: ColumnsType = [ - { title: '前缀', dataIndex: 'token_prefix', key: 'token_prefix', width: 140 }, { title: '客户端', dataIndex: 'client_id', key: 'client_id', width: 240 }, { title: '范围', @@ -134,6 +149,24 @@ export default function Tokens() { width: 80, render: (s: string) => {s}, }, + { + title: 'Token', + dataIndex: 'token_plain', + key: 'token', + width: 300, + render: (plain: string | null, row: TokenRow) => { + if (!plain) return row.token_prefix; + const masked = plain.slice(0, 12) + '••••••••'; + return ( + + + {masked} + +