调整签发Token逻辑

This commit is contained in:
2026-09-01 17:53:52 +08:00
parent 1f056ae099
commit 2ac36d7c72
4 changed files with 58 additions and 22 deletions
+8 -7
View File
@@ -35,6 +35,7 @@ def _row_to_dict(row) -> dict:
return { return {
"token_id": row["token_id"], "token_id": row["token_id"],
"token_prefix": row["token_prefix"], "token_prefix": row["token_prefix"],
"token_plain": row["token_plain"],
"client_id": row["client_id"], "client_id": row["client_id"],
"service_scope": row["service_scope"], "service_scope": row["service_scope"],
"status": row["status"], "status": row["status"],
@@ -85,13 +86,13 @@ 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 不允许重复签发 # client_id + service_scope 唯一校验:同一客户端同一服务不允许重复签发
existing = await pool.fetchval( existing = await pool.fetchval(
"SELECT 1 FROM mcp_token WHERE client_id = $1 AND status = 'active'", "SELECT 1 FROM mcp_token WHERE client_id = $1 AND service_scope = $2 AND status = 'active'",
req.client_id, req.client_id, req.service_scope,
) )
if existing: 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:仅此一次返回 # 生成明文 token:仅此一次返回
plain = secrets.token_urlsafe(32) plain = secrets.token_urlsafe(32)
@@ -99,12 +100,12 @@ async def create_token(req: TokenCreate, admin: dict = Depends(current_admin)):
token_prefix = plain[:12] + "…" token_prefix = plain[:12] + "…"
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_plain, token_prefix, client_id, service_scope, status,
expires_at, description, created_by) 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, RETURNING token_id, token_prefix, client_id, service_scope, status,
expires_at, description, created_at, created_by""", 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"), req.expires_at, req.description, admin.get("username", "admin"),
) )
+1
View File
@@ -3,6 +3,7 @@ import api from './client';
export interface TokenRow { export interface TokenRow {
token_id: number; token_id: number;
token_prefix: string; token_prefix: string;
token_plain: string | null;
client_id: string; client_id: string;
service_scope: string; service_scope: string;
status: string; status: string;
+46 -13
View File
@@ -11,7 +11,7 @@ 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 {
@@ -110,22 +110,37 @@ export default function Tokens() {
}); });
}; };
const onCreate = async () => { const onCopy = (text: string) => {
const values = await form.validateFields(); navigator.clipboard.writeText(text).then(() => {
const res = await createToken({ message.success('已复制');
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(); const onCreate = async () => {
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 (e: any) {
if (e?.response?.data?.detail) {
message.error(e.response.data.detail);
} else if (e?.errorFields) {
// 表单校验错误,antd 自动处理
} else {
message.error('签发失败');
}
}
}; };
const columns: ColumnsType<TokenRow> = [ const columns: ColumnsType<TokenRow> = [
{ title: '前缀', dataIndex: 'token_prefix', key: 'token_prefix', width: 140 },
{ title: '客户端', dataIndex: 'client_id', key: 'client_id', width: 240 }, { title: '客户端', dataIndex: 'client_id', key: 'client_id', width: 240 },
{ {
title: '范围', title: '范围',
@@ -134,6 +149,24 @@ export default function Tokens() {
width: 80, width: 80,
render: (s: string) => <Tag color="blue">{s}</Tag>, render: (s: string) => <Tag color="blue">{s}</Tag>,
}, },
{
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 (
<Space size="small">
<Typography.Text style={{ fontFamily: 'monospace', fontSize: 13 }} ellipsis>
{masked}
</Typography.Text>
<Button type="text" size="small" icon={<CopyOutlined />} onClick={() => onCopy(plain)} />
</Space>
);
},
},
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
+3 -2
View File
@@ -10,10 +10,11 @@
-- 1. mcp_token:token 主表 -- 1. mcp_token:token 主表
CREATE TABLE IF NOT EXISTS mcp_token ( CREATE TABLE IF NOT EXISTS mcp_token (
token_id BIGSERIAL PRIMARY KEY, token_id BIGSERIAL PRIMARY KEY,
token_hash VARCHAR(64) UNIQUE NOT NULL, -- sha256(明文),不存明文 token_hash VARCHAR(64) UNIQUE NOT NULL, -- sha256(明文),verify 校验用
token_plain VARCHAR(128), -- 明文 Token(管理后台二次复制用)
token_prefix VARCHAR(16) NOT NULL, -- 明文前 12 字符 + '…',前端识别用 token_prefix VARCHAR(16) NOT NULL, -- 明文前 12 字符 + '…',前端识别用
client_id VARCHAR(64) NOT NULL, -- 调用方标识(如 trae / partner-a) client_id VARCHAR(64) NOT NULL, -- 调用方标识(如 trae / partner-a)
service_scope VARCHAR(32) NOT NULL, -- 'erp' | 'crm' | 'both' service_scope VARCHAR(32) NOT NULL, -- 'erp' | 'crm'
status VARCHAR(16) NOT NULL DEFAULT 'active', -- active/revoked/expired status VARCHAR(16) NOT NULL DEFAULT 'active', -- active/revoked/expired
expires_at TIMESTAMPTZ, -- null = 永不过期 expires_at TIMESTAMPTZ, -- null = 永不过期
description VARCHAR(200), -- 用途说明 description VARCHAR(200), -- 用途说明