feat(admin): add managed Open API client keys

This commit is contained in:
jiashumeng
2026-09-15 12:05:26 +09:30
parent 6cf1c0f4a0
commit e064e86def
22 changed files with 783 additions and 27 deletions
+3 -2
View File
@@ -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. 每次调用前的身份自检
+128
View File
@@ -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>
+3
View File
@@ -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);
}
@@ -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());
}
}