调整签发Token逻辑
This commit is contained in:
@@ -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"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,7 +110,14 @@ export default function Tokens() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onCopy = (text: string) => {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
message.success('已复制');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const onCreate = async () => {
|
const onCreate = async () => {
|
||||||
|
try {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
const res = await createToken({
|
const res = await createToken({
|
||||||
client_id: values.client_id,
|
client_id: values.client_id,
|
||||||
@@ -122,10 +129,18 @@ export default function Tokens() {
|
|||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
load();
|
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',
|
||||||
|
|||||||
@@ -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), -- 用途说明
|
||||||
|
|||||||
Reference in New Issue
Block a user