feat(admin): add managed Open API client keys
This commit is contained in:
@@ -212,7 +212,7 @@ claude mcp add --transport http xiaoqu http://192.168.4.236:8088/mcp \
|
||||
|
||||
## Open API(服务间接口,/api/open/**)
|
||||
|
||||
供其他系统在服务端到服务端场景下获取用户 Token。鉴权为静态服务间密钥(header `X-Open-Key`,dev 明文配置在 `application-dev.yml` 的 `app.open.api-key`;配置为空 = 接口关闭)。
|
||||
供其他系统在服务端到服务端场景下获取用户或服务 Token。鉴权使用 header `X-Open-Key`:生产优先在管理后台「服务密钥」页面创建并绑定作用域,明文只展示一次、数据库仅存 SHA-256 哈希;原有 `app.open.api-key` / `app.open.clients` 环境配置继续兼容。
|
||||
|
||||
**`POST /api/open/token/ensure`** —— 按 手机号/邮箱 + 作用域编码 确保 Token 存在(get-or-create):
|
||||
|
||||
@@ -228,12 +228,13 @@ curl -X POST http://localhost:8088/api/open/token/ensure \
|
||||
- 已存在但无密文(历史数据)→ 自动轮换:禁用旧把、签发新把,按 `created=true` 返回新明文。
|
||||
- 不存在 → 自动创建永久 Token → `{"created":true,"token":"xqak_...(明文)",...}`。
|
||||
- 调用写 `audit_log`(`open.token.ensure` / client_type=open-api,不记录明文)。
|
||||
- 管理后台支持服务密钥列表、创建、编辑作用域、吊销/启用和轮换;吊销或轮换后旧密钥立即无法通过 `/api/open/**` 鉴权。
|
||||
|
||||
**签发规则(2026-09-04 起)**:一作用域一 Token。后台签发时选 N 个作用域会生成 N 把 token,每把独立绑定单作用域;编辑存量 token 时作用域为单选。
|
||||
|
||||
## codegen(run-once)
|
||||
|
||||
表白名单:m0 `user, agent, authority, properyt, agent_manage_district_auth`;m2 `warehouse_agent_auth`;svc `agent_api_key, agent_scope, tool, scope_tool, token_scope, audit_log, admin_user`(注:物业表真实表名为老系统遗留拼写 `properyt`)。生成物落在 `src/main/java` 对应分包并提交进仓库。
|
||||
表白名单:m0 `user, agent, authority, properyt, agent_manage_district_auth`;m2 `warehouse_agent_auth`;svc `agent_api_key, agent_scope, tool, scope_tool, token_scope, audit_log, admin_user, app_open_client, app_open_client_scope`(注:物业表真实表名为老系统遗留拼写 `properyt`)。生成物落在 `src/main/java` 对应分包并提交进仓库。
|
||||
|
||||
```bash
|
||||
# 生成(依赖已下载后执行;连 dev 库)
|
||||
|
||||
@@ -151,7 +151,7 @@ xiaoqu-ai Android/iOS/Web ─► Gateway/Agent ─────┤
|
||||
|
||||
供其他业务系统/智能体平台在服务端到服务端场景下获取用户 Token,路径 `/api/open/**`,与 ak 体系、后台 session 体系完全分离。
|
||||
|
||||
- **鉴权**:静态服务间密钥,header `X-Open-Key`,配置项 `app.open.api-key`(dev 明文写 `application-dev.yml`;配置为空 = 接口关闭)。常量时间比较防时序侧信道。
|
||||
- **鉴权**:服务间密钥放在 header `X-Open-Key`。生产密钥由现有管理后台创建,数据库只存 SHA-256 哈希并绑定作用域;旧 `app.open.api-key` / `app.open.clients` 配置保留兼容,其中环境变量明文使用常量时间比较。
|
||||
- **`POST /api/open/token/ensure`**(get-or-create):
|
||||
- 入参:`mobile`、`email`(至少一个,手机号优先)、`scopeCode`(作用域编码,必填)。
|
||||
- 人员只认企业账号(`enterprise_type` 0/1,NULL 保留,与后台用户列表同口径);查不到 → 404。
|
||||
@@ -162,7 +162,7 @@ xiaoqu-ai Android/iOS/Web ─► Gateway/Agent ─────┤
|
||||
- **服务 Token(principal_type=service,REQ-20260911-0014)**:
|
||||
- `agent_api_key.principal_type` 区分 `user`(用户委托,绑定 `user_id`,行级权限跟随人员)与 `service`(租户服务,`user_id` 为 NULL,`subject_name` 标识服务主体,行级权限不适用,数据范围 = 绑定作用域全量)。
|
||||
- ensure 入参补 `principalType` / `serviceName`:`principalType=service` 时按 `serviceName + scopeCode` get-or-create 服务 Token,语义与 user 路径一致(存在解密回传、无密文自动轮换、不存在签发永久 Token)。
|
||||
- **服务密钥 → 数据域绑定**:`app.open.clients` 配置密钥清单(key/name/scopes),鉴权时按密钥确定可操作的作用域,越域 403;旧配置 `app.open.api-key` 保留为不限域密钥,仅存量 user ensure 可用,**不可签发 service Token**。
|
||||
- **服务密钥 → 数据域绑定**:管理后台「服务密钥」页面创建调用方并勾选作用域,鉴权时按密钥确定可操作的数据域,越域 403;密钥明文仅在创建/轮换时展示,吊销/轮换立即生效。`app.open.clients` 作为环境变量兼容通道;旧 `app.open.api-key` 保留为不限域密钥,仅存量 user ensure 可用,**不可签发 service Token**。
|
||||
- 吊销:后台 `PATCH /api/admin/tokens/{id}/status` 置 0,`AkAuthFilter` 现有 status 校验天然生效,吊销后立即 401;轮换:`POST /api/admin/tokens/{id}/rotate` 禁用旧把并按原主体/作用域/过期时间签新把,明文仅本次响应返回。
|
||||
- **人员停用失效**:`AuthService` 校验 user 类型 Token 时检查 m0.user `soft_delete`(注意 `work_status` 是考勤状态,不是在职状态),人员状态本地缓存 TTL 60s(→ Redis 热缓存 → m0 库三级),停用最长 60s 后 ak 调用返回 401/UNAUTHENTICATED。
|
||||
- **SYNC 工具白名单**:个人 Token 禁调 SYNC 工具不新造拦截代码,靠 `token_scope` 白名单——SYNC 工具(`*.changes.list`,后续需求 5628 注册)只绑进 `sync` 作用域,个人 Token 不勾该作用域即天然 PERMISSION_DENIED;`sync` 作用域由 `sql/seed-dev.sql` 预建。ensure 侧同时做入口约束:`principalType=service` 只允许 `scopeCode=sync`,user ensure 不允许 `sync`。
|
||||
@@ -203,6 +203,8 @@ xiaoqu-agent-service/
|
||||
| `tool` | name、category、description、kind(read/write)、status | 工具注册表,启动时同步;category 代码声明 |
|
||||
| `audit_log` | user_id、token_id、tool_name、params_digest、result_status、cost_ms、client_ip、client_type | 审计;client_type 区分 mcp-kimi/mcp-claude/cli |
|
||||
| `admin_user` | account、password_hash | 后台管理员 |
|
||||
| `app_open_client` | name、key_prefix、key_hash、status、last_used_time | 管理后台创建的 Open API 服务密钥调用方(不存明文) |
|
||||
| `app_open_client_scope` | client_id、scope_id | 服务密钥-作用域关系 |
|
||||
|
||||
均带标准审计五件套(create/modify id/time、soft_delete)。
|
||||
|
||||
@@ -220,7 +222,7 @@ xiaoqu-agent-service/
|
||||
| P1 骨架 | 工程初始化、版本组合验证、三数据源连通、codegen 跑通 | demo 工具经 MCP 调通 |
|
||||
| P2 权限 | ak 鉴权 Filter、scope 校验、数据权限引擎、审计 | 不同权限组的人查询结果隔离正确 |
|
||||
| P3 首批工具 | 代理商查询 + 客户(物业)查询(定义见 §9.1)+ tool 表同步 | Kimi/Claude 双端实测 |
|
||||
| P4 管理后台 | 用户/token、工具、作用域、审计四页面(Vue3 内嵌) | 管理员全流程自助配置 |
|
||||
| P4 管理后台 | 用户/token、服务密钥、工具、作用域、审计五页面(Vue3 内嵌) | 管理员全流程自助配置 |
|
||||
| P5 加固 | 限流、ES 接入(按需)、写工具评审开放、CLI(Go 单二进制) | 生产上线 |
|
||||
|
||||
### 9.1 首期工具定义(P3,均已确认)
|
||||
|
||||
@@ -13,11 +13,22 @@
|
||||
|
||||
| 步骤 | 操作方 | 说明 |
|
||||
|---|---|---|
|
||||
| 1 | 小趣管理员 | 为 CRM 开通服务密钥(X-Open-Key,一系统一密钥) |
|
||||
| 2 | 小趣管理员 | 给 CRM 的服务主体绑定 `sync` 作用域(含 `integration.context.get` + 三个 changes 工具) |
|
||||
| 3 | CRM 后端 | 调 `POST /api/open/token/ensure`(principal=服务主体)拿**长期服务 Token**(`xqak-...`),安全保管 |
|
||||
| 1 | 小趣管理员 | 登录 `https://agent.ququclean.com/`,进入「服务密钥」→「创建服务密钥」 |
|
||||
| 2 | 小趣管理员 | 调用方名称填 `crm`,勾选 `sync` 作用域,保存并立即复制只展示一次的 `X-Open-Key` |
|
||||
| 3 | CRM 后端 | 用下面的请求换取**长期服务 Token**(`xqak-...`),安全保管 |
|
||||
|
||||
完成后 CRM 持有两样东西:**服务 Token**(后台扫描用)、**服务密钥**(给销售换个人 Token 用)。
|
||||
```bash
|
||||
curl -X POST 'https://agent.ququclean.com/api/open/token/ensure' \
|
||||
-H 'X-Open-Key: <管理后台创建后显示的服务密钥>' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"scopeCode":"sync","principalType":"service","serviceName":"crm"}'
|
||||
```
|
||||
|
||||
响应的 `token` 就是后续放进 MCP `Authorization: Bearer <token>` 的服务 Token。`X-Open-Key` 不是 MCP Token,不能直接放进 Authorization 头。
|
||||
|
||||
完成后 CRM 持有两样东西:**服务 Token**(后台扫描用)、**服务密钥**(重新 ensure 获取 Token 或给销售换个人 Token 用)。若还需要给销售换 QUERY 个人 Token,创建/编辑服务密钥时还要勾选对应 QUERY 作用域;只绑定 `sync` 的密钥不能跨域签发个人 Token。
|
||||
|
||||
密钥泄露时,管理员在同一页面点「轮换」(旧密钥立即失效)或「吊销」。数据库只保存密钥哈希,关闭创建/轮换结果弹窗后无法找回明文。
|
||||
|
||||
## 2. 每次调用前的身份自检
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { api, setCsrf } from './api'
|
||||
|
||||
const menus = [
|
||||
{ id: 'tokens', label: '用户与 Token', icon: '👥' },
|
||||
{ id: 'clients', label: '服务密钥', icon: '🔐' },
|
||||
{ id: 'tools', label: '工具管理', icon: '🔧' },
|
||||
{ id: 'scopes', label: '作用域管理', icon: '🧩' },
|
||||
{ id: 'audit', label: '审计日志', icon: '📋' },
|
||||
@@ -20,6 +21,7 @@ const userPage = reactive({ page: 1, pageSize: 20, total: 0 })
|
||||
const selectedUser = ref(null)
|
||||
const permission = ref(null)
|
||||
const tokens = ref([])
|
||||
const openClients = ref([])
|
||||
const scopes = ref([])
|
||||
const tools = ref([])
|
||||
const audits = ref([])
|
||||
@@ -28,6 +30,9 @@ const auditFilter = reactive({ person: '', tokenPrefix: '', tool: '', result: ''
|
||||
const tokenDialog = ref(false)
|
||||
const tokenForm = reactive({ id: null, memo: '', scopeIds: [], scopeId: null, expireTime: null })
|
||||
const issuedTokens = ref([])
|
||||
const clientDialog = ref(false)
|
||||
const clientForm = reactive({ id: null, name: '', memo: '', scopeIds: [] })
|
||||
const issuedOpenKey = ref(null)
|
||||
const scopeDialog = ref(false)
|
||||
const scopeForm = reactive({ id: null, name: '', code: '', memo: '' })
|
||||
const scopeToolsDialog = ref(false)
|
||||
@@ -41,6 +46,20 @@ const groupedTools = computed(() => tools.value.reduce((groups, tool) => {
|
||||
;(groups[category] ||= []).push(tool)
|
||||
return groups
|
||||
}, {}))
|
||||
const issuedKeyCurl = computed(() => {
|
||||
if (!issuedOpenKey.value) return ''
|
||||
const payload = JSON.stringify({
|
||||
scopeCode: 'sync', principalType: 'service', serviceName: issuedOpenKey.value.client.name,
|
||||
})
|
||||
return [
|
||||
"curl -X POST 'https://agent.ququclean.com/api/open/token/ensure' \\",
|
||||
` -H 'X-Open-Key: ${issuedOpenKey.value.plaintext}' \\`,
|
||||
" -H 'Content-Type: application/json' \\",
|
||||
` -d ${shellQuote(payload)}`,
|
||||
].join('\n')
|
||||
})
|
||||
|
||||
function shellQuote(value) { return `'${value.replaceAll("'", `'"'"'`)}'` }
|
||||
|
||||
function qs(values) {
|
||||
const params = new URLSearchParams()
|
||||
@@ -76,6 +95,7 @@ async function logout() {
|
||||
async function switchMenu(id) {
|
||||
current.value = id
|
||||
if (id === 'tokens') await loadUsers()
|
||||
if (id === 'clients') await loadOpenClients()
|
||||
if (id === 'tools') await loadTools()
|
||||
if (id === 'scopes') await loadScopes()
|
||||
if (id === 'audit') await Promise.all([loadAudit(), api('/tools').then(data => { tools.value = data })])
|
||||
@@ -146,6 +166,54 @@ async function deleteToken(token) {
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
async function loadOpenClients() {
|
||||
;[openClients.value, scopes.value] = await Promise.all([api('/open-clients'), api('/scopes')])
|
||||
}
|
||||
|
||||
function openClient(client = null) {
|
||||
Object.assign(clientForm, client ? {
|
||||
id: client.id, name: client.name, memo: client.memo || '', scopeIds: [...client.scopeIds],
|
||||
} : { id: null, name: '', memo: '', scopeIds: [] })
|
||||
clientDialog.value = true
|
||||
}
|
||||
|
||||
async function saveClient() {
|
||||
if (!clientForm.name.trim()) return ElMessage.warning('请填写调用方名称')
|
||||
if (!clientForm.scopeIds.length) return ElMessage.warning('至少选择一个作用域')
|
||||
await run(async () => {
|
||||
const body = JSON.stringify({ name: clientForm.name, memo: clientForm.memo, scopeIds: clientForm.scopeIds })
|
||||
if (clientForm.id) await api(`/open-clients/${clientForm.id}`, { method: 'PUT', body })
|
||||
else issuedOpenKey.value = await api('/open-clients', { method: 'POST', body })
|
||||
clientDialog.value = false
|
||||
await loadOpenClients()
|
||||
ElMessage.success(clientForm.id ? '服务密钥配置已保存' : '服务密钥已创建')
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
async function setClientStatus(client) {
|
||||
if (client.status === 1) {
|
||||
try { await ElMessageBox.confirm(`吊销后 ${client.name} 将立即无法用此密钥换取 Token,确认继续?`, '吊销服务密钥', { type: 'warning' }) }
|
||||
catch { return }
|
||||
}
|
||||
await run(async () => {
|
||||
await api(`/open-clients/${client.id}/status`, {
|
||||
method: 'PATCH', body: JSON.stringify({ status: client.status === 1 ? 0 : 1 }),
|
||||
})
|
||||
await loadOpenClients()
|
||||
ElMessage.success(client.status === 1 ? '服务密钥已吊销' : '服务密钥已启用')
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
async function rotateClient(client) {
|
||||
try { await ElMessageBox.confirm(`轮换后 ${client.name} 的旧密钥立即失效,确认继续?`, '轮换服务密钥', { type: 'warning' }) }
|
||||
catch { return }
|
||||
await run(async () => {
|
||||
issuedOpenKey.value = await api(`/open-clients/${client.id}/rotate`, { method: 'POST' })
|
||||
await loadOpenClients()
|
||||
ElMessage.success('服务密钥已轮换')
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
async function loadTools() {
|
||||
tools.value = await api(`/tools${qs(toolFilter)}`)
|
||||
}
|
||||
@@ -212,6 +280,7 @@ async function copyText(value) { await navigator.clipboard.writeText(value); ElM
|
||||
async function copyAllTokens() {
|
||||
await copyText(issuedTokens.value.map(item => `${item.token.scopeNames.join('/')}: ${item.plaintext}`).join('\n'))
|
||||
}
|
||||
function clearIssuedOpenKey() { issuedOpenKey.value = null }
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
@@ -242,6 +311,7 @@ onMounted(async () => {
|
||||
<div class="logo"><span>趣</span> 智能体服务后台</div>
|
||||
<nav aria-label="主导航">
|
||||
<button :class="['menu-item', { active: current === 'tokens' }]" data-testid="admin-menu-tokens" @click="switchMenu('tokens')">👥 用户与 Token</button>
|
||||
<button :class="['menu-item', { active: current === 'clients' }]" data-testid="admin-menu-clients" @click="switchMenu('clients')">🔐 服务密钥</button>
|
||||
<button :class="['menu-item', { active: current === 'tools' }]" data-testid="admin-menu-tools" @click="switchMenu('tools')">🔧 工具管理</button>
|
||||
<button :class="['menu-item', { active: current === 'scopes' }]" data-testid="admin-menu-scopes" @click="switchMenu('scopes')">🧩 作用域管理</button>
|
||||
<button :class="['menu-item', { active: current === 'audit' }]" data-testid="admin-menu-audit" @click="switchMenu('audit')">📋 审计日志</button>
|
||||
@@ -311,6 +381,31 @@ onMounted(async () => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="current === 'clients'">
|
||||
<article class="card">
|
||||
<div class="card-title">
|
||||
<span>服务密钥调用方</span>
|
||||
<el-button type="primary" data-testid="admin-btn-new-client" @click="openClient()">+ 创建服务密钥</el-button>
|
||||
</div>
|
||||
<el-alert title="密钥明文只在创建或轮换成功后展示一次;已通过环境变量配置的存量密钥继续有效,但不在本页展示。" type="info" :closable="false" show-icon />
|
||||
<el-table :data="openClients" class="client-table" data-testid="admin-table-clients" empty-text="暂无服务密钥">
|
||||
<el-table-column prop="name" label="调用方" min-width="140" />
|
||||
<el-table-column label="密钥前缀" min-width="145"><template #default="{ row }"><code>{{ row.keyPrefix }}…</code></template></el-table-column>
|
||||
<el-table-column prop="memo" label="用途备注" min-width="180" />
|
||||
<el-table-column label="允许作用域" min-width="200"><template #default="{ row }"><el-tag v-for="(name, index) in row.scopeNames" :key="row.scopeIds[index]" size="small" effect="plain">{{ name }}({{ row.scopeCodes[index] }})</el-tag></template></el-table-column>
|
||||
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">{{ row.status === 1 ? '正常' : '已吊销' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="最后使用" min-width="155"><template #default="{ row }">{{ date(row.lastUsedTime) }}</template></el-table-column>
|
||||
<el-table-column label="更新时间" min-width="155"><template #default="{ row }">{{ date(row.modifyTime) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="205" fixed="right"><template #default="{ row }">
|
||||
<el-button link type="primary" :data-testid="`admin-client-edit-${row.id}`" @click="openClient(row)">编辑</el-button>
|
||||
<el-button link type="warning" :data-testid="`admin-client-rotate-${row.id}`" @click="rotateClient(row)">轮换</el-button>
|
||||
<el-button link :type="row.status === 1 ? 'danger' : 'success'" :data-testid="`admin-client-status-${row.id}`" @click="setClientStatus(row)">{{ row.status === 1 ? '吊销' : '启用' }}</el-button>
|
||||
</template></el-table-column>
|
||||
</el-table>
|
||||
<p class="hint">服务密钥用于调用 <code>/api/open/token/ensure</code> 换取业务 Token;作用域决定该密钥能为哪些数据域签发或获取 Token。</p>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<template v-else-if="current === 'tools'">
|
||||
<article class="card">
|
||||
<div class="toolbar">
|
||||
@@ -416,6 +511,25 @@ onMounted(async () => {
|
||||
<template #footer><el-button data-testid="admin-scope-cancel" @click="scopeDialog = false">取消</el-button><el-button type="primary" data-testid="admin-scope-save" @click="saveScope">保存</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="clientDialog" :title="clientForm.id ? '编辑服务密钥' : '创建服务密钥'" width="560px" destroy-on-close>
|
||||
<el-form label-width="92px">
|
||||
<el-form-item label="调用方名称"><el-input v-model="clientForm.name" maxlength="64" placeholder="如 crm" aria-label="服务密钥调用方名称" data-testid="admin-client-name" /></el-form-item>
|
||||
<el-form-item label="用途备注"><el-input v-model="clientForm.memo" type="textarea" maxlength="255" placeholder="如:CRM 合同增量同步" aria-label="服务密钥用途备注" data-testid="admin-client-memo" /></el-form-item>
|
||||
<el-form-item label="允许作用域">
|
||||
<el-checkbox-group v-model="clientForm.scopeIds" data-testid="admin-client-scopes">
|
||||
<el-checkbox
|
||||
v-for="scope in scopes"
|
||||
:key="scope.id"
|
||||
:value="scope.id"
|
||||
:disabled="scope.status !== 1 && !clientForm.scopeIds.includes(scope.id)"
|
||||
>{{ scope.name }}({{ scope.code }})<template v-if="scope.status !== 1"> · 已停用</template></el-checkbox>
|
||||
</el-checkbox-group>
|
||||
<div class="hint" style="margin:4px 0 0">CRM 后台增量同步请选择 sync;其他调用方按实际用途授权。</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button data-testid="admin-client-cancel" @click="clientDialog = false">取消</el-button><el-button type="primary" data-testid="admin-client-save" @click="saveClient">保存</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="scopeToolsDialog" :title="`配置工具 · ${activeScope?.name || ''}`" width="680px" destroy-on-close>
|
||||
<el-checkbox-group v-model="selectedToolIds" class="tool-groups" data-testid="admin-scope-tool-options">
|
||||
<section v-for="(items, category) in groupedTools" :key="category" class="tool-group"><h3>{{ category }}</h3><el-checkbox v-for="tool in items" :key="tool.id" :value="tool.id" :data-testid="`admin-scope-tool-${tool.id}`"><code>{{ tool.name }}</code><small>{{ tool.description }}</small></el-checkbox></section>
|
||||
@@ -432,4 +546,18 @@ onMounted(async () => {
|
||||
</div>
|
||||
<template #footer><el-button type="primary" data-testid="admin-copy-all-tokens" @click="copyAllTokens">全部复制</el-button><el-button data-testid="admin-close-token" @click="issuedTokens = []">我已保存</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog :model-value="!!issuedOpenKey" title="服务密钥已生成" width="720px" :close-on-click-modal="false" @close="clearIssuedOpenKey">
|
||||
<el-alert title="明文只展示这一次,请立即复制并交给对应系统安全保存。关闭后后台无法找回,只能轮换。" type="warning" :closable="false" show-icon />
|
||||
<template v-if="issuedOpenKey">
|
||||
<div class="issued-row">
|
||||
<code class="token-secret" data-testid="admin-issued-client-key">{{ issuedOpenKey.plaintext }}</code>
|
||||
<el-button link type="primary" data-testid="admin-copy-client-key" @click="copyText(issuedOpenKey.plaintext)">复制密钥</el-button>
|
||||
</div>
|
||||
<p class="field-label key-example-label">CRM 换取服务 Token 示例</p>
|
||||
<pre class="key-example" data-testid="admin-client-curl">{{ issuedKeyCurl }}</pre>
|
||||
<p class="hint">如果绑定的不是 sync 作用域,请按实际作用域修改请求中的 scopeCode 和主体参数。</p>
|
||||
</template>
|
||||
<template #footer><el-button type="primary" data-testid="admin-copy-client-curl" @click="copyText(issuedKeyCurl)">复制调用示例</el-button><el-button data-testid="admin-close-client-key" @click="clearIssuedOpenKey">我已保存</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
@@ -62,4 +62,7 @@ code { font-family: Menlo, Consolas, monospace; font-size: 12px; }
|
||||
.token-secret { display: block; margin-top: 14px; padding: 14px; word-break: break-all; border-radius: 4px; background: #f4f4f5; font-size: 13px; }
|
||||
.issued-row { display: flex; align-items: center; gap: 10px; margin-top: 12px; }
|
||||
.issued-row .token-secret { flex: 1; margin-top: 0; padding: 9px 12px; }
|
||||
.client-table { margin-top: 16px; }
|
||||
.key-example-label { margin: 18px 0 7px; }
|
||||
.key-example { margin: 0; padding: 14px; overflow: auto; color: #e6edf3; background: #1f2328; border-radius: 5px; font: 12px/1.6 Menlo, Consolas, monospace; white-space: pre-wrap; word-break: break-all; }
|
||||
@media (max-width: 1200px) { .token-layout { grid-template-columns: 300px minmax(640px, 1fr); } }
|
||||
|
||||
@@ -9,11 +9,15 @@ test('prototype interactions expose stable test ids', async () => {
|
||||
'admin-input-password',
|
||||
'admin-btn-login',
|
||||
'admin-menu-tokens',
|
||||
'admin-menu-clients',
|
||||
'admin-menu-tools',
|
||||
'admin-menu-scopes',
|
||||
'admin-menu-audit',
|
||||
'admin-input-user-search',
|
||||
'admin-btn-issue-token',
|
||||
'admin-btn-new-client',
|
||||
'admin-table-clients',
|
||||
'admin-issued-client-key',
|
||||
'admin-table-tools',
|
||||
'admin-btn-new-scope',
|
||||
'admin-table-scopes',
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS app_open_client_scope;
|
||||
DROP TABLE IF EXISTS app_open_client;
|
||||
@@ -0,0 +1,65 @@
|
||||
package me.iiv.agent.admin;
|
||||
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import me.iiv.agent.open.OpenClientService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 复用现有管理员会话的服务密钥管理接口。 */
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/open-clients")
|
||||
public class AdminOpenClientController {
|
||||
|
||||
private final OpenClientService service;
|
||||
|
||||
public AdminOpenClientController(OpenClientService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<OpenClientService.ClientView> list() {
|
||||
return service.list();
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public OpenClientService.IssuedClient create(@RequestBody OpenClientService.ClientRequest body,
|
||||
HttpSession session) {
|
||||
return service.create(body, adminId(session));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void update(@PathVariable Long id, @RequestBody OpenClientService.ClientRequest body,
|
||||
HttpSession session) {
|
||||
service.update(id, body, adminId(session));
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}/status")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void status(@PathVariable Long id, @RequestBody StatusRequest body, HttpSession session) {
|
||||
if (body == null) throw new org.springframework.web.server.ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "请求内容不能为空");
|
||||
service.status(id, body.status(), adminId(session));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/rotate")
|
||||
public OpenClientService.IssuedClient rotate(@PathVariable Long id, HttpSession session) {
|
||||
return service.rotate(id, adminId(session));
|
||||
}
|
||||
|
||||
private static Long adminId(HttpSession session) {
|
||||
return (Long) session.getAttribute(AdminSessionFilter.ADMIN_SESSION_ID);
|
||||
}
|
||||
|
||||
public record StatusRequest(byte status) {}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package me.iiv.agent.entity.svc;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.ToString;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@ToString
|
||||
@TableName("app_open_client")
|
||||
public class AppOpenClient implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
private String name;
|
||||
private String keyPrefix;
|
||||
private String keyHash;
|
||||
private String memo;
|
||||
private Byte status;
|
||||
private Date lastUsedTime;
|
||||
private Long createId;
|
||||
private Date createTime;
|
||||
private Long modifyId;
|
||||
private Date modifyTime;
|
||||
private Byte softDelete;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package me.iiv.agent.entity.svc;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.ToString;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@ToString
|
||||
@TableName("app_open_client_scope")
|
||||
public class AppOpenClientScope implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long clientId;
|
||||
private Long scopeId;
|
||||
private Long createId;
|
||||
private Date createTime;
|
||||
private Long modifyId;
|
||||
private Date modifyTime;
|
||||
private Byte softDelete;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package me.iiv.agent.mapper.svc;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import me.iiv.agent.entity.svc.AppOpenClient;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
public interface AppOpenClientMapper extends BaseMapper<AppOpenClient> {
|
||||
|
||||
/** 使用时间属于运行态信息,不应改变管理员看到的配置修改时间。 */
|
||||
@Update("UPDATE app_open_client SET last_used_time = CURRENT_TIMESTAMP, modify_time = modify_time WHERE id = #{id}")
|
||||
int touchLastUsed(@Param("id") Long id);
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package me.iiv.agent.mapper.svc;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import me.iiv.agent.entity.svc.AppOpenClientScope;
|
||||
|
||||
public interface AppOpenClientScopeMapper extends BaseMapper<AppOpenClientScope> {
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import java.util.List;
|
||||
/**
|
||||
* /api/open 服务密钥配置(REQ-20260911-0014)。
|
||||
* 旧配置 api-key 为单个不绑定数据域的密钥,仅保留给存量用户 Token ensure;
|
||||
* 新接入方(尤其签发 service 类型 Token)必须配置 clients:密钥 → 名称 + 允许的作用域编码,
|
||||
* 鉴权时按密钥确定可调用的数据域,越域请求 403。
|
||||
* 新接入方优先使用管理后台创建并落库的服务密钥;clients 继续作为环境变量兼容通道。
|
||||
* 两种来源都按密钥确定允许的作用域编码,越域请求 403。
|
||||
*/
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "app.open")
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
package me.iiv.agent.open;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import me.iiv.agent.entity.svc.AgentScope;
|
||||
import me.iiv.agent.entity.svc.AppOpenClient;
|
||||
import me.iiv.agent.entity.svc.AppOpenClientScope;
|
||||
import me.iiv.agent.mapper.svc.AgentScopeMapper;
|
||||
import me.iiv.agent.mapper.svc.AppOpenClientMapper;
|
||||
import me.iiv.agent.mapper.svc.AppOpenClientScopeMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 数据库托管的 Open API 服务密钥。
|
||||
* 明文只在创建或轮换时存在于本次响应中,持久化层仅保存 SHA-256 哈希。
|
||||
*/
|
||||
@Service
|
||||
public class OpenClientService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(OpenClientService.class);
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
private static final String KEY_PREFIX = "xqok_";
|
||||
|
||||
private final AppOpenClientMapper clientMapper;
|
||||
private final AppOpenClientScopeMapper clientScopeMapper;
|
||||
private final AgentScopeMapper scopeMapper;
|
||||
|
||||
public OpenClientService(AppOpenClientMapper clientMapper, AppOpenClientScopeMapper clientScopeMapper,
|
||||
AgentScopeMapper scopeMapper) {
|
||||
this.clientMapper = clientMapper;
|
||||
this.clientScopeMapper = clientScopeMapper;
|
||||
this.scopeMapper = scopeMapper;
|
||||
}
|
||||
|
||||
/** 解析后台创建的密钥;禁用或删除的调用方不会命中。 */
|
||||
public OpenClientProperties.ResolvedClient resolve(String plaintext) {
|
||||
if (!StringUtils.hasText(plaintext)) return null;
|
||||
AppOpenClient client = clientMapper.selectOne(new LambdaQueryWrapper<AppOpenClient>()
|
||||
.eq(AppOpenClient::getKeyHash, sha256(plaintext))
|
||||
.eq(AppOpenClient::getStatus, 1).eq(AppOpenClient::getSoftDelete, 0));
|
||||
if (client == null) return null;
|
||||
List<String> scopeCodes = boundScopes(client.getId()).stream().map(AgentScope::getCode).toList();
|
||||
try {
|
||||
clientMapper.touchLastUsed(client.getId());
|
||||
} catch (RuntimeException e) {
|
||||
// 使用时间是非关键遥测,不应使一把本来有效的密钥鉴权失败。
|
||||
log.warn("服务密钥最后使用时间更新失败 clientId={}: {}", client.getId(), e.getMessage());
|
||||
}
|
||||
return new OpenClientProperties.ResolvedClient(client.getName(), scopeCodes, false);
|
||||
}
|
||||
|
||||
/** 数据库中存在任何未删除配置时,Open API 视为已配置(即使当前全部已吊销)。 */
|
||||
public boolean hasConfiguredClients() {
|
||||
Long count = clientMapper.selectCount(new LambdaQueryWrapper<AppOpenClient>()
|
||||
.eq(AppOpenClient::getSoftDelete, 0));
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
public List<ClientView> list() {
|
||||
List<AppOpenClient> clients = clientMapper.selectList(new LambdaQueryWrapper<AppOpenClient>()
|
||||
.eq(AppOpenClient::getSoftDelete, 0).orderByDesc(AppOpenClient::getId));
|
||||
if (clients.isEmpty()) return List.of();
|
||||
|
||||
Set<Long> clientIds = clients.stream().map(AppOpenClient::getId).collect(Collectors.toSet());
|
||||
List<AppOpenClientScope> relations = clientScopeMapper.selectList(
|
||||
new LambdaQueryWrapper<AppOpenClientScope>().in(AppOpenClientScope::getClientId, clientIds)
|
||||
.eq(AppOpenClientScope::getSoftDelete, 0));
|
||||
Set<Long> scopeIds = relations.stream().map(AppOpenClientScope::getScopeId).collect(Collectors.toSet());
|
||||
Map<Long, AgentScope> scopeById = scopeIds.isEmpty() ? Map.of() : scopeMapper.selectList(
|
||||
new LambdaQueryWrapper<AgentScope>().in(AgentScope::getId, scopeIds)
|
||||
.eq(AgentScope::getSoftDelete, 0))
|
||||
.stream().collect(Collectors.toMap(AgentScope::getId, Function.identity()));
|
||||
Map<Long, List<AgentScope>> scopesByClient = new LinkedHashMap<>();
|
||||
for (AppOpenClientScope relation : relations) {
|
||||
AgentScope scope = scopeById.get(relation.getScopeId());
|
||||
if (scope != null) scopesByClient.computeIfAbsent(relation.getClientId(), ignored -> new ArrayList<>()).add(scope);
|
||||
}
|
||||
return clients.stream().map(client -> view(client, scopesByClient.getOrDefault(client.getId(), List.of()))).toList();
|
||||
}
|
||||
|
||||
@Transactional(transactionManager = "svcTransactionManager")
|
||||
public IssuedClient create(ClientRequest body, Long adminId) {
|
||||
ValidatedRequest request = validate(body, null);
|
||||
String plaintext = generateKey();
|
||||
AppOpenClient client = new AppOpenClient();
|
||||
client.setName(request.name());
|
||||
client.setMemo(request.memo());
|
||||
client.setKeyPrefix(displayPrefix(plaintext));
|
||||
client.setKeyHash(sha256(plaintext));
|
||||
client.setStatus((byte) 1);
|
||||
client.setCreateId(adminId);
|
||||
client.setModifyId(adminId);
|
||||
client.setSoftDelete((byte) 0);
|
||||
clientMapper.insert(client);
|
||||
replaceScopes(client.getId(), request.scopeIds(), adminId);
|
||||
return new IssuedClient(plaintext, view(client, request.scopes()));
|
||||
}
|
||||
|
||||
@Transactional(transactionManager = "svcTransactionManager")
|
||||
public void update(Long id, ClientRequest body, Long adminId) {
|
||||
AppOpenClient client = activeClient(id);
|
||||
ValidatedRequest request = validate(body, id);
|
||||
client.setName(request.name());
|
||||
client.setMemo(request.memo());
|
||||
client.setModifyId(adminId);
|
||||
clientMapper.updateById(client);
|
||||
replaceScopes(id, request.scopeIds(), adminId);
|
||||
}
|
||||
|
||||
public void status(Long id, byte status, Long adminId) {
|
||||
if (status != 0 && status != 1) bad("状态无效");
|
||||
AppOpenClient client = activeClient(id);
|
||||
client.setStatus(status);
|
||||
client.setModifyId(adminId);
|
||||
clientMapper.updateById(client);
|
||||
}
|
||||
|
||||
@Transactional(transactionManager = "svcTransactionManager")
|
||||
public IssuedClient rotate(Long id, Long adminId) {
|
||||
AppOpenClient client = activeClient(id);
|
||||
String plaintext = generateKey();
|
||||
client.setKeyPrefix(displayPrefix(plaintext));
|
||||
client.setKeyHash(sha256(plaintext));
|
||||
client.setModifyId(adminId);
|
||||
clientMapper.updateById(client);
|
||||
return new IssuedClient(plaintext, view(client, boundScopes(client.getId())));
|
||||
}
|
||||
|
||||
private ValidatedRequest validate(ClientRequest body, Long currentId) {
|
||||
if (body == null) bad("请求内容不能为空");
|
||||
if (!StringUtils.hasText(body.name()) || body.name().trim().length() > 64) {
|
||||
bad("调用方名称不能为空且最多 64 字");
|
||||
}
|
||||
if (body.memo() != null && body.memo().length() > 255) bad("用途备注最多 255 字");
|
||||
AppOpenClient duplicate = clientMapper.selectOne(new LambdaQueryWrapper<AppOpenClient>()
|
||||
.eq(AppOpenClient::getName, body.name().trim()).eq(AppOpenClient::getSoftDelete, 0)
|
||||
.ne(currentId != null, AppOpenClient::getId, currentId));
|
||||
if (duplicate != null) throw new ResponseStatusException(HttpStatus.CONFLICT, "调用方名称已存在");
|
||||
|
||||
List<Long> ids = body.scopeIds() == null ? List.of() : body.scopeIds().stream()
|
||||
.filter(Objects::nonNull).distinct().toList();
|
||||
if (ids.isEmpty()) bad("至少选择一个作用域");
|
||||
if (ids.size() > 100) bad("作用域数量不能超过 100");
|
||||
List<AgentScope> scopes = scopeMapper.selectList(new LambdaQueryWrapper<AgentScope>()
|
||||
.in(AgentScope::getId, ids).eq(AgentScope::getStatus, 1).eq(AgentScope::getSoftDelete, 0));
|
||||
if (scopes.size() != ids.size()) bad("作用域不存在或已停用");
|
||||
Map<Long, AgentScope> byId = scopes.stream().collect(Collectors.toMap(AgentScope::getId, Function.identity()));
|
||||
List<AgentScope> ordered = ids.stream().map(byId::get).toList();
|
||||
return new ValidatedRequest(body.name().trim(), trimToNull(body.memo()), ids, ordered);
|
||||
}
|
||||
|
||||
private void replaceScopes(Long clientId, List<Long> scopeIds, Long adminId) {
|
||||
List<AppOpenClientScope> old = clientScopeMapper.selectList(new LambdaQueryWrapper<AppOpenClientScope>()
|
||||
.eq(AppOpenClientScope::getClientId, clientId));
|
||||
for (AppOpenClientScope relation : old) {
|
||||
relation.setSoftDelete((byte) 1);
|
||||
relation.setModifyId(adminId);
|
||||
clientScopeMapper.updateById(relation);
|
||||
}
|
||||
for (Long scopeId : scopeIds) {
|
||||
AppOpenClientScope relation = old.stream()
|
||||
.filter(row -> Objects.equals(row.getScopeId(), scopeId)).findFirst().orElse(null);
|
||||
if (relation == null) {
|
||||
relation = new AppOpenClientScope();
|
||||
relation.setClientId(clientId);
|
||||
relation.setScopeId(scopeId);
|
||||
relation.setCreateId(adminId);
|
||||
}
|
||||
relation.setModifyId(adminId);
|
||||
relation.setSoftDelete((byte) 0);
|
||||
if (relation.getId() == null) clientScopeMapper.insert(relation);
|
||||
else clientScopeMapper.updateById(relation);
|
||||
}
|
||||
}
|
||||
|
||||
private AppOpenClient activeClient(Long id) {
|
||||
AppOpenClient client = clientMapper.selectById(id);
|
||||
if (client == null || !Byte.valueOf((byte) 0).equals(client.getSoftDelete())) bad("服务密钥不存在");
|
||||
return client;
|
||||
}
|
||||
|
||||
private List<AgentScope> boundScopes(Long clientId) {
|
||||
List<Long> ids = clientScopeMapper.selectList(new LambdaQueryWrapper<AppOpenClientScope>()
|
||||
.eq(AppOpenClientScope::getClientId, clientId).eq(AppOpenClientScope::getSoftDelete, 0))
|
||||
.stream().map(AppOpenClientScope::getScopeId).toList();
|
||||
if (ids.isEmpty()) return List.of();
|
||||
return scopeMapper.selectList(new LambdaQueryWrapper<AgentScope>().in(AgentScope::getId, ids)
|
||||
.eq(AgentScope::getSoftDelete, 0).orderByAsc(AgentScope::getId));
|
||||
}
|
||||
|
||||
private static ClientView view(AppOpenClient client, List<AgentScope> scopes) {
|
||||
return new ClientView(client.getId(), client.getName(), client.getKeyPrefix(), client.getMemo(),
|
||||
client.getStatus(), client.getLastUsedTime(), client.getCreateTime(), client.getModifyTime(),
|
||||
scopes.stream().map(AgentScope::getId).toList(),
|
||||
scopes.stream().map(AgentScope::getName).toList(),
|
||||
scopes.stream().map(AgentScope::getCode).toList());
|
||||
}
|
||||
|
||||
static String generateKey() {
|
||||
byte[] random = new byte[24];
|
||||
RANDOM.nextBytes(random);
|
||||
return KEY_PREFIX + HexFormat.of().formatHex(random);
|
||||
}
|
||||
|
||||
static String sha256(String value) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("SHA-256 不可用", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String displayPrefix(String plaintext) {
|
||||
return plaintext.substring(0, Math.min(13, plaintext.length()));
|
||||
}
|
||||
|
||||
private static String trimToNull(String value) {
|
||||
if (value == null || value.trim().isEmpty()) return null;
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private static void bad(String message) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
|
||||
}
|
||||
|
||||
public record ClientRequest(String name, String memo, List<Long> scopeIds) {}
|
||||
public record ClientView(Long id, String name, String keyPrefix, String memo, Byte status,
|
||||
Date lastUsedTime, Date createTime, Date modifyTime,
|
||||
List<Long> scopeIds, List<String> scopeNames, List<String> scopeCodes) {}
|
||||
public record IssuedClient(String plaintext, ClientView client) {}
|
||||
private record ValidatedRequest(String name, String memo, List<Long> scopeIds, List<AgentScope> scopes) {}
|
||||
}
|
||||
@@ -26,9 +26,11 @@ public class OpenKeyFilter extends OncePerRequestFilter {
|
||||
static final String CLIENT_ATTRIBUTE = "me.iiv.agent.open.client";
|
||||
|
||||
private final OpenClientProperties properties;
|
||||
private final OpenClientService clientService;
|
||||
|
||||
public OpenKeyFilter(OpenClientProperties properties) {
|
||||
public OpenKeyFilter(OpenClientProperties properties, OpenClientService clientService) {
|
||||
this.properties = properties;
|
||||
this.clientService = clientService;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -44,7 +46,8 @@ public class OpenKeyFilter extends OncePerRequestFilter {
|
||||
OpenClientProperties.ResolvedClient client = provided == null ? null : resolve(provided);
|
||||
if (client == null) {
|
||||
boolean configured = properties.getApiKey() != null && !properties.getApiKey().isBlank()
|
||||
|| properties.getClients() != null && !properties.getClients().isEmpty();
|
||||
|| properties.getClients() != null && !properties.getClients().isEmpty()
|
||||
|| clientService.hasConfiguredClients();
|
||||
error(response, configured ? 401 : 403, configured ? "服务间密钥无效" : "Open API 未启用");
|
||||
return;
|
||||
}
|
||||
@@ -54,6 +57,8 @@ public class OpenKeyFilter extends OncePerRequestFilter {
|
||||
|
||||
/** 命中 clients 清单返回绑定的作用域;命中旧 api-key 返回不绑域的 legacy client;均未命中返回 null */
|
||||
private OpenClientProperties.ResolvedClient resolve(String provided) {
|
||||
OpenClientProperties.ResolvedClient managed = clientService.resolve(provided);
|
||||
if (managed != null) return managed;
|
||||
byte[] providedBytes = provided.getBytes(StandardCharsets.UTF_8);
|
||||
if (properties.getClients() != null) {
|
||||
for (OpenClientProperties.Client client : properties.getClients()) {
|
||||
|
||||
@@ -23,7 +23,7 @@ import java.util.Date;
|
||||
* 存在有效 Token 时解密 key_cipher 回传明文(二次获取,2026-09-07 决策);
|
||||
* 历史数据无密文的 Token 自动轮换(禁用旧把、签发新把);
|
||||
* 不存在则自动创建永久 Token。两条路径均返回明文。
|
||||
* service 类型(REQ-20260911-0014):要求服务密钥已在配置中绑定该作用域(app.open.clients),
|
||||
* service 类型(REQ-20260911-0014):要求服务密钥已在后台或配置中绑定该作用域,
|
||||
* 旧版不限域密钥(app.open.api-key)不可签发服务 Token。
|
||||
*/
|
||||
@Service
|
||||
@@ -91,7 +91,7 @@ public class OpenTokenService {
|
||||
}
|
||||
if (service && client.legacy()) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||
"签发服务 Token 必须在 app.open.clients 中配置绑定了作用域的服务密钥");
|
||||
"签发服务 Token 必须使用绑定了作用域的非旧版服务密钥");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@ app:
|
||||
open:
|
||||
# /api/open 服务间静态密钥;为空 = Open API 关闭。dev 明文见 application-dev.yml
|
||||
# 旧密钥不限作用域,仅存量用户 Token ensure 可用;
|
||||
# 签发 service 类型 Token 需在 app.open.clients 中配置绑定了作用域的密钥
|
||||
#(prod 用环境变量宽松绑定注入,如 APP_OPEN_CLIENTS_0_KEY / _0_NAME / _0_SCOPES_0,勿写明文)
|
||||
# 签发 service 类型 Token 使用管理后台创建的数据库密钥;仍兼容 app.open.clients 环境变量配置
|
||||
#(prod 环境变量宽松绑定如 APP_OPEN_CLIENTS_0_KEY / _0_NAME / _0_SCOPES_0,勿写明文)
|
||||
api-key: ${OPEN_API_KEY:}
|
||||
# 增量同步(REQ-20260911-0013):sync_token/cursor 签名密钥(为空时从 token-cipher-key 派生)、
|
||||
# 快照有效期、单页上限与慢查询超时(秒)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
-- Open API 服务密钥管理(REQ-20260914-0010)。
|
||||
-- 密钥明文只在创建/轮换时返回,数据库仅保存 SHA-256 哈希和展示前缀。
|
||||
CREATE TABLE IF NOT EXISTS app_open_client (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
name VARCHAR(64) NOT NULL COMMENT '调用方名称(如 crm)',
|
||||
key_prefix VARCHAR(16) NOT NULL COMMENT '服务密钥前缀(展示用)',
|
||||
key_hash CHAR(64) NOT NULL COMMENT '服务密钥明文 SHA-256 hex',
|
||||
memo VARCHAR(255) NULL COMMENT '用途备注',
|
||||
status TINYINT NOT NULL DEFAULT 1 COMMENT '1=正常 0=已吊销',
|
||||
last_used_time DATETIME NULL COMMENT '最后使用时间',
|
||||
create_id BIGINT NULL COMMENT '创建人(admin_user.id)',
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
modify_id BIGINT NULL COMMENT '修改人(admin_user.id)',
|
||||
modify_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
|
||||
soft_delete TINYINT NOT NULL DEFAULT 0 COMMENT '0=正常 1=删除',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_open_client_name (name),
|
||||
UNIQUE KEY uk_open_client_key_hash (key_hash)
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='Open API 服务密钥调用方';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_open_client_scope (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
client_id BIGINT NOT NULL COMMENT 'app_open_client.id',
|
||||
scope_id BIGINT NOT NULL COMMENT 'agent_scope.id',
|
||||
create_id BIGINT NULL COMMENT '创建人(admin_user.id)',
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
modify_id BIGINT NULL COMMENT '修改人(admin_user.id)',
|
||||
modify_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
|
||||
soft_delete TINYINT NOT NULL DEFAULT 0 COMMENT '0=正常 1=删除',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_open_client_scope (client_id, scope_id),
|
||||
KEY idx_open_client_scope_scope (scope_id)
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='Open API 服务密钥-作用域关系';
|
||||
@@ -3,3 +3,4 @@ f8ec43a4fda81382d5675bc59fe7f4339f2e38a5b4241c260c3569e33b43a26d V1__init.sql
|
||||
0b6e407106156911f5fe83a13b342eeec5986e2e1e5b37b6797431a8720e1332 V3__token_cipher.sql
|
||||
5704691fa88406931c6aaf72bceaffd37cf6cc6982ce938c824ad8ecb94db1b5 V4__token_principal_type.sql
|
||||
17b270891eea9c831809de16b9c30a8dbdb01fb5c532f9e372e54513b92a65f9 V5__tool_description_length.sql
|
||||
6fbe58d48ba9291ce07a8c76bbdda4c70e2c759e3064df4d26cb863294c98ae8 V6__open_client_management.sql
|
||||
|
||||
@@ -19,8 +19,9 @@ class FlywayMigrationTest {
|
||||
|
||||
flyway.migrate();
|
||||
|
||||
assertEquals("5", flyway.info().current().getVersion().getVersion());
|
||||
assertEquals("6", flyway.info().current().getVersion().getVersion());
|
||||
assertEquals(255, passwordHashLength(dataSource));
|
||||
assertEquals(64, columnLength(dataSource, "app_open_client", "key_hash"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -36,8 +37,9 @@ class FlywayMigrationTest {
|
||||
|
||||
flyway.migrate();
|
||||
|
||||
assertEquals("5", flyway.info().current().getVersion().getVersion());
|
||||
assertEquals("6", flyway.info().current().getVersion().getVersion());
|
||||
assertEquals(255, passwordHashLength(dataSource));
|
||||
assertEquals(64, columnLength(dataSource, "app_open_client", "key_hash"));
|
||||
}
|
||||
|
||||
private static Flyway flyway(JdbcDataSource dataSource, boolean baseline) {
|
||||
@@ -54,10 +56,14 @@ class FlywayMigrationTest {
|
||||
}
|
||||
|
||||
private static int passwordHashLength(JdbcDataSource dataSource) throws Exception {
|
||||
return columnLength(dataSource, "admin_user", "password_hash");
|
||||
}
|
||||
|
||||
private static int columnLength(JdbcDataSource dataSource, String table, String column) throws Exception {
|
||||
try (Connection connection = dataSource.getConnection();
|
||||
Statement statement = connection.createStatement();
|
||||
ResultSet result = statement.executeQuery("SELECT CHARACTER_MAXIMUM_LENGTH FROM INFORMATION_SCHEMA.COLUMNS "
|
||||
+ "WHERE TABLE_NAME='admin_user' AND COLUMN_NAME='password_hash'")) {
|
||||
+ "WHERE TABLE_NAME='" + table + "' AND COLUMN_NAME='" + column + "'")) {
|
||||
result.next();
|
||||
return result.getInt(1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package me.iiv.agent.open;
|
||||
|
||||
import me.iiv.agent.entity.svc.AgentScope;
|
||||
import me.iiv.agent.entity.svc.AppOpenClient;
|
||||
import me.iiv.agent.entity.svc.AppOpenClientScope;
|
||||
import me.iiv.agent.mapper.svc.AgentScopeMapper;
|
||||
import me.iiv.agent.mapper.svc.AppOpenClientMapper;
|
||||
import me.iiv.agent.mapper.svc.AppOpenClientScopeMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class OpenClientServiceTest {
|
||||
|
||||
@Mock AppOpenClientMapper clientMapper;
|
||||
@Mock AppOpenClientScopeMapper relationMapper;
|
||||
@Mock AgentScopeMapper scopeMapper;
|
||||
|
||||
OpenClientService service;
|
||||
AgentScope sync;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new OpenClientService(clientMapper, relationMapper, scopeMapper);
|
||||
sync = new AgentScope();
|
||||
sync.setId(9L);
|
||||
sync.setName("数据同步");
|
||||
sync.setCode("sync");
|
||||
sync.setStatus((byte) 1);
|
||||
sync.setSoftDelete((byte) 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createReturnsPlaintextOnceAndPersistsOnlyHash() {
|
||||
when(clientMapper.selectOne(any())).thenReturn(null);
|
||||
when(scopeMapper.selectList(any())).thenReturn(List.of(sync));
|
||||
when(relationMapper.selectList(any())).thenReturn(List.of());
|
||||
doAnswer(invocation -> {
|
||||
AppOpenClient client = invocation.getArgument(0);
|
||||
client.setId(21L);
|
||||
return 1;
|
||||
}).when(clientMapper).insert(any(AppOpenClient.class));
|
||||
|
||||
OpenClientService.IssuedClient issued = service.create(
|
||||
new OpenClientService.ClientRequest("crm", "合同同步", List.of(9L)), 1L);
|
||||
|
||||
assertTrue(issued.plaintext().matches("xqok_[0-9a-f]{48}"));
|
||||
ArgumentCaptor<AppOpenClient> client = ArgumentCaptor.forClass(AppOpenClient.class);
|
||||
verify(clientMapper).insert(client.capture());
|
||||
assertEquals(OpenClientService.sha256(issued.plaintext()), client.getValue().getKeyHash());
|
||||
assertFalse(client.getValue().getKeyHash().contains(issued.plaintext()));
|
||||
assertEquals(13, client.getValue().getKeyPrefix().length());
|
||||
ArgumentCaptor<AppOpenClientScope> relation = ArgumentCaptor.forClass(AppOpenClientScope.class);
|
||||
verify(relationMapper).insert(relation.capture());
|
||||
assertEquals(21L, relation.getValue().getClientId());
|
||||
assertEquals(9L, relation.getValue().getScopeId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveReturnsBoundScopeAndTouchesUsage() {
|
||||
AppOpenClient client = client("crm", "known-key");
|
||||
AppOpenClientScope relation = new AppOpenClientScope();
|
||||
relation.setClientId(21L);
|
||||
relation.setScopeId(9L);
|
||||
when(clientMapper.selectOne(any())).thenReturn(client);
|
||||
when(relationMapper.selectList(any())).thenReturn(List.of(relation));
|
||||
when(scopeMapper.selectList(any())).thenReturn(List.of(sync));
|
||||
|
||||
OpenClientProperties.ResolvedClient resolved = service.resolve("known-key");
|
||||
|
||||
assertEquals("crm", resolved.name());
|
||||
assertEquals(List.of("sync"), resolved.scopes());
|
||||
assertFalse(resolved.legacy());
|
||||
verify(clientMapper).touchLastUsed(21L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownKeyDoesNotResolve() {
|
||||
when(clientMapper.selectOne(any())).thenReturn(null);
|
||||
assertEquals(null, service.resolve("wrong-key"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rotateImmediatelyReplacesStoredHash() {
|
||||
AppOpenClient client = client("crm", "old-key");
|
||||
String oldHash = client.getKeyHash();
|
||||
when(clientMapper.selectById(21L)).thenReturn(client);
|
||||
when(relationMapper.selectList(any())).thenReturn(List.of());
|
||||
|
||||
OpenClientService.IssuedClient issued = service.rotate(21L, 1L);
|
||||
|
||||
assertNotEquals(oldHash, client.getKeyHash());
|
||||
assertEquals(OpenClientService.sha256(issued.plaintext()), client.getKeyHash());
|
||||
verify(clientMapper).updateById(client);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRejectsMissingScope() {
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.create(
|
||||
new OpenClientService.ClientRequest("crm", null, List.of()), 1L));
|
||||
}
|
||||
|
||||
private static AppOpenClient client(String name, String plaintext) {
|
||||
AppOpenClient client = new AppOpenClient();
|
||||
client.setId(21L);
|
||||
client.setName(name);
|
||||
client.setKeyPrefix("xqok_12345678");
|
||||
client.setKeyHash(OpenClientService.sha256(plaintext));
|
||||
client.setStatus((byte) 1);
|
||||
client.setSoftDelete((byte) 0);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class OpenKeyFilterTest {
|
||||
|
||||
@@ -35,10 +37,13 @@ class OpenKeyFilterTest {
|
||||
return request;
|
||||
}
|
||||
|
||||
private static OpenKeyFilter filter(OpenClientProperties properties) {
|
||||
return new OpenKeyFilter(properties, mock(OpenClientService.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void clientKeyResolvesToBoundClient() throws Exception {
|
||||
OpenKeyFilter filter = new OpenKeyFilter(
|
||||
properties("legacy-key", "client-key", List.of("operation")));
|
||||
OpenKeyFilter filter = filter(properties("legacy-key", "client-key", List.of("operation")));
|
||||
MockHttpServletRequest request = request("client-key");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
@@ -55,8 +60,7 @@ class OpenKeyFilterTest {
|
||||
|
||||
@Test
|
||||
void legacyKeyResolvesToUnrestrictedLegacyClient() throws Exception {
|
||||
OpenKeyFilter filter = new OpenKeyFilter(
|
||||
properties("legacy-key", "client-key", List.of("operation")));
|
||||
OpenKeyFilter filter = filter(properties("legacy-key", "client-key", List.of("operation")));
|
||||
MockHttpServletRequest request = request("legacy-key");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
@@ -71,8 +75,7 @@ class OpenKeyFilterTest {
|
||||
|
||||
@Test
|
||||
void invalidKeyRejectedWith401() throws Exception {
|
||||
OpenKeyFilter filter = new OpenKeyFilter(
|
||||
properties("legacy-key", "client-key", List.of("operation")));
|
||||
OpenKeyFilter filter = filter(properties("legacy-key", "client-key", List.of("operation")));
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
filter.doFilter(request("wrong-key"), response, new MockFilterChain());
|
||||
@@ -82,11 +85,37 @@ class OpenKeyFilterTest {
|
||||
|
||||
@Test
|
||||
void noKeyConfiguredMeansApiClosed() throws Exception {
|
||||
OpenKeyFilter filter = new OpenKeyFilter(properties("", null, null));
|
||||
OpenKeyFilter filter = filter(properties("", null, null));
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
filter.doFilter(request("any"), response, new MockFilterChain());
|
||||
|
||||
assertEquals(403, response.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void databaseManagedKeyTakesPrecedence() throws Exception {
|
||||
OpenClientService service = mock(OpenClientService.class);
|
||||
OpenClientProperties.ResolvedClient managed =
|
||||
new OpenClientProperties.ResolvedClient("crm", List.of("sync"), false);
|
||||
when(service.resolve("managed-key")).thenReturn(managed);
|
||||
OpenKeyFilter filter = new OpenKeyFilter(properties("legacy-key", null, null), service);
|
||||
MockHttpServletRequest request = request("managed-key");
|
||||
|
||||
filter.doFilter(request, new MockHttpServletResponse(), new MockFilterChain());
|
||||
|
||||
assertSame(managed, request.getAttribute(OpenKeyFilter.CLIENT_ATTRIBUTE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledManagedConfigurationMakesUnknownKeyUnauthorizedInsteadOfClosed() throws Exception {
|
||||
OpenClientService service = mock(OpenClientService.class);
|
||||
when(service.hasConfiguredClients()).thenReturn(true);
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
new OpenKeyFilter(properties("", null, null), service)
|
||||
.doFilter(request("wrong-key"), response, new MockFilterChain());
|
||||
|
||||
assertEquals(401, response.getStatus());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user