调整签发Token逻辑
This commit is contained in:
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,7 +110,14 @@ export default function Tokens() {
|
||||
});
|
||||
};
|
||||
|
||||
const onCopy = (text: string) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
message.success('已复制');
|
||||
});
|
||||
};
|
||||
|
||||
const onCreate = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const res = await createToken({
|
||||
client_id: values.client_id,
|
||||
@@ -122,10 +129,18 @@ export default function Tokens() {
|
||||
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> = [
|
||||
{ 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) => <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: '状态',
|
||||
dataIndex: 'status',
|
||||
|
||||
@@ -10,10 +10,11 @@
|
||||
-- 1. mcp_token:token 主表
|
||||
CREATE TABLE IF NOT EXISTS mcp_token (
|
||||
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 字符 + '…',前端识别用
|
||||
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
|
||||
expires_at TIMESTAMPTZ, -- null = 永不过期
|
||||
description VARCHAR(200), -- 用途说明
|
||||
|
||||
Reference in New Issue
Block a user