feat(open): ensure支持二次获取明文(ak存AES-GCM密文key_cipher, app.token-cipher-key派生密钥); 历史无密文Token自动轮换; V3迁移+回滚方案; 同步文档

This commit is contained in:
xiaoqu
2026-09-07 12:41:03 +08:00
parent 1721bc15cb
commit 8c872ac15d
17 changed files with 249 additions and 34 deletions
+6 -3
View File
@@ -132,6 +132,8 @@ cp deploy/.env.example deploy/.env.dev # 填写 dev 凭据(已 gitignore,
仓库不提供固定 ak。每位开发者在本地生成随机 ak,只把 SHA-256 写入 `agent_api_key`,再用 `token_scope` 关联需要的作用域;明文只保存在本机客户端配置(如 `.kimi-code/mcp.json`,已 gitignore)中,不提交版本库。仓库历史中曾出现的 dev ak 必须视为已泄露并在数据库中禁用。
手工插入的 ak 没有 `key_cipher` 密文:走 `/mcp` 正常(只校验哈希),但被 `ensure` 命中时会触发自动轮换(旧把禁用、签发新把)——这是预期行为。
```bash
AK="xqak_$(openssl rand -hex 24)" # 生成随机 ak,明文只贴进本机客户端配置
printf %s "$AK" | shasum -a 256 # 输出的哈希写入 agent_api_key.key_hash
@@ -211,9 +213,10 @@ curl -X POST http://localhost:8088/api/open/token/ensure \
```
- 入参:`mobile`、`email` 至少一个(手机号优先);`scopeCode` 必填。人员只认企业账号(enterprise_type 0/1),查不到 → 404。
- 已存在有效 Token → `{"created":false,"token":null,"keyPrefix":"xqak_xxx…",...}`(服务端只存哈希,**明文不可回吐**,丢了只能后台重新签发)。
- 不存在 → 自动创建永久 Token → `{"created":true,"token":"xqak_...(明文,仅此一次)",...}`。
- 调用写 `audit_log`(`open.token.ensure` / client_type=open-api)。
- 已存在有效 Token → `{"created":false,"token":"xqak_...(解密既有密文得到的明文)",...}`(**支持二次获取**:服务端保存 SHA-256 哈希 + AES-GCM 密文,密钥由 `app.token-cipher-key` 派生)。
- 已存在但无密文(历史数据)→ 自动轮换:禁用旧把、签发新把,按 `created=true` 返回新明文。
- 不存在 → 自动创建永久 Token → `{"created":true,"token":"xqak_...(明文)",...}`。
- 调用写 `audit_log`(`open.token.ensure` / client_type=open-api,不记录明文)。
**签发规则(2026-09-04 起)**:一作用域一 Token。后台签发时选 N 个作用域会生成 N 把 token,每把独立绑定单作用域;编辑存量 token 时作用域为单选。
@@ -123,7 +123,7 @@ xq CLI ─────┘ (Go 单二进制, 同为 MCP 客户端) ┌──
```
- Claude Code:`claude mcp add --transport http xiaoqu https://mcp.<域名>/mcp --header "Authorization: Bearer <ak>"`。
- ak 明文只出现在服务端签发响应和运行时;服务端保存 SHA-256 哈希及加密密文,含 ak 的客户端配置文件不得提交版本库。
- ak 明文只出现在服务端签发/ensure 响应和运行时;服务端保存 SHA-256 哈希及 AES-GCM 加密密文(二次获取用),含 ak 的客户端配置文件不得提交版本库。
### 5.2 Web 管理后台(四个页面)
@@ -155,9 +155,10 @@ xq CLI ─────┘ (Go 单二进制, 同为 MCP 客户端) ┌──
- **`POST /api/open/token/ensure`**(get-or-create):
- 入参:`mobile`、`email`(至少一个,手机号优先)、`scopeCode`(作用域编码,必填)。
- 人员只认企业账号(`enterprise_type` 0/1,NULL 保留,与后台用户列表同口径);查不到 → 404。
- 该人员在该作用域下**已存在**有效 Token(正常且未过期)→ `created=false`,只回 `keyPrefix`/`expireTime` 等元信息,明文不回传。
- **不存在** → 自动创建永久 Token(一作用域一 Token,memo 标记 `open-api 自动签发`),`created=true` 返回明文,仅此一次。
- 全程写 `audit_log`(`tool_name=open.token.ensure`,`client_type=open-api`,参数摘要脱敏)。
- 该人员在该作用域下**已存在**有效 Token(正常且未过期)→ `created=false`,**解密 `key_cipher` 回传明文**(二次获取,2026-09-07 决策:服务端保存 SHA-256 哈希 + AES-GCM 加密密文,密钥由配置 `app.token-cipher-key` 派生)。
- 已存在但**无密文**(V3 之前的历史数据)→ 自动轮换:禁用旧把、签发新把,按 `created=true` 返回新明文;解密失败 → 500,不回退。
- **不存在** → 自动创建永久 Token(一作用域一 Token,memo 标记 `open-api 自动签发`),`created=true` 返回明文。
- 全程写 `audit_log`(`tool_name=open.token.ensure`,`client_type=open-api`,参数摘要脱敏,不记录明文)。
## 6. 技术栈与工程结构
@@ -186,7 +187,7 @@ xiaoqu-agent-service/
| 表 | 关键字段 | 说明 |
|---|---|---|
| `agent_api_key` | user_id、key_prefix、key_hash(SHA-256)、status、expire_time(可空=永久)、last_used_time | token;user_id 逻辑关联 m0.user |
| `agent_api_key` | user_id、key_prefix、key_hash(SHA-256)、key_cipher(AES-GCM 密文)、status、expire_time(可空=永久)、last_used_time | token;user_id 逻辑关联 m0.user |
| `scope` | name、code、memo、status | 作用域 |
| `scope_tool` | scope_id、tool_id | 作用域-工具 |
| `token_scope` | token_id、scope_id | token-作用域 |
@@ -199,7 +200,7 @@ xiaoqu-agent-service/
## 8. 安全基线
- dev/内网测试阶段允许裸跑 HTTP(客户端 URL 用 `http://`);**对外/生产必须 HTTPS**(Nginx 终结 TLS,ak 在 HTTP 下明文可被抓包)
- ak 只存哈希;审计不记录 ak;限流按 token 维度(防 agent 死循环打爆库)
- ak 存 SHA-256 哈希 + AES-GCM 加密密文(不存明文;密钥独立配置 `app.token-cipher-key`);审计不记录 ak;限流按 token 维度(防 agent 死循环打爆库)
- 数据访问全部只读账号;scope 最小化发放,新工具默认不可用
- 可选:Nginx IP 白名单(客户端出口固定时)
@@ -240,7 +241,7 @@ xiaoqu-agent-service/
- 不依赖老 jar,Entity 自行生成 → 与老系统零代码依赖
- 前后端一体打包单 jar → 可逆决定,将来可拆
- 一作用域一 Token(2026-09-04)→ 收回某能力不需整把吊销;数据模型不变(token_scope 每把只写一行)
- 长期 Token 仅按用户首次创建返回明文一次;后续复用元信息,不对外提供二次获取明文接口(服务端仅用于禁用/审计与治理)
- ensure 支持二次获取明文(2026-09-07,推翻此前"明文不可回吐"红线):服务端存 SHA-256 哈希 + AES-GCM 密文(`app.token-cipher-key` 派生密钥);已存在 Token 解密回传明文,历史无密文 Token 自动轮换 → 调用方丢了明文无需人工重新签发;代价是拿到 `X-Open-Key` 者可取回任意用户 ak 明文,密钥必须仅限受信任后端保管
- Open API 鉴权用配置文件静态密钥(dev)→ 服务间调用方少,落库+轮换留给生产阶段
- 列表类接口一律服务端分页,pageSize/rows 上限 100(超出截断为 100)→ 防止一次性拉全表,MCP 工具与后台 API 同一尺度
@@ -24,7 +24,7 @@
### 1.3 明确不做
- 不把一个固定 Token 写入 App、公共配置文件或所有用户共用。
- 不把数据库中保存的 Token 哈希当作客户端 Token 返回;明文只在签发响应中出现一次。
- 不把数据库中保存的 Token 哈希当作客户端 Token 返回;明文仅通过签发/ensure 响应经加密密文解密返回,不落日志。
- 不让客户端提交任意 `user_id`、`tenant_id` 来冒充用户身份。
## 2. 现状分析
@@ -38,7 +38,7 @@
| `xiaoqu-ai/claw-code-parity/rust/crates/claw-mobile/src/mcp_http.rs` | Rust HTTP | JSON-RPC HTTP/SSE MCP 客户端 | 修改 |
| `xiaoqu-agent-service/src/main/java/me/iiv/agent/security/AkAuthFilter.java` | 服务鉴权 | 仅解析 `Authorization: Bearer <ak>` | 保持兼容 |
| `xiaoqu-agent-service/src/main/java/me/iiv/agent/security/AuthService.java` | 服务鉴权 | AK SHA-256 哈希查询、用户加载、过期检查 | 修改/复用 |
| `xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminTokenService.java` | 服务 Token | 管理员为指定用户签发 AK,明文只返回一次 | 扩展复用 |
| `xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminTokenService.java` | 服务 Token | 管理员为指定用户签发 AK,保存哈希 + AES-GCM 密文 | 扩展复用 |
### 2.2 当前缺口
@@ -96,7 +96,7 @@
响应 200:
- `created`: bool — 本次是否新建。
- `token`: string | null — **仅 `created=true` 时返回明文,且只出现这一次**;`created=false` 时恒为 null。
- `token`: string — **Token 明文,两条路径均返回**(2026-09-07 起支持二次获取);`created=true` 为新签发的明文,`created=false` 为解密既有密文得到的明文。
- `keyPrefix`: string — Token 前缀(如 `xqak_abcd1234`),用于标识和对账。
- `expireTime`: datetime | null — 现行为 null(永久,靠禁用回收)。
- `userId` / `userName` / `scopeCode`: 归属信息。
@@ -111,7 +111,7 @@
服务端处理(已实现):
- 复用 `agent_api_key` 哈希存储与 `token_scope` 绑定;一作用域一 Token。
- 已存在有效 Token(正常且未过期)只回元信息——**明文不可回吐,服务端只存 SHA-256**。
- 已存在有效 Token(正常且未过期)→ 解密 `key_cipher` 回传明文(服务端保存 SHA-256 + AES-GCM 密文,密钥由 `app.token-cipher-key` 派生);历史无密文 Token 自动轮换(禁用旧把、签发新把)。
- 不存在则自动创建永久 Token,memo 标记 `open-api 自动签发(<scopeCode>)`。
- 全程写 `audit_log`(`tool_name=open.token.ensure`、`client_type=open-api`,手机号/邮箱脱敏,不记录 Token)。
@@ -44,9 +44,9 @@ Content-Type: application/json
- `X-Open-Key` 只能配置在受信任后端,不能下发 App。
- 业务后端必须从已验证用户会话查询手机号/邮箱,不能信任客户端或模型传入的身份字段。
- 首次创建返回 Token 明文;xiaoqu-mcp 同时保存 SHA-256 哈希和 KMS/独立密钥加密密文。
- 已存在 Token 不回传明文,重复调用返回元信息并复用。
- Token 密文缺失或解密失败必须报错;不得回退到哈希值或公共 Token。
- 首次创建返回 Token 明文;xiaoqu-mcp 同时保存 SHA-256 哈希和 AES-GCM 加密密文(密钥由 `app.token-cipher-key` 派生)。
- 已存在 Token 解密回传明文(二次获取),重复调用幂等复用同一把 Token;历史无密文 Token 自动轮换(禁用旧把、签发新把)。
- Token 密文解密失败必须报错;不得回退到哈希值或公共 Token。
## 4. 推荐实现
@@ -0,0 +1,3 @@
-- 回滚 V3:删除 key_cipher 列。回滚后 ensure 对存量 Token 无法二次获取明文(回到只发新把模式)。
ALTER TABLE agent_api_key
DROP COLUMN key_cipher;
@@ -10,6 +10,7 @@ import me.iiv.agent.mapper.svc.AgentApiKeyMapper;
import me.iiv.agent.mapper.svc.AgentScopeMapper;
import me.iiv.agent.mapper.svc.TokenScopeMapper;
import me.iiv.agent.security.AuthService;
import me.iiv.agent.security.TokenCipher;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -25,18 +26,21 @@ public class AdminTokenService {
private final AgentApiKeyMapper tokenMapper;
private final TokenScopeMapper relationMapper;
private final AgentScopeMapper scopeMapper;
private final TokenCipher tokenCipher;
public AdminTokenService(UserMapper userMapper, AgentApiKeyMapper tokenMapper,
TokenScopeMapper relationMapper, AgentScopeMapper scopeMapper) {
TokenScopeMapper relationMapper, AgentScopeMapper scopeMapper,
TokenCipher tokenCipher) {
this.userMapper = userMapper;
this.tokenMapper = tokenMapper;
this.relationMapper = relationMapper;
this.scopeMapper = scopeMapper;
this.tokenCipher = tokenCipher;
}
/**
* 签发 Token:一作用域一 Token。选 N 个作用域生成 N 把,每把独立绑定单个作用域,
* 便于独立吊销、独立审计,明文逐把返回(仅本次可见)。
* 便于独立吊销、独立审计,明文逐把返回;服务端同时保存 SHA-256 哈希和 AES-GCM 密文(供二次获取)。
*/
@Transactional(transactionManager = "svcTransactionManager")
public List<IssuedToken> issue(Long userId, String memo, List<Long> scopeIds, Date expireTime, Long adminId) {
@@ -56,6 +60,7 @@ public class AdminTokenService {
token.setUserId(userId);
token.setKeyPrefix(plaintext.substring(0, 13));
token.setKeyHash(AuthService.sha256Hex(plaintext));
token.setKeyCipher(tokenCipher.encrypt(plaintext));
token.setMemo(trim(memo, 255));
token.setStatus((byte) 1);
token.setExpireTime(expireTime);
@@ -47,6 +47,11 @@ public class AgentApiKey implements Serializable {
*/
private String keyHash;
/**
* ak 明文 AES-GCM 加密密文(二次获取用),空=历史数据未存
*/
private String keyCipher;
/**
* 用途备注(如 Kimi 用 / Claude 用)
*/
@@ -21,8 +21,8 @@ public class OpenTokenController {
}
/**
* 确保某人员在某作用域下有有效 Token:存在则返回元信息(created=false,无明文),
* 不存在则自动创建永久 Token 并返回明文(created=true,仅此一次可见)。
* 确保某人员在某作用域下有有效 Token:存在则解密回传明文(created=false,二次获取),
* 不存在则自动创建永久 Token 并返回明文(created=true);历史无密文 Token 自动轮换后按创建返回。
*/
@PostMapping("/token/ensure")
public EnsureResult ensure(@RequestBody EnsureRequest body) {
@@ -9,6 +9,7 @@ import me.iiv.agent.entity.svc.AgentScope;
import me.iiv.agent.mapper.m0.UserMapper;
import me.iiv.agent.mapper.svc.AgentScopeMapper;
import me.iiv.agent.security.AuthContext;
import me.iiv.agent.security.TokenCipher;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -19,8 +20,9 @@ import java.util.Date;
/**
* Open API:按 手机号/邮箱 + 作用域编码 确保 Token 存在(get-or-create)。
* 存在有效 Token 只回元信息(服务端只存哈希,明文不可回吐);
* 不存在则自动创建永久 Token,明文仅本次返回。
* 存在有效 Token 时解密 key_cipher 回传明文(二次获取,2026-09-07 决策);
* 历史数据无密文的 Token 自动轮换(禁用旧把、签发新把);
* 不存在则自动创建永久 Token。两条路径均返回明文。
*/
@Service
public class OpenTokenService {
@@ -33,13 +35,16 @@ public class OpenTokenService {
private final AgentScopeMapper scopeMapper;
private final AdminTokenService tokenService;
private final AuditService auditService;
private final TokenCipher tokenCipher;
public OpenTokenService(UserMapper userMapper, AgentScopeMapper scopeMapper,
AdminTokenService tokenService, AuditService auditService) {
AdminTokenService tokenService, AuditService auditService,
TokenCipher tokenCipher) {
this.userMapper = userMapper;
this.scopeMapper = scopeMapper;
this.tokenService = tokenService;
this.auditService = auditService;
this.tokenCipher = tokenCipher;
}
@Transactional(transactionManager = "svcTransactionManager")
@@ -65,9 +70,21 @@ public class OpenTokenService {
AgentApiKey existing = tokenService.findActiveToken(user.getId(), scope.getId());
if (existing != null) {
audit(user, existing, body, "success", "已存在有效 Token,返回元信息", start);
return new OpenTokenController.EnsureResult(false, null, existing.getKeyPrefix(),
existing.getExpireTime(), user.getId(), user.getName(), scope.getCode());
if (StringUtils.hasText(existing.getKeyCipher())) {
String plaintext;
try {
plaintext = tokenCipher.decrypt(existing.getKeyCipher());
} catch (IllegalStateException e) {
audit(user, existing, body, "error", "Token 密文解密失败", start);
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Token 密文解密失败");
}
audit(user, existing, body, "success", "已存在有效 Token,解密返回明文", start);
return new OpenTokenController.EnsureResult(false, plaintext, existing.getKeyPrefix(),
existing.getExpireTime(), user.getId(), user.getName(), scope.getCode());
}
// 历史数据无密文:禁用旧把并签发新把(轮换),保证总能拿到明文
tokenService.status(existing.getId(), (byte) 0, SYSTEM_CREATOR_ID);
audit(user, existing, body, "success", "旧 Token 无密文,自动轮换", start);
}
AdminTokenService.IssuedToken issued = tokenService.issueSingle(user.getId(),
@@ -0,0 +1,78 @@
package me.iiv.agent.security;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
/**
* ak 明文加密器:AES-GCM,密钥由配置 app.token-cipher-key 经 SHA-256 派生(配置任意长度字符串即可)。
* 密文格式:Base64( iv(12B) ‖ cipher+tag ),供 Open API 二次获取明文时解密。
* 配置为空时加解密直接报错(视为功能未开启,不回退到任何弱行为)。
*/
@Component
public class TokenCipher {
private static final int IV_LEN = 12;
private static final int TAG_BITS = 128;
private final byte[] key;
private final SecureRandom random = new SecureRandom();
public TokenCipher(@Value("${app.token-cipher-key:}") String cipherKey) {
if (cipherKey == null || cipherKey.isBlank()) {
this.key = null;
} else {
try {
this.key = MessageDigest.getInstance("SHA-256")
.digest(cipherKey.trim().getBytes(StandardCharsets.UTF_8));
} catch (Exception e) {
throw new IllegalStateException("SHA-256 不可用", e);
}
}
}
public String encrypt(String plaintext) {
byte[] iv = new byte[IV_LEN];
random.nextBytes(iv);
try {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(requireKey(), "AES"),
new GCMParameterSpec(TAG_BITS, iv));
byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
byte[] packed = new byte[IV_LEN + encrypted.length];
System.arraycopy(iv, 0, packed, 0, IV_LEN);
System.arraycopy(encrypted, 0, packed, IV_LEN, encrypted.length);
return Base64.getEncoder().encodeToString(packed);
} catch (Exception e) {
throw new IllegalStateException("ak 加密失败", e);
}
}
public String decrypt(String packedBase64) {
try {
byte[] packed = Base64.getDecoder().decode(packedBase64);
byte[] iv = new byte[IV_LEN];
System.arraycopy(packed, 0, iv, 0, IV_LEN);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(requireKey(), "AES"),
new GCMParameterSpec(TAG_BITS, iv));
return new String(cipher.doFinal(packed, IV_LEN, packed.length - IV_LEN), StandardCharsets.UTF_8);
} catch (IllegalStateException e) {
throw e;
} catch (Exception e) {
throw new IllegalStateException("ak 密文解密失败", e);
}
}
private byte[] requireKey() {
if (key == null) throw new IllegalStateException("未配置 app.token-cipher-key,ak 加解密不可用");
return key;
}
}
@@ -5,6 +5,9 @@ app:
open:
# Open API(/api/open/**)服务间静态密钥,dev 明文;调用方放 header X-Open-Key
api-key: xqok_4b44012f12b9fa54d6ec11690e6da57563a75c891df60961
# ak 明文 AES-GCM 加密密钥(任意长度字符串,SHA-256 派生为密钥),供 ensure 二次获取明文;
# dev 明文(同库密码惯例),生产必须改用环境变量且与 dev 不同值
token-cipher-key: d70da46f5f0db2016d88d5ee4b1d6af35867b7700b97105fec9b3ad567d474a5
datasource:
m0: # 权限库 xiaoqu_comples_d(user/agent/authority/propery/agent_manage_district_auth)
driver-class-name: com.mysql.cj.jdbc.Driver
@@ -0,0 +1,3 @@
-- ak 二次获取(2026-09-07):签发时保存 AES-GCM 加密密文,ensure 已存在 Token 时解密回传明文
ALTER TABLE agent_api_key
ADD COLUMN key_cipher VARCHAR(512) NULL COMMENT 'ak 明文 AES-GCM 加密密文(Base64: iv‖cipher‖tag),空=历史数据未存' AFTER key_hash;
@@ -1,2 +1,3 @@
f8ec43a4fda81382d5675bc59fe7f4339f2e38a5b4241c260c3569e33b43a26d V1__init.sql
0ad892601e891a18712b1b6a613c392c94e63492dc14f4f779375c092a3acab6 V2__p4_admin.sql
0b6e407106156911f5fe83a13b342eeec5986e2e1e5b37b6797431a8720e1332 V3__token_cipher.sql
@@ -9,6 +9,7 @@ import me.iiv.agent.mapper.svc.AgentApiKeyMapper;
import me.iiv.agent.mapper.svc.AgentScopeMapper;
import me.iiv.agent.mapper.svc.TokenScopeMapper;
import me.iiv.agent.security.AuthService;
import me.iiv.agent.security.TokenCipher;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
@@ -33,6 +34,7 @@ class AdminTokenServiceTest {
@Mock AgentApiKeyMapper tokenMapper;
@Mock TokenScopeMapper tokenScopeMapper;
@Mock AgentScopeMapper scopeMapper;
@Mock TokenCipher tokenCipher;
private User stubUser() {
User user = new User();
@@ -43,11 +45,13 @@ class AdminTokenServiceTest {
}
private AdminTokenService service() {
return new AdminTokenService(userMapper, tokenMapper, tokenScopeMapper, scopeMapper);
org.mockito.Mockito.lenient().when(tokenCipher.encrypt(any()))
.thenAnswer(invocation -> "cipher:" + invocation.getArgument(0));
return new AdminTokenService(userMapper, tokenMapper, tokenScopeMapper, scopeMapper, tokenCipher);
}
@Test
void issueReturnsPlaintextOnceAndStoresOnlyHash() {
void issueReturnsPlaintextAndStoresHashAndCipher() {
stubUser();
AgentScope scope = new AgentScope();
scope.setId(3L);
@@ -68,6 +72,7 @@ class AdminTokenServiceTest {
assertTrue(issued.get(0).plaintext().matches("xqak_[0-9a-f]{48}"));
assertEquals(AuthService.sha256Hex(issued.get(0).plaintext()), stored.getKeyHash());
assertNotEquals(issued.get(0).plaintext(), stored.getKeyHash());
assertEquals("cipher:" + issued.get(0).plaintext(), stored.getKeyCipher());
assertEquals(11L, issued.get(0).token().id());
}
@@ -19,7 +19,7 @@ class FlywayMigrationTest {
flyway.migrate();
assertEquals("2", flyway.info().current().getVersion().getVersion());
assertEquals("3", flyway.info().current().getVersion().getVersion());
assertEquals(255, passwordHashLength(dataSource));
}
@@ -28,12 +28,13 @@ class FlywayMigrationTest {
JdbcDataSource dataSource = dataSource("legacy");
try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) {
statement.execute("CREATE TABLE admin_user (id BIGINT AUTO_INCREMENT PRIMARY KEY, password_hash CHAR(64) NOT NULL)");
statement.execute("CREATE TABLE agent_api_key (id BIGINT AUTO_INCREMENT PRIMARY KEY, key_hash CHAR(64) NOT NULL)");
}
Flyway flyway = flyway(dataSource, true);
flyway.migrate();
assertEquals("2", flyway.info().current().getVersion().getVersion());
assertEquals("3", flyway.info().current().getVersion().getVersion());
assertEquals(255, passwordHashLength(dataSource));
}
@@ -7,6 +7,7 @@ import me.iiv.agent.entity.svc.AgentApiKey;
import me.iiv.agent.entity.svc.AgentScope;
import me.iiv.agent.mapper.m0.UserMapper;
import me.iiv.agent.mapper.svc.AgentScopeMapper;
import me.iiv.agent.security.TokenCipher;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -37,6 +38,7 @@ class OpenTokenServiceTest {
@Mock AgentScopeMapper scopeMapper;
@Mock AdminTokenService tokenService;
@Mock AuditService auditService;
@Mock TokenCipher tokenCipher;
OpenTokenService service;
AgentScope scope;
@@ -44,7 +46,7 @@ class OpenTokenServiceTest {
@BeforeEach
void setUp() {
service = new OpenTokenService(userMapper, scopeMapper, tokenService, auditService);
service = new OpenTokenService(userMapper, scopeMapper, tokenService, auditService, tokenCipher);
scope = new AgentScope();
scope.setId(3L);
scope.setCode("operation");
@@ -62,24 +64,62 @@ class OpenTokenServiceTest {
}
@Test
void existingActiveTokenReturnsMetadataWithoutPlaintext() {
void existingTokenWithCipherReturnsDecryptedPlaintext() {
stubScopeAndUser();
AgentApiKey existing = new AgentApiKey();
existing.setId(11L);
existing.setKeyPrefix("xqak_abcd1234");
existing.setKeyCipher("packed-cipher");
when(tokenService.findActiveToken(7L, 3L)).thenReturn(existing);
when(tokenCipher.decrypt("packed-cipher")).thenReturn("xqak_abcd1234ffff");
OpenTokenController.EnsureResult result = service.ensure(
new OpenTokenController.EnsureRequest("13800001111", null, "operation"));
assertFalse(result.created());
assertNull(result.token());
assertEquals("xqak_abcd1234ffff", result.token());
assertEquals("xqak_abcd1234", result.keyPrefix());
assertEquals(7L, result.userId());
org.mockito.Mockito.verify(tokenService, never())
.issueSingle(anyLong(), anyString(), anyLong(), isNull(), anyLong());
}
@Test
void existingTokenWithoutCipherRotatesAndReturnsNewPlaintext() {
stubScopeAndUser();
AgentApiKey existing = new AgentApiKey();
existing.setId(11L);
existing.setKeyPrefix("xqak_abcd1234");
when(tokenService.findActiveToken(7L, 3L)).thenReturn(existing);
AdminTokenService.TokenView view = new AdminTokenService.TokenView(
12L, "xqak_efgh5678", "open-api 自动签发(operation)", (byte) 1,
null, null, List.of(3L), List.of("运营"));
when(tokenService.issueSingle(eq(7L), anyString(), eq(3L), isNull(), eq(0L)))
.thenReturn(new AdminTokenService.IssuedToken("xqak_" + "b".repeat(48), view));
OpenTokenController.EnsureResult result = service.ensure(
new OpenTokenController.EnsureRequest("13800001111", null, "operation"));
org.mockito.Mockito.verify(tokenService).status(11L, (byte) 0, 0L);
assertTrue(result.created());
assertTrue(result.token().matches("xqak_[0-9a-f]{48}"));
}
@Test
void decryptFailureReturns500() {
stubScopeAndUser();
AgentApiKey existing = new AgentApiKey();
existing.setId(11L);
existing.setKeyPrefix("xqak_abcd1234");
existing.setKeyCipher("bad-cipher");
when(tokenService.findActiveToken(7L, 3L)).thenReturn(existing);
when(tokenCipher.decrypt("bad-cipher")).thenThrow(new IllegalStateException("ak 密文解密失败"));
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.ensure(
new OpenTokenController.EnsureRequest("13800001111", null, "operation")));
assertEquals(500, error.getStatusCode().value());
}
@Test
void missingTokenCreatesPermanentOneAndReturnsPlaintext() {
stubScopeAndUser();
@@ -0,0 +1,50 @@
package me.iiv.agent.security;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class TokenCipherTest {
private final TokenCipher cipher = new TokenCipher("test-cipher-key");
@Test
void encryptDecryptRoundTrip() {
String plaintext = "xqak_25939fab31f812eb4e566197f53f16482d3e1f588b8bcaca";
String packed = cipher.encrypt(plaintext);
assertNotEquals(plaintext, packed);
assertEquals(plaintext, cipher.decrypt(packed));
}
@Test
void samePlaintextEncryptsDifferentlyEachTime() {
String plaintext = "xqak_" + "a".repeat(48);
assertNotEquals(cipher.encrypt(plaintext), cipher.encrypt(plaintext));
}
@Test
void wrongKeyCannotDecrypt() {
String packed = cipher.encrypt("xqak_secret");
TokenCipher other = new TokenCipher("another-key");
assertThrows(IllegalStateException.class, () -> other.decrypt(packed));
}
@Test
void tamperedCipherRejected() {
String packed = cipher.encrypt("xqak_secret");
byte[] raw = java.util.Base64.getDecoder().decode(packed);
raw[raw.length - 1] ^= 0x01;
String tampered = java.util.Base64.getEncoder().encodeToString(raw);
assertThrows(IllegalStateException.class, () -> cipher.decrypt(tampered));
}
@Test
void blankKeyDisablesCipher() {
TokenCipher disabled = new TokenCipher(" ");
assertThrows(IllegalStateException.class, () -> disabled.encrypt("xqak_x"));
assertThrows(IllegalStateException.class, () -> disabled.decrypt("AAAA"));
}
}