feat(mcp): 服务 Token 生命周期与开放通道域绑定 (REQ-20260911-0014) #11
@@ -159,6 +159,15 @@ xiaoqu-ai Android/iOS/Web ─► Gateway/Agent ─────┤
|
||||
- 已存在但**无密文**(V3 之前的历史数据)→ 自动轮换:禁用旧把、签发新把,按 `created=true` 返回新明文;解密失败 → 500,不回退。
|
||||
- **不存在** → 自动创建永久 Token(一作用域一 Token,memo 标记 `open-api 自动签发`),`created=true` 返回明文。
|
||||
- 全程写 `audit_log`(`tool_name=open.token.ensure`,`client_type=open-api`,参数摘要脱敏,不记录明文)。
|
||||
- **服务 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**。
|
||||
- 吊销:后台 `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`。
|
||||
- **手机号变更调研结论(REQ-20260911-0014 范围 5)**:`mobile` 是 `m0.user` 的普通属性,变更手机号不改 `user.id`;Token 与行级权限均按 `user.id` 生效,因此手机号变更后仍保持同一个 userId,无需额外处理。
|
||||
- **5629 冒烟清单**:① scoped key 跨域 ensure → 403;② 旧 `app.open.api-key` 签发 service → 403;③ `principalType=service` + `scopeCode=sync` ensure 成功,拿 ak 调 `integration.context.get` 返回 `principal_type=tenant_service`;④ 后台吊销该 Token 后立即 401;⑤ rotate 后旧 ak 401、新明文可用;⑥ 人员停用后等待 >60s(本地缓存 TTL)再调 → 401/UNAUTHENTICATED;⑦ user ensure `scopeCode=sync` → 403。
|
||||
|
||||
## 6. 技术栈与工程结构
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- 回滚 V4:删除 principal_type / subject_name 列,user_id 恢复 NOT NULL。
|
||||
-- 回滚后服务 Token 无法被识别(AuthService 不再区分主体类型),存量 principal_type='service'
|
||||
-- 的 Token 会因 user_id 为空无法解析人员而 401,等于全部失效;
|
||||
-- 回滚前应先禁用或删除服务 Token(UPDATE agent_api_key SET status=0 WHERE principal_type='service')。
|
||||
-- 残余 user_id 为 NULL 的行回填 0 以通过 NOT NULL 约束(这些行已不再参与鉴权)。
|
||||
UPDATE agent_api_key SET user_id = 0 WHERE user_id IS NULL;
|
||||
|
||||
ALTER TABLE agent_api_key
|
||||
DROP COLUMN principal_type;
|
||||
|
||||
ALTER TABLE agent_api_key
|
||||
DROP COLUMN subject_name;
|
||||
|
||||
ALTER TABLE agent_api_key
|
||||
MODIFY COLUMN user_id BIGINT NOT NULL COMMENT '所属人(m0.user.id)';
|
||||
@@ -33,6 +33,20 @@ SELECT s.id, t.id, 0 FROM agent_scope s JOIN tool t
|
||||
WHERE s.code = 'hardware' AND t.name IN ('hardware.list.query', 'hardware.detail.query')
|
||||
AND NOT EXISTS (SELECT 1 FROM scope_tool st WHERE st.scope_id = s.id AND st.tool_id = t.id);
|
||||
|
||||
-- 作用域:sync(SYNC 增量同步,REQ-20260911-0014 预留;SYNC 工具本身是后续需求 5628)
|
||||
-- 个人 Token 禁调 SYNC 工具靠 token_scope 白名单天然生效:个人 Token 不绑定本作用域即 PERMISSION_DENIED,
|
||||
-- 无需任何拦截代码。5628 注册 *.changes.list 工具后,按上面同款 INSERT 把工具绑进本作用域,
|
||||
-- 再只为服务 Token(principal_type=service)勾选 sync 作用域。
|
||||
INSERT INTO agent_scope (name, code, memo, status, create_id)
|
||||
SELECT '数据同步', 'sync', 'SYNC 增量同步作用域(5628 注册 *.changes.list 工具后绑定,仅服务 Token 使用)', 1, 0
|
||||
WHERE NOT EXISTS (SELECT 1 FROM agent_scope WHERE code = 'sync');
|
||||
|
||||
-- sync 作用域-工具:integration.context.get(服务 Token 自检入口;*.changes.list 由 5628 注册后追加)
|
||||
INSERT INTO scope_tool (scope_id, tool_id, create_id)
|
||||
SELECT s.id, t.id, 0 FROM agent_scope s JOIN tool t
|
||||
WHERE s.code = 'sync' AND t.name IN ('integration.context.get')
|
||||
AND NOT EXISTS (SELECT 1 FROM scope_tool st WHERE st.scope_id = s.id AND st.tool_id = t.id);
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- 校验
|
||||
|
||||
@@ -156,6 +156,12 @@ public class AdminConsoleController {
|
||||
tokenService.delete(id, adminId(session));
|
||||
}
|
||||
|
||||
/** 轮换:禁用旧把并按原主体/作用域签新把,明文仅在本次响应返回(REQ-20260911-0014)。 */
|
||||
@PostMapping("/tokens/{id}/rotate")
|
||||
public List<AdminTokenService.IssuedToken> rotateToken(@PathVariable Long id, HttpSession session) {
|
||||
return tokenService.rotate(id, adminId(session));
|
||||
}
|
||||
|
||||
@GetMapping("/tools")
|
||||
public List<ToolView> tools(@RequestParam(defaultValue = "") String category,
|
||||
@RequestParam(defaultValue = "") String kind,
|
||||
|
||||
@@ -55,23 +55,66 @@ public class AdminTokenService {
|
||||
|
||||
/** 签发一把绑定单个作用域的 Token;人员/作用域合法性由调用方保证。 */
|
||||
public IssuedToken issueSingle(Long userId, String memo, Long scopeId, Date expireTime, Long creatorId) {
|
||||
String plaintext = "xqak_" + AdminAuthController.randomHex(24);
|
||||
AgentApiKey token = new AgentApiKey();
|
||||
AgentApiKey token = newToken(memo, expireTime, creatorId);
|
||||
token.setUserId(userId);
|
||||
token.setKeyPrefix(plaintext.substring(0, 13));
|
||||
token.setKeyHash(AuthService.sha256Hex(plaintext));
|
||||
token.setKeyCipher(tokenCipher.encrypt(plaintext));
|
||||
token.setPrincipalType(AgentApiKey.PRINCIPAL_TYPE_USER);
|
||||
return insert(token, scopeId, creatorId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 签发服务 Token(principal_type=service):不绑定人员,行级权限不适用,
|
||||
* 数据范围由绑定的作用域决定(scope 粒度)。subjectName 标识服务主体,供 get-or-create 查找。
|
||||
*/
|
||||
public IssuedToken issueServiceToken(String serviceName, String memo, Long scopeId, Date expireTime, Long creatorId) {
|
||||
if (serviceName == null || !serviceName.trim().matches("[a-z][a-z0-9_-]{0,63}")) bad("服务主体标识格式无效");
|
||||
AgentApiKey token = newToken(memo, expireTime, creatorId);
|
||||
token.setPrincipalType(AgentApiKey.PRINCIPAL_TYPE_SERVICE);
|
||||
token.setSubjectName(serviceName.trim());
|
||||
return insert(token, scopeId, creatorId);
|
||||
}
|
||||
|
||||
private AgentApiKey newToken(String memo, Date expireTime, Long creatorId) {
|
||||
AgentApiKey token = new AgentApiKey();
|
||||
token.setMemo(trim(memo, 255));
|
||||
token.setStatus((byte) 1);
|
||||
token.setExpireTime(expireTime);
|
||||
token.setCreateId(creatorId);
|
||||
token.setModifyId(creatorId);
|
||||
token.setSoftDelete((byte) 0);
|
||||
return token;
|
||||
}
|
||||
|
||||
private IssuedToken insert(AgentApiKey token, Long scopeId, Long creatorId) {
|
||||
String plaintext = "xqak_" + AdminAuthController.randomHex(24);
|
||||
token.setKeyPrefix(plaintext.substring(0, 13));
|
||||
token.setKeyHash(AuthService.sha256Hex(plaintext));
|
||||
token.setKeyCipher(tokenCipher.encrypt(plaintext));
|
||||
tokenMapper.insert(token);
|
||||
replaceScopes(token.getId(), List.of(scopeId), creatorId);
|
||||
return new IssuedToken(plaintext, view(token));
|
||||
}
|
||||
|
||||
/** 轮换:禁用旧把(吊销立即生效,AkAuthFilter 校验 status),按原主体/作用域/过期时间签新把,明文逐把返回。 */
|
||||
@Transactional(transactionManager = "svcTransactionManager")
|
||||
public List<IssuedToken> rotate(Long tokenId, Long operatorId) {
|
||||
AgentApiKey current = activeToken(tokenId);
|
||||
List<Long> scopeIds = relationMapper.selectList(new LambdaQueryWrapper<TokenScope>()
|
||||
.eq(TokenScope::getTokenId, tokenId).eq(TokenScope::getSoftDelete, 0))
|
||||
.stream().map(TokenScope::getScopeId).toList();
|
||||
if (scopeIds.isEmpty()) bad("Token 未绑定作用域,无法轮换");
|
||||
current.setStatus((byte) 0);
|
||||
current.setModifyId(operatorId);
|
||||
tokenMapper.updateById(current);
|
||||
boolean service = AgentApiKey.PRINCIPAL_TYPE_SERVICE.equals(current.getPrincipalType());
|
||||
return scopeIds.stream()
|
||||
.map(scopeId -> service
|
||||
? issueServiceToken(current.getSubjectName(), current.getMemo(), scopeId,
|
||||
current.getExpireTime(), operatorId)
|
||||
: issueSingle(current.getUserId(), current.getMemo(), scopeId,
|
||||
current.getExpireTime(), operatorId))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** 查找某人员在某作用域下的有效 Token(正常状态且未过期),取最新一把;无则 null。 */
|
||||
public AgentApiKey findActiveToken(Long userId, Long scopeId) {
|
||||
List<Long> tokenIds = relationMapper.selectList(new LambdaQueryWrapper<TokenScope>()
|
||||
@@ -90,6 +133,25 @@ public class AdminTokenService {
|
||||
.stream().findFirst().orElse(null);
|
||||
}
|
||||
|
||||
/** 查找某服务主体在某作用域下的有效 Token(正常状态且未过期),取最新一把;无则 null。 */
|
||||
public AgentApiKey findActiveServiceToken(String serviceName, Long scopeId) {
|
||||
List<Long> tokenIds = relationMapper.selectList(new LambdaQueryWrapper<TokenScope>()
|
||||
.eq(TokenScope::getScopeId, scopeId).eq(TokenScope::getSoftDelete, 0))
|
||||
.stream().map(TokenScope::getTokenId).toList();
|
||||
if (tokenIds.isEmpty()) return null;
|
||||
return tokenMapper.selectList(new LambdaQueryWrapper<AgentApiKey>()
|
||||
.in(AgentApiKey::getId, tokenIds)
|
||||
.eq(AgentApiKey::getPrincipalType, AgentApiKey.PRINCIPAL_TYPE_SERVICE)
|
||||
.eq(AgentApiKey::getSubjectName, serviceName)
|
||||
.eq(AgentApiKey::getStatus, 1)
|
||||
.eq(AgentApiKey::getSoftDelete, 0)
|
||||
.and(w -> w.isNull(AgentApiKey::getExpireTime)
|
||||
.or().gt(AgentApiKey::getExpireTime, new Date()))
|
||||
.orderByDesc(AgentApiKey::getCreateTime)
|
||||
.last("LIMIT 1"))
|
||||
.stream().findFirst().orElse(null);
|
||||
}
|
||||
|
||||
@Transactional(transactionManager = "svcTransactionManager")
|
||||
public void update(Long tokenId, String memo, List<Long> scopeIds, Date expireTime, Long adminId) {
|
||||
AgentApiKey current = activeToken(tokenId);
|
||||
@@ -133,6 +195,7 @@ public class AdminTokenService {
|
||||
new LambdaQueryWrapper<AgentScope>().in(AgentScope::getId, scopeIds))
|
||||
.stream().map(AgentScope::getName).toList();
|
||||
return new TokenView(token.getId(), token.getKeyPrefix(), token.getMemo(), token.getStatus(),
|
||||
token.getPrincipalType(), token.getSubjectName(),
|
||||
token.getExpireTime(), token.getLastUsedTime(), scopeIds, scopeNames);
|
||||
}
|
||||
|
||||
@@ -187,7 +250,8 @@ public class AdminTokenService {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
|
||||
}
|
||||
|
||||
public record TokenView(Long id, String keyPrefix, String memo, Byte status, Date expireTime,
|
||||
public record TokenView(Long id, String keyPrefix, String memo, Byte status,
|
||||
String principalType, String subjectName, Date expireTime,
|
||||
Date lastUsedTime, List<Long> scopeIds, List<String> scopeNames) {}
|
||||
public record IssuedToken(String plaintext, TokenView token) {}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,12 @@ public class AgentApiKey implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** 主体类型:用户委托(绑定 user_id,行级权限跟随人员) */
|
||||
public static final String PRINCIPAL_TYPE_USER = "user";
|
||||
|
||||
/** 主体类型:租户服务(Open API 服务密钥签发,不绑定人员) */
|
||||
public static final String PRINCIPAL_TYPE_SERVICE = "service";
|
||||
|
||||
/**
|
||||
* 主键
|
||||
*/
|
||||
@@ -33,10 +39,20 @@ public class AgentApiKey implements Serializable {
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 所属人(m0.user.id)
|
||||
* 所属人(m0.user.id);principal_type=service 时为 NULL
|
||||
*/
|
||||
private Long userId;
|
||||
|
||||
/**
|
||||
* 主体类型:user=用户委托 service=租户服务
|
||||
*/
|
||||
private String principalType;
|
||||
|
||||
/**
|
||||
* 服务主体标识(principal_type=service 时必填,如 xiaoqu-ai)
|
||||
*/
|
||||
private String subjectName;
|
||||
|
||||
/**
|
||||
* ak 前缀(展示用,如 xqak_abcd1234)
|
||||
*/
|
||||
|
||||
@@ -69,16 +69,13 @@ public class IntegrationContextService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主体类型判定(5629 切换点):agent_api_key 后续会新增 principal_type 列
|
||||
* (REQ-20260911-0013 的 Flyway),届时只需改本方法为读列。
|
||||
* 当前按现有数据推导:token 绑定了用户(user_id 非空且人已解析)即 user_delegated,
|
||||
* 否则 tenant_service(存量 ak 均为 ensure 签发的用户 Token,不会走到该分支)。
|
||||
*/
|
||||
/** 主体类型按 agent_api_key.principal_type 列判定(REQ-20260911-0014 切换):service → tenant_service。 */
|
||||
String resolvePrincipalType(AuthContext ctx) {
|
||||
AgentApiKey token = ctx.getToken();
|
||||
boolean hasUser = ctx.getUser() != null || (token != null && token.getUserId() != null);
|
||||
return hasUser ? PRINCIPAL_USER_DELEGATED : PRINCIPAL_TENANT_SERVICE;
|
||||
if (token != null && AgentApiKey.PRINCIPAL_TYPE_SERVICE.equals(token.getPrincipalType())) {
|
||||
return PRINCIPAL_TENANT_SERVICE;
|
||||
}
|
||||
return PRINCIPAL_USER_DELEGATED;
|
||||
}
|
||||
|
||||
/** 数据权限版本:token_scope 绑定集合的最大 modify_time(空取 create_time)转 epoch 毫秒;无绑定为 0 */
|
||||
@@ -110,11 +107,16 @@ public class IntegrationContextService {
|
||||
return scopes.stream().map(AgentScope::getCode).sorted().collect(Collectors.joining(","));
|
||||
}
|
||||
|
||||
/** 用户委托 → 用户 ID;服务主体(无人员绑定)→ token 自身 ID 作为服务主体标识 */
|
||||
private Long subjectId(AuthContext ctx) {
|
||||
if (ctx.getUser() != null) {
|
||||
return ctx.getUser().getId();
|
||||
}
|
||||
return ctx.getToken() == null ? null : ctx.getToken().getUserId();
|
||||
AgentApiKey token = ctx.getToken();
|
||||
if (token == null) {
|
||||
return null;
|
||||
}
|
||||
return token.getUserId() != null ? token.getUserId() : token.getId();
|
||||
}
|
||||
|
||||
private List<AgentScope> enabledScopes(List<TokenScope> bindings) {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package me.iiv.agent.open;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* /api/open 服务密钥配置(REQ-20260911-0014)。
|
||||
* 旧配置 api-key 为单个不绑定数据域的密钥,仅保留给存量用户 Token ensure;
|
||||
* 新接入方(尤其签发 service 类型 Token)必须配置 clients:密钥 → 名称 + 允许的作用域编码,
|
||||
* 鉴权时按密钥确定可调用的数据域,越域请求 403。
|
||||
*/
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "app.open")
|
||||
public class OpenClientProperties {
|
||||
|
||||
/** 兼容旧配置:单个静态密钥,不限作用域(视为隐式 client,name=legacy) */
|
||||
private String apiKey;
|
||||
|
||||
/** 服务密钥清单 */
|
||||
private List<Client> clients = new ArrayList<>();
|
||||
|
||||
public String getApiKey() {
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
public void setApiKey(String apiKey) {
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
public List<Client> getClients() {
|
||||
return clients;
|
||||
}
|
||||
|
||||
public void setClients(List<Client> clients) {
|
||||
this.clients = clients;
|
||||
}
|
||||
|
||||
public static class Client {
|
||||
|
||||
/** 密钥明文(X-Open-Key 头值) */
|
||||
private String key;
|
||||
|
||||
/** 调用方名称(审计/排查用) */
|
||||
private String name;
|
||||
|
||||
/** 允许操作的作用域编码;空列表 = 未绑定数据域,不能签发/操作任何 scope */
|
||||
private List<String> scopes = new ArrayList<>();
|
||||
|
||||
public String getKey() {
|
||||
return key;
|
||||
}
|
||||
|
||||
public void setKey(String key) {
|
||||
this.key = key;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public List<String> getScopes() {
|
||||
return scopes;
|
||||
}
|
||||
|
||||
public void setScopes(List<String> scopes) {
|
||||
this.scopes = scopes;
|
||||
}
|
||||
}
|
||||
|
||||
/** 鉴权通过的调用方;scopes 为 null 表示旧密钥(不限作用域,但不可签发 service Token) */
|
||||
public record ResolvedClient(String name, List<String> scopes, boolean legacy) {}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
@@ -13,16 +12,24 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
/**
|
||||
* /api/open 通道服务间鉴权:X-Open-Key 静态密钥(配置 app.open.api-key)。
|
||||
* 与 ak 体系、后台 session 体系完全分离;配置为空 = 接口关闭。
|
||||
* /api/open 通道服务间鉴权:X-Open-Key 服务密钥(配置 app.open,见 OpenClientProperties)。
|
||||
* 鉴权通过后将调用方(名称 + 绑定的作用域编码)写入请求属性 {@link #CLIENT_ATTRIBUTE},
|
||||
* 供 ensure 校验"密钥 → 数据域"绑定关系。与 ak 体系、后台 session 体系完全分离;
|
||||
* 未配置任何密钥 = 接口关闭。
|
||||
*/
|
||||
@Component
|
||||
public class OpenKeyFilter extends OncePerRequestFilter {
|
||||
|
||||
static final String OPEN_KEY_HEADER = "X-Open-Key";
|
||||
|
||||
@Value("${app.open.api-key:}")
|
||||
private String apiKey;
|
||||
/** 请求属性名:值为 OpenClientProperties.ResolvedClient */
|
||||
static final String CLIENT_ATTRIBUTE = "me.iiv.agent.open.client";
|
||||
|
||||
private final OpenClientProperties properties;
|
||||
|
||||
public OpenKeyFilter(OpenClientProperties properties) {
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
@@ -33,19 +40,38 @@ public class OpenKeyFilter extends OncePerRequestFilter {
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain chain) throws ServletException, IOException {
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
if (apiKey == null || apiKey.isBlank()) {
|
||||
error(response, 403, "Open API 未启用");
|
||||
return;
|
||||
}
|
||||
String provided = request.getHeader(OPEN_KEY_HEADER);
|
||||
if (provided == null || !MessageDigest.isEqual(
|
||||
apiKey.getBytes(StandardCharsets.UTF_8), provided.getBytes(StandardCharsets.UTF_8))) {
|
||||
error(response, 401, "服务间密钥无效");
|
||||
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();
|
||||
error(response, configured ? 401 : 403, configured ? "服务间密钥无效" : "Open API 未启用");
|
||||
return;
|
||||
}
|
||||
request.setAttribute(CLIENT_ATTRIBUTE, client);
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
|
||||
/** 命中 clients 清单返回绑定的作用域;命中旧 api-key 返回不绑域的 legacy client;均未命中返回 null */
|
||||
private OpenClientProperties.ResolvedClient resolve(String provided) {
|
||||
byte[] providedBytes = provided.getBytes(StandardCharsets.UTF_8);
|
||||
if (properties.getClients() != null) {
|
||||
for (OpenClientProperties.Client client : properties.getClients()) {
|
||||
if (client.getKey() != null && !client.getKey().isBlank()
|
||||
&& MessageDigest.isEqual(client.getKey().getBytes(StandardCharsets.UTF_8), providedBytes)) {
|
||||
return new OpenClientProperties.ResolvedClient(
|
||||
client.getName(), client.getScopes(), false);
|
||||
}
|
||||
}
|
||||
}
|
||||
String legacy = properties.getApiKey();
|
||||
if (legacy != null && !legacy.isBlank()
|
||||
&& MessageDigest.isEqual(legacy.getBytes(StandardCharsets.UTF_8), providedBytes)) {
|
||||
return new OpenClientProperties.ResolvedClient("legacy", null, true);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void error(HttpServletResponse response, int status, String message) throws IOException {
|
||||
response.setStatus(status);
|
||||
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package me.iiv.agent.open;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@@ -21,15 +22,21 @@ public class OpenTokenController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保某人员在某作用域下有有效 Token:存在则解密回传明文(created=false,二次获取),
|
||||
* 确保某主体在某作用域下有有效 Token:存在则解密回传明文(created=false,二次获取),
|
||||
* 不存在则自动创建永久 Token 并返回明文(created=true);历史无密文 Token 自动轮换后按创建返回。
|
||||
* 主体类型:缺省 user(按手机号/邮箱匹配人员);principalType=service 时按 serviceName 签发
|
||||
* 服务 Token(REQ-20260911-0014),要求所用服务密钥已在配置中绑定该作用域。
|
||||
*/
|
||||
@PostMapping("/token/ensure")
|
||||
public EnsureResult ensure(@RequestBody EnsureRequest body) {
|
||||
return openTokenService.ensure(body);
|
||||
public EnsureResult ensure(@RequestBody EnsureRequest body, HttpServletRequest request) {
|
||||
OpenClientProperties.ResolvedClient client =
|
||||
(OpenClientProperties.ResolvedClient) request.getAttribute(OpenKeyFilter.CLIENT_ATTRIBUTE);
|
||||
return openTokenService.ensure(body, client);
|
||||
}
|
||||
|
||||
public record EnsureRequest(String mobile, String email, String scopeCode) {}
|
||||
public record EnsureRequest(String mobile, String email, String scopeCode,
|
||||
String principalType, String serviceName) {}
|
||||
public record EnsureResult(boolean created, String token, String keyPrefix, Date expireTime,
|
||||
Long userId, String userName, String scopeCode) {}
|
||||
Long userId, String userName, String scopeCode,
|
||||
String principalType, String subjectName) {}
|
||||
}
|
||||
|
||||
@@ -19,10 +19,12 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* Open API:按 手机号/邮箱 + 作用域编码 确保 Token 存在(get-or-create)。
|
||||
* Open API:按 手机号/邮箱(user)或 服务主体标识(service)+ 作用域编码 确保 Token 存在(get-or-create)。
|
||||
* 存在有效 Token 时解密 key_cipher 回传明文(二次获取,2026-09-07 决策);
|
||||
* 历史数据无密文的 Token 自动轮换(禁用旧把、签发新把);
|
||||
* 不存在则自动创建永久 Token。两条路径均返回明文。
|
||||
* service 类型(REQ-20260911-0014):要求服务密钥已在配置中绑定该作用域(app.open.clients),
|
||||
* 旧版不限域密钥(app.open.api-key)不可签发服务 Token。
|
||||
*/
|
||||
@Service
|
||||
public class OpenTokenService {
|
||||
@@ -30,6 +32,8 @@ public class OpenTokenService {
|
||||
/** 系统签发(Open API 自动创建)的 createId 约定值 */
|
||||
static final long SYSTEM_CREATOR_ID = 0L;
|
||||
static final String AUDIT_TOOL = "open.token.ensure";
|
||||
/** SYNC 作用域编码:服务 Token 只能绑定该作用域,用户 Token 不能绑定(REQ-20260911-0014) */
|
||||
static final String SYNC_SCOPE_CODE = "sync";
|
||||
|
||||
private final UserMapper userMapper;
|
||||
private final AgentScopeMapper scopeMapper;
|
||||
@@ -48,10 +52,14 @@ public class OpenTokenService {
|
||||
}
|
||||
|
||||
@Transactional(transactionManager = "svcTransactionManager")
|
||||
public OpenTokenController.EnsureResult ensure(OpenTokenController.EnsureRequest body) {
|
||||
public OpenTokenController.EnsureResult ensure(OpenTokenController.EnsureRequest body,
|
||||
OpenClientProperties.ResolvedClient client) {
|
||||
long start = System.currentTimeMillis();
|
||||
if (body == null) bad(HttpStatus.BAD_REQUEST, "请求内容不能为空");
|
||||
if (!StringUtils.hasText(body.mobile()) && !StringUtils.hasText(body.email())) {
|
||||
boolean service = AgentApiKey.PRINCIPAL_TYPE_SERVICE.equals(body.principalType());
|
||||
if (service) {
|
||||
if (!StringUtils.hasText(body.serviceName())) bad(HttpStatus.BAD_REQUEST, "服务主体标识不能为空");
|
||||
} else if (!StringUtils.hasText(body.mobile()) && !StringUtils.hasText(body.email())) {
|
||||
bad(HttpStatus.BAD_REQUEST, "手机号和邮箱至少传一个");
|
||||
}
|
||||
if (!StringUtils.hasText(body.scopeCode())) bad(HttpStatus.BAD_REQUEST, "作用域编码不能为空");
|
||||
@@ -61,7 +69,34 @@ public class OpenTokenService {
|
||||
if (scope == null || !Byte.valueOf((byte) 1).equals(scope.getStatus())) {
|
||||
bad(HttpStatus.BAD_REQUEST, "作用域不存在或已停用");
|
||||
}
|
||||
checkScopeBinding(client, scope, service);
|
||||
if (service && !SYNC_SCOPE_CODE.equals(scope.getCode())) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "服务 Token 只能绑定 SYNC 作用域");
|
||||
}
|
||||
if (!service && SYNC_SCOPE_CODE.equals(scope.getCode())) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "SYNC 作用域仅允许服务 Token");
|
||||
}
|
||||
return service ? ensureService(body, scope, start) : ensureUser(body, scope, start);
|
||||
}
|
||||
|
||||
/** 密钥 → 数据域绑定校验:绑定了作用域清单的密钥只能操作清单内的 scope;service 签发不允许用旧版不限域密钥 */
|
||||
private void checkScopeBinding(OpenClientProperties.ResolvedClient client, AgentScope scope, boolean service) {
|
||||
if (client == null) {
|
||||
// OpenKeyFilter 已拦截无效密钥,走到这里说明上下文丢失,按未授权处理
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "服务间密钥无效");
|
||||
}
|
||||
if (client.scopes() != null && !client.scopes().contains(scope.getCode())) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||
"该服务密钥未绑定作用域 [" + scope.getCode() + "]");
|
||||
}
|
||||
if (service && client.legacy()) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||
"签发服务 Token 必须在 app.open.clients 中配置绑定了作用域的服务密钥");
|
||||
}
|
||||
}
|
||||
|
||||
private OpenTokenController.EnsureResult ensureUser(OpenTokenController.EnsureRequest body,
|
||||
AgentScope scope, long start) {
|
||||
User user = findUser(body.mobile(), body.email());
|
||||
if (user == null) {
|
||||
audit(null, null, body, "error", "人员不存在或非企业账号", start);
|
||||
@@ -70,17 +105,12 @@ public class OpenTokenService {
|
||||
|
||||
AgentApiKey existing = tokenService.findActiveToken(user.getId(), scope.getId());
|
||||
if (existing != null) {
|
||||
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 密文解密失败");
|
||||
}
|
||||
String plaintext = decryptOrNull(user, existing, body, start);
|
||||
if (plaintext != null) {
|
||||
audit(user, existing, body, "success", "已存在有效 Token,解密返回明文", start);
|
||||
return new OpenTokenController.EnsureResult(false, plaintext, existing.getKeyPrefix(),
|
||||
existing.getExpireTime(), user.getId(), user.getName(), scope.getCode());
|
||||
existing.getExpireTime(), user.getId(), user.getName(), scope.getCode(),
|
||||
AgentApiKey.PRINCIPAL_TYPE_USER, null);
|
||||
}
|
||||
// 历史数据无密文:禁用旧把并签发新把(轮换),保证总能拿到明文
|
||||
tokenService.status(existing.getId(), (byte) 0, SYSTEM_CREATOR_ID);
|
||||
@@ -93,7 +123,46 @@ public class OpenTokenService {
|
||||
created.setId(issued.token().id());
|
||||
audit(user, created, body, "success", "自动创建永久 Token", start);
|
||||
return new OpenTokenController.EnsureResult(true, issued.plaintext(), issued.token().keyPrefix(),
|
||||
null, user.getId(), user.getName(), scope.getCode());
|
||||
null, user.getId(), user.getName(), scope.getCode(), AgentApiKey.PRINCIPAL_TYPE_USER, null);
|
||||
}
|
||||
|
||||
private OpenTokenController.EnsureResult ensureService(OpenTokenController.EnsureRequest body,
|
||||
AgentScope scope, long start) {
|
||||
String serviceName = body.serviceName().trim();
|
||||
AgentApiKey existing = tokenService.findActiveServiceToken(serviceName, scope.getId());
|
||||
if (existing != null) {
|
||||
String plaintext = decryptOrNull(null, existing, body, start);
|
||||
if (plaintext != null) {
|
||||
audit(null, existing, body, "success", "已存在有效服务 Token,解密返回明文", start);
|
||||
return new OpenTokenController.EnsureResult(false, plaintext, existing.getKeyPrefix(),
|
||||
existing.getExpireTime(), null, null, scope.getCode(),
|
||||
AgentApiKey.PRINCIPAL_TYPE_SERVICE, serviceName);
|
||||
}
|
||||
tokenService.status(existing.getId(), (byte) 0, SYSTEM_CREATOR_ID);
|
||||
audit(null, existing, body, "success", "旧服务 Token 无密文,自动轮换", start);
|
||||
}
|
||||
|
||||
AdminTokenService.IssuedToken issued = tokenService.issueServiceToken(serviceName,
|
||||
"open-api 服务签发(" + scope.getCode() + ")", scope.getId(), null, SYSTEM_CREATOR_ID);
|
||||
AgentApiKey created = new AgentApiKey();
|
||||
created.setId(issued.token().id());
|
||||
audit(null, created, body, "success", "自动创建服务 Token", start);
|
||||
return new OpenTokenController.EnsureResult(true, issued.plaintext(), issued.token().keyPrefix(),
|
||||
null, null, null, scope.getCode(), AgentApiKey.PRINCIPAL_TYPE_SERVICE, serviceName);
|
||||
}
|
||||
|
||||
/** 已有 Token 解密回明文;无密文返回 null(调用方走轮换);解密失败 500 不回退 */
|
||||
private String decryptOrNull(User user, AgentApiKey existing, OpenTokenController.EnsureRequest body,
|
||||
long start) {
|
||||
if (!StringUtils.hasText(existing.getKeyCipher())) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return tokenCipher.decrypt(existing.getKeyCipher());
|
||||
} catch (IllegalStateException e) {
|
||||
audit(user, existing, body, "error", "Token 密文解密失败", start);
|
||||
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Token 密文解密失败");
|
||||
}
|
||||
}
|
||||
|
||||
/** 手机号优先、邮箱兜底;只认企业账号(enterprise_type 0/1,历史 NULL 保留),与后台用户列表同口径。 */
|
||||
@@ -113,7 +182,9 @@ public class OpenTokenService {
|
||||
private void audit(User user, AgentApiKey token, OpenTokenController.EnsureRequest body,
|
||||
String status, String message, long start) {
|
||||
String digest = "mobile=" + maskMobile(body.mobile()) + " email=" + maskEmail(body.email())
|
||||
+ " scopeCode=" + body.scopeCode();
|
||||
+ " scopeCode=" + body.scopeCode()
|
||||
+ (body.principalType() == null ? "" : " principalType=" + body.principalType())
|
||||
+ (body.serviceName() == null ? "" : " serviceName=" + body.serviceName());
|
||||
auditService.record(new AuthContext(token, user), AUDIT_TOOL, digest,
|
||||
status, message, System.currentTimeMillis() - start);
|
||||
}
|
||||
|
||||
@@ -66,7 +66,8 @@ public class AkAuthFilter extends OncePerRequestFilter {
|
||||
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("error", "unauthorized");
|
||||
body.put("message", "缺少有效的 Authorization: Bearer <ak>,或 ak 已禁用/过期");
|
||||
body.put("code", "UNAUTHENTICATED");
|
||||
body.put("message", "缺少有效的 Authorization: Bearer <ak>,或 ak 已禁用/过期/人员已停用");
|
||||
response.getWriter().write(objectMapper.writeValueAsString(body));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,14 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Date;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* ak 解析:ak → SHA-256 → agent_api_key → 人(Redis 热缓存优先,回源 m0.user)。
|
||||
* ak 解析:ak → SHA-256 → agent_api_key → 人(本地短 TTL 缓存 → Redis 热缓存 → m0.user)。
|
||||
* principal_type=service 的 Token 不绑定人员,跳过人员状态检查。
|
||||
* user 类型 Token 每次解析都校验人员在职状态(soft_delete=0 有效),
|
||||
* 人员状态本地缓存 60s(REQ-20260911-0014),停用最长 60s 后生效,避免每请求查 m0 库。
|
||||
*/
|
||||
@Component
|
||||
public class AuthService {
|
||||
@@ -28,10 +33,16 @@ public class AuthService {
|
||||
/** 老系统写入的用户热缓存 key 前缀(fastjson camelCase JSON) */
|
||||
private static final String USER_CACHE_KEY = "entity:User:";
|
||||
|
||||
/** 人员状态本地缓存 TTL(毫秒):停用/换绑最长 60s 后对 ak 调用生效 */
|
||||
private static final long USER_CACHE_TTL_MS = 60_000L;
|
||||
|
||||
final AgentApiKeyMapper agentApiKeyMapper;
|
||||
final UserMapper userMapper;
|
||||
final StringRedisTemplate redisTemplate;
|
||||
|
||||
/** userId → 人员快照(含不存在=null 的负缓存),按 TTL 过期 */
|
||||
private final Map<Long, CachedUser> userCache = new ConcurrentHashMap<>();
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper()
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
|
||||
@@ -42,7 +53,7 @@ public class AuthService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 ak,返回 AuthContext;ak 无效/禁用/过期或人不存在时返回 null。
|
||||
* 解析 ak,返回 AuthContext;ak 无效/禁用/过期、人员不存在/已停用(仅 user 类型)时返回 null。
|
||||
*/
|
||||
public AuthContext resolve(String ak) {
|
||||
AgentApiKey token = agentApiKeyMapper.selectOne(new LambdaQueryWrapper<AgentApiKey>()
|
||||
@@ -54,9 +65,12 @@ public class AuthService {
|
||||
if (token.getExpireTime() != null && token.getExpireTime().before(new Date())) {
|
||||
return null;
|
||||
}
|
||||
User user = loadUser(token.getUserId());
|
||||
if (user == null || (user.getSoftDelete() != null && user.getSoftDelete() == 1)) {
|
||||
return null;
|
||||
User user = null;
|
||||
if (!AgentApiKey.PRINCIPAL_TYPE_SERVICE.equals(token.getPrincipalType())) {
|
||||
user = loadUserCached(token.getUserId());
|
||||
if (user == null || (user.getSoftDelete() != null && user.getSoftDelete() == 1)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// 更新最后使用时间(自有库,读写)
|
||||
AgentApiKey update = new AgentApiKey();
|
||||
@@ -66,6 +80,21 @@ public class AuthService {
|
||||
return new AuthContext(token, user);
|
||||
}
|
||||
|
||||
/** 人员快照优先读本地缓存(TTL 60s),未命中回源 Redis 热缓存 / m0.user;不存在也缓存,防止穿透 */
|
||||
private User loadUserCached(Long userId) {
|
||||
if (userId == null) {
|
||||
return null;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
CachedUser cached = userCache.get(userId);
|
||||
if (cached != null && cached.expiresAt() > now) {
|
||||
return cached.user();
|
||||
}
|
||||
User user = loadUser(userId);
|
||||
userCache.put(userId, new CachedUser(user, now + USER_CACHE_TTL_MS));
|
||||
return user;
|
||||
}
|
||||
|
||||
/** 用户资料优先读 Redis db5 entity:User:{id} 热缓存(变更即时生效),读不到回源 m0.user */
|
||||
private User loadUser(Long userId) {
|
||||
try {
|
||||
@@ -79,6 +108,8 @@ public class AuthService {
|
||||
return userMapper.selectById(userId);
|
||||
}
|
||||
|
||||
private record CachedUser(User user, long expiresAt) {}
|
||||
|
||||
public static String sha256Hex(String raw) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
|
||||
@@ -4,7 +4,14 @@
|
||||
app:
|
||||
open:
|
||||
# Open API(/api/open/**)服务间静态密钥,dev 明文;调用方放 header X-Open-Key
|
||||
# 旧密钥:不限作用域,仅存量用户 Token ensure 可用(不可签发 service Token)
|
||||
api-key: xqok_4b44012f12b9fa54d6ec11690e6da57563a75c891df60961
|
||||
# 服务密钥 → 数据域绑定(REQ-20260911-0014):签发 service Token 必须用这里配置的密钥,
|
||||
# 且只能操作 scopes 清单内的作用域
|
||||
clients:
|
||||
- key: xqok_391b373047456e313a999ae0ee81f909b1750d508daab39c
|
||||
name: dev-sync-service
|
||||
scopes: [sync]
|
||||
# ak 明文 AES-GCM 加密密钥(任意长度字符串,SHA-256 派生为密钥),供 ensure 二次获取明文;
|
||||
# dev 明文(同库密码惯例),生产必须改用环境变量且与 dev 不同值
|
||||
token-cipher-key: d70da46f5f0db2016d88d5ee4b1d6af35867b7700b97105fec9b3ad567d474a5
|
||||
|
||||
@@ -16,6 +16,9 @@ app:
|
||||
name: ${ADMIN_BOOTSTRAP_NAME:系统管理员}
|
||||
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,勿写明文)
|
||||
api-key: ${OPEN_API_KEY:}
|
||||
|
||||
spring:
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Token 主体类型(REQ-20260911-0014):user=用户委托(ensure/后台签发,绑定 user_id)
|
||||
-- service=租户服务(Open API 服务密钥签发,不绑定人员,行级权限不适用)
|
||||
-- 存量行由 DEFAULT 回填为 user;服务 Token 以 subject_name 标识服务主体,user_id 放宽为可空
|
||||
ALTER TABLE agent_api_key
|
||||
ADD COLUMN principal_type VARCHAR(16) NOT NULL DEFAULT 'user' COMMENT '主体类型:user=用户委托 service=租户服务' AFTER user_id;
|
||||
|
||||
ALTER TABLE agent_api_key
|
||||
ADD COLUMN subject_name VARCHAR(64) NULL COMMENT '服务主体标识(principal_type=service 时必填,如 xiaoqu-ai)' AFTER principal_type;
|
||||
|
||||
ALTER TABLE agent_api_key
|
||||
MODIFY COLUMN user_id BIGINT NULL COMMENT '所属人(m0.user.id);principal_type=service 时为 NULL';
|
||||
@@ -1,3 +1,4 @@
|
||||
f8ec43a4fda81382d5675bc59fe7f4339f2e38a5b4241c260c3569e33b43a26d V1__init.sql
|
||||
0ad892601e891a18712b1b6a613c392c94e63492dc14f4f779375c092a3acab6 V2__p4_admin.sql
|
||||
0b6e407106156911f5fe83a13b342eeec5986e2e1e5b37b6797431a8720e1332 V3__token_cipher.sql
|
||||
5704691fa88406931c6aaf72bceaffd37cf6cc6982ce938c824ad8ecb94db1b5 V4__token_principal_type.sql
|
||||
|
||||
@@ -21,6 +21,7 @@ import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
@@ -112,4 +113,104 @@ class AdminTokenServiceTest {
|
||||
assertThrows(RuntimeException.class,
|
||||
() -> service().issue(7L, "Kimi", List.of(3L, 3L), null, 1L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void issueServiceTokenStoresServicePrincipalWithoutUser() {
|
||||
when(tokenMapper.insert(any(AgentApiKey.class))).thenAnswer(invocation -> {
|
||||
invocation.<AgentApiKey>getArgument(0).setId(21L);
|
||||
return 1;
|
||||
});
|
||||
|
||||
AdminTokenService.IssuedToken issued = service()
|
||||
.issueServiceToken("xiaoqu-ai", "同步任务用", 3L, null, 0L);
|
||||
|
||||
ArgumentCaptor<AgentApiKey> captor = ArgumentCaptor.forClass(AgentApiKey.class);
|
||||
org.mockito.Mockito.verify(tokenMapper).insert(captor.capture());
|
||||
AgentApiKey stored = captor.getValue();
|
||||
assertEquals("service", stored.getPrincipalType());
|
||||
assertEquals("xiaoqu-ai", stored.getSubjectName());
|
||||
assertNull(stored.getUserId());
|
||||
assertEquals(AuthService.sha256Hex(issued.plaintext()), stored.getKeyHash());
|
||||
assertEquals("cipher:" + issued.plaintext(), stored.getKeyCipher());
|
||||
assertEquals("service", issued.token().principalType());
|
||||
assertEquals("xiaoqu-ai", issued.token().subjectName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void issueServiceTokenRejectsInvalidServiceName() {
|
||||
assertThrows(RuntimeException.class,
|
||||
() -> service().issueServiceToken("Bad Name", "memo", 3L, null, 0L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rotateDisablesOldServiceTokenAndIssuesNewWithSameSubjectAndScope() {
|
||||
AgentApiKey old = new AgentApiKey();
|
||||
old.setId(11L);
|
||||
old.setSoftDelete((byte) 0);
|
||||
old.setStatus((byte) 1);
|
||||
old.setPrincipalType("service");
|
||||
old.setSubjectName("xiaoqu-ai");
|
||||
old.setMemo("同步任务用");
|
||||
when(tokenMapper.selectById(11L)).thenReturn(old);
|
||||
TokenScope relation = new TokenScope();
|
||||
relation.setTokenId(11L);
|
||||
relation.setScopeId(3L);
|
||||
relation.setSoftDelete((byte) 0);
|
||||
when(tokenScopeMapper.selectList(any())).thenReturn(List.of(relation));
|
||||
when(tokenMapper.insert(any(AgentApiKey.class))).thenAnswer(invocation -> {
|
||||
invocation.<AgentApiKey>getArgument(0).setId(22L);
|
||||
return 1;
|
||||
});
|
||||
|
||||
List<AdminTokenService.IssuedToken> rotated = service().rotate(11L, 1L);
|
||||
|
||||
assertEquals((byte) 0, old.getStatus());
|
||||
org.mockito.Mockito.verify(tokenMapper).updateById(old);
|
||||
assertEquals(1, rotated.size());
|
||||
ArgumentCaptor<AgentApiKey> captor = ArgumentCaptor.forClass(AgentApiKey.class);
|
||||
org.mockito.Mockito.verify(tokenMapper).insert(captor.capture());
|
||||
AgentApiKey stored = captor.getValue();
|
||||
assertEquals("service", stored.getPrincipalType());
|
||||
assertEquals("xiaoqu-ai", stored.getSubjectName());
|
||||
assertTrue(rotated.get(0).plaintext().matches("xqak_[0-9a-f]{48}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rotateUserTokenKeepsUserBinding() {
|
||||
AgentApiKey old = new AgentApiKey();
|
||||
old.setId(11L);
|
||||
old.setSoftDelete((byte) 0);
|
||||
old.setStatus((byte) 1);
|
||||
old.setPrincipalType("user");
|
||||
old.setUserId(7L);
|
||||
when(tokenMapper.selectById(11L)).thenReturn(old);
|
||||
TokenScope relation = new TokenScope();
|
||||
relation.setTokenId(11L);
|
||||
relation.setScopeId(3L);
|
||||
relation.setSoftDelete((byte) 0);
|
||||
when(tokenScopeMapper.selectList(any())).thenReturn(List.of(relation));
|
||||
when(tokenMapper.insert(any(AgentApiKey.class))).thenAnswer(invocation -> {
|
||||
invocation.<AgentApiKey>getArgument(0).setId(23L);
|
||||
return 1;
|
||||
});
|
||||
|
||||
List<AdminTokenService.IssuedToken> rotated = service().rotate(11L, 1L);
|
||||
|
||||
assertEquals(1, rotated.size());
|
||||
ArgumentCaptor<AgentApiKey> captor = ArgumentCaptor.forClass(AgentApiKey.class);
|
||||
org.mockito.Mockito.verify(tokenMapper).insert(captor.capture());
|
||||
assertEquals("user", captor.getValue().getPrincipalType());
|
||||
assertEquals(7L, captor.getValue().getUserId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rotateRejectsTokenWithoutScope() {
|
||||
AgentApiKey old = new AgentApiKey();
|
||||
old.setId(11L);
|
||||
old.setSoftDelete((byte) 0);
|
||||
when(tokenMapper.selectById(11L)).thenReturn(old);
|
||||
when(tokenScopeMapper.selectList(any())).thenReturn(List.of());
|
||||
|
||||
assertThrows(RuntimeException.class, () -> service().rotate(11L, 1L));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ class FlywayMigrationTest {
|
||||
|
||||
flyway.migrate();
|
||||
|
||||
assertEquals("3", flyway.info().current().getVersion().getVersion());
|
||||
assertEquals("4", flyway.info().current().getVersion().getVersion());
|
||||
assertEquals(255, passwordHashLength(dataSource));
|
||||
}
|
||||
|
||||
@@ -28,13 +28,14 @@ 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)");
|
||||
statement.execute("CREATE TABLE agent_api_key (id BIGINT AUTO_INCREMENT PRIMARY KEY,"
|
||||
+ " user_id BIGINT NOT NULL, key_hash CHAR(64) NOT NULL)");
|
||||
}
|
||||
Flyway flyway = flyway(dataSource, true);
|
||||
|
||||
flyway.migrate();
|
||||
|
||||
assertEquals("3", flyway.info().current().getVersion().getVersion());
|
||||
assertEquals("4", flyway.info().current().getVersion().getVersion());
|
||||
assertEquals(255, passwordHashLength(dataSource));
|
||||
}
|
||||
|
||||
|
||||
+15
-8
@@ -21,7 +21,6 @@ import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -60,23 +59,26 @@ class IntegrationContextServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void principalTypeIsTenantServiceWhenNoUserBound() {
|
||||
void principalTypeIsTenantServiceWhenServiceColumn() {
|
||||
when(tokenScopeMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
|
||||
when(toolGuard.allowedToolNames(7L)).thenReturn(Set.of());
|
||||
IntegrationContextService service = service();
|
||||
AuthContext ctx = context(token(7L, null), null);
|
||||
AuthContext ctx = context(token(7L, null, "service"), null);
|
||||
|
||||
assertEquals("tenant_service", service.resolvePrincipalType(ctx));
|
||||
assertNull(service.buildContext(ctx).get("subject_id"));
|
||||
// 服务主体无人员绑定,subject_id 取 token 自身 id
|
||||
assertEquals(7L, service.buildContext(ctx).get("subject_id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void principalTypeIsUserDelegatedWhenUserBound() {
|
||||
void principalTypeIsUserDelegatedForUserTokens() {
|
||||
IntegrationContextService service = service();
|
||||
|
||||
assertEquals("user_delegated", service.resolvePrincipalType(context(token(7L, 42L), user(42L))));
|
||||
// 人未加载但 token 绑定了 user_id,仍按用户委托判定(5629 切列前以绑定关系为准)
|
||||
assertEquals("user_delegated", service.resolvePrincipalType(context(token(7L, 42L), null)));
|
||||
assertEquals("user_delegated",
|
||||
service.resolvePrincipalType(context(token(7L, 42L, "user"), user(42L))));
|
||||
// 人未加载但列值为 user,仍按用户委托判定
|
||||
assertEquals("user_delegated",
|
||||
service.resolvePrincipalType(context(token(7L, 42L, "user"), null)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -136,9 +138,14 @@ class IntegrationContextServiceTest {
|
||||
}
|
||||
|
||||
private static AgentApiKey token(Long id, Long userId) {
|
||||
return token(id, userId, "user");
|
||||
}
|
||||
|
||||
private static AgentApiKey token(Long id, Long userId, String principalType) {
|
||||
AgentApiKey token = new AgentApiKey();
|
||||
token.setId(id);
|
||||
token.setUserId(userId);
|
||||
token.setPrincipalType(principalType);
|
||||
return token;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package me.iiv.agent.open;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockFilterChain;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
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;
|
||||
|
||||
class OpenKeyFilterTest {
|
||||
|
||||
private static OpenClientProperties properties(String legacyKey, String clientKey, List<String> scopes) {
|
||||
OpenClientProperties properties = new OpenClientProperties();
|
||||
properties.setApiKey(legacyKey);
|
||||
if (clientKey != null) {
|
||||
OpenClientProperties.Client client = new OpenClientProperties.Client();
|
||||
client.setKey(clientKey);
|
||||
client.setName("dev-sync-service");
|
||||
client.setScopes(scopes);
|
||||
properties.setClients(List.of(client));
|
||||
}
|
||||
return properties;
|
||||
}
|
||||
|
||||
private static MockHttpServletRequest request(String key) {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/open/token/ensure");
|
||||
if (key != null) {
|
||||
request.addHeader(OpenKeyFilter.OPEN_KEY_HEADER, key);
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
@Test
|
||||
void clientKeyResolvesToBoundClient() throws Exception {
|
||||
OpenKeyFilter filter = new OpenKeyFilter(
|
||||
properties("legacy-key", "client-key", List.of("operation")));
|
||||
MockHttpServletRequest request = request("client-key");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
assertSame(request, chain.getRequest());
|
||||
OpenClientProperties.ResolvedClient client =
|
||||
(OpenClientProperties.ResolvedClient) request.getAttribute(OpenKeyFilter.CLIENT_ATTRIBUTE);
|
||||
assertEquals("dev-sync-service", client.name());
|
||||
assertEquals(List.of("operation"), client.scopes());
|
||||
assertEquals(false, client.legacy());
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyKeyResolvesToUnrestrictedLegacyClient() throws Exception {
|
||||
OpenKeyFilter filter = new OpenKeyFilter(
|
||||
properties("legacy-key", "client-key", List.of("operation")));
|
||||
MockHttpServletRequest request = request("legacy-key");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
OpenClientProperties.ResolvedClient client =
|
||||
(OpenClientProperties.ResolvedClient) request.getAttribute(OpenKeyFilter.CLIENT_ATTRIBUTE);
|
||||
assertTrue(client.legacy());
|
||||
assertNull(client.scopes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidKeyRejectedWith401() throws Exception {
|
||||
OpenKeyFilter filter = new OpenKeyFilter(
|
||||
properties("legacy-key", "client-key", List.of("operation")));
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
filter.doFilter(request("wrong-key"), response, new MockFilterChain());
|
||||
|
||||
assertEquals(401, response.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void noKeyConfiguredMeansApiClosed() throws Exception {
|
||||
OpenKeyFilter filter = new OpenKeyFilter(properties("", null, null));
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
filter.doFilter(request("any"), response, new MockFilterChain());
|
||||
|
||||
assertEquals(403, response.getStatus());
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,16 @@ import static org.mockito.Mockito.when;
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class OpenTokenServiceTest {
|
||||
|
||||
/** 旧版不限域密钥(app.open.api-key) */
|
||||
private static final OpenClientProperties.ResolvedClient LEGACY =
|
||||
new OpenClientProperties.ResolvedClient("legacy", null, true);
|
||||
/** 绑定了 operation 作用域的服务密钥(app.open.clients) */
|
||||
private static final OpenClientProperties.ResolvedClient SCOPED =
|
||||
new OpenClientProperties.ResolvedClient("dev-sync-service", List.of("operation"), false);
|
||||
/** 绑定了 sync 作用域的服务密钥(app.open.clients) */
|
||||
private static final OpenClientProperties.ResolvedClient SCOPED_SYNC =
|
||||
new OpenClientProperties.ResolvedClient("dev-sync-service", List.of("sync"), false);
|
||||
|
||||
@Mock UserMapper userMapper;
|
||||
@Mock AgentScopeMapper scopeMapper;
|
||||
@Mock AdminTokenService tokenService;
|
||||
@@ -63,6 +73,22 @@ class OpenTokenServiceTest {
|
||||
when(userMapper.selectList(any())).thenReturn(List.of(user));
|
||||
}
|
||||
|
||||
private static AgentScope syncScope() {
|
||||
AgentScope sync = new AgentScope();
|
||||
sync.setId(9L);
|
||||
sync.setCode("sync");
|
||||
sync.setStatus((byte) 1);
|
||||
sync.setSoftDelete((byte) 0);
|
||||
return sync;
|
||||
}
|
||||
|
||||
private static AdminTokenService.IssuedToken issuedToken(String plaintext) {
|
||||
AdminTokenService.TokenView view = new AdminTokenService.TokenView(
|
||||
12L, "xqak_efgh5678", "open-api 自动签发(operation)", (byte) 1,
|
||||
"user", null, null, null, List.of(3L), List.of("运营"));
|
||||
return new AdminTokenService.IssuedToken(plaintext, view);
|
||||
}
|
||||
|
||||
@Test
|
||||
void existingTokenWithCipherReturnsDecryptedPlaintext() {
|
||||
stubScopeAndUser();
|
||||
@@ -74,12 +100,13 @@ class OpenTokenServiceTest {
|
||||
when(tokenCipher.decrypt("packed-cipher")).thenReturn("xqak_abcd1234ffff");
|
||||
|
||||
OpenTokenController.EnsureResult result = service.ensure(
|
||||
new OpenTokenController.EnsureRequest("13800001111", null, "operation"));
|
||||
new OpenTokenController.EnsureRequest("13800001111", null, "operation", null, null), LEGACY);
|
||||
|
||||
assertFalse(result.created());
|
||||
assertEquals("xqak_abcd1234ffff", result.token());
|
||||
assertEquals("xqak_abcd1234", result.keyPrefix());
|
||||
assertEquals(7L, result.userId());
|
||||
assertEquals("user", result.principalType());
|
||||
org.mockito.Mockito.verify(tokenService, never())
|
||||
.issueSingle(anyLong(), anyString(), anyLong(), isNull(), anyLong());
|
||||
}
|
||||
@@ -91,14 +118,11 @@ class OpenTokenServiceTest {
|
||||
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));
|
||||
.thenReturn(issuedToken("xqak_" + "b".repeat(48)));
|
||||
|
||||
OpenTokenController.EnsureResult result = service.ensure(
|
||||
new OpenTokenController.EnsureRequest("13800001111", null, "operation"));
|
||||
new OpenTokenController.EnsureRequest("13800001111", null, "operation", null, null), LEGACY);
|
||||
|
||||
org.mockito.Mockito.verify(tokenService).status(11L, (byte) 0, 0L);
|
||||
assertTrue(result.created());
|
||||
@@ -116,7 +140,7 @@ class OpenTokenServiceTest {
|
||||
when(tokenCipher.decrypt("bad-cipher")).thenThrow(new IllegalStateException("ak 密文解密失败"));
|
||||
|
||||
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.ensure(
|
||||
new OpenTokenController.EnsureRequest("13800001111", null, "operation")));
|
||||
new OpenTokenController.EnsureRequest("13800001111", null, "operation", null, null), LEGACY));
|
||||
assertEquals(500, error.getStatusCode().value());
|
||||
}
|
||||
|
||||
@@ -124,14 +148,11 @@ class OpenTokenServiceTest {
|
||||
void missingTokenCreatesPermanentOneAndReturnsPlaintext() {
|
||||
stubScopeAndUser();
|
||||
when(tokenService.findActiveToken(7L, 3L)).thenReturn(null);
|
||||
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_" + "a".repeat(48), view));
|
||||
.thenReturn(issuedToken("xqak_" + "a".repeat(48)));
|
||||
|
||||
OpenTokenController.EnsureResult result = service.ensure(
|
||||
new OpenTokenController.EnsureRequest(null, "zhangsan@test.com", "operation"));
|
||||
new OpenTokenController.EnsureRequest(null, "zhangsan@test.com", "operation", null, null), LEGACY);
|
||||
|
||||
assertTrue(result.created());
|
||||
assertTrue(result.token().matches("xqak_[0-9a-f]{48}"));
|
||||
@@ -145,7 +166,7 @@ class OpenTokenServiceTest {
|
||||
when(userMapper.selectList(any())).thenReturn(List.of());
|
||||
|
||||
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.ensure(
|
||||
new OpenTokenController.EnsureRequest("13800001111", null, "operation")));
|
||||
new OpenTokenController.EnsureRequest("13800001111", null, "operation", null, null), LEGACY));
|
||||
assertEquals(404, error.getStatusCode().value());
|
||||
}
|
||||
|
||||
@@ -155,12 +176,149 @@ class OpenTokenServiceTest {
|
||||
when(scopeMapper.selectOne(any())).thenReturn(scope);
|
||||
|
||||
assertThrows(ResponseStatusException.class, () -> service.ensure(
|
||||
new OpenTokenController.EnsureRequest("13800001111", null, "operation")));
|
||||
new OpenTokenController.EnsureRequest("13800001111", null, "operation", null, null), LEGACY));
|
||||
}
|
||||
|
||||
@Test
|
||||
void requiresMobileOrEmail() {
|
||||
assertThrows(ResponseStatusException.class, () -> service.ensure(
|
||||
new OpenTokenController.EnsureRequest(null, null, "operation")));
|
||||
new OpenTokenController.EnsureRequest(null, null, "operation", null, null), LEGACY));
|
||||
}
|
||||
|
||||
@Test
|
||||
void userEnsureWithScopedKeyAllowedWhenScopeBound() {
|
||||
stubScopeAndUser();
|
||||
when(tokenService.findActiveToken(7L, 3L)).thenReturn(null);
|
||||
when(tokenService.issueSingle(eq(7L), anyString(), eq(3L), isNull(), eq(0L)))
|
||||
.thenReturn(issuedToken("xqak_" + "a".repeat(48)));
|
||||
|
||||
OpenTokenController.EnsureResult result = service.ensure(
|
||||
new OpenTokenController.EnsureRequest("13800001111", null, "operation", null, null), SCOPED);
|
||||
|
||||
assertTrue(result.created());
|
||||
}
|
||||
|
||||
@Test
|
||||
void userEnsureWithScopedKeyForbiddenWhenScopeNotBound() {
|
||||
when(scopeMapper.selectOne(any())).thenReturn(scope);
|
||||
OpenClientProperties.ResolvedClient hardwareOnly =
|
||||
new OpenClientProperties.ResolvedClient("other", List.of("hardware"), false);
|
||||
|
||||
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.ensure(
|
||||
new OpenTokenController.EnsureRequest("13800001111", null, "operation", null, null),
|
||||
hardwareOnly));
|
||||
assertEquals(403, error.getStatusCode().value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void serviceEnsureCreatesServiceToken() {
|
||||
AgentScope sync = syncScope();
|
||||
when(scopeMapper.selectOne(any())).thenReturn(sync);
|
||||
when(tokenService.findActiveServiceToken("xiaoqu-ai", 9L)).thenReturn(null);
|
||||
AdminTokenService.TokenView view = new AdminTokenService.TokenView(
|
||||
12L, "xqak_efgh5678", "open-api 服务签发(sync)", (byte) 1,
|
||||
"service", "xiaoqu-ai", null, null, List.of(9L), List.of("数据同步"));
|
||||
when(tokenService.issueServiceToken(eq("xiaoqu-ai"), anyString(), eq(9L), isNull(), eq(0L)))
|
||||
.thenReturn(new AdminTokenService.IssuedToken("xqak_" + "c".repeat(48), view));
|
||||
|
||||
OpenTokenController.EnsureResult result = service.ensure(
|
||||
new OpenTokenController.EnsureRequest(null, null, "sync", "service", "xiaoqu-ai"), SCOPED_SYNC);
|
||||
|
||||
assertTrue(result.created());
|
||||
assertEquals("service", result.principalType());
|
||||
assertEquals("xiaoqu-ai", result.subjectName());
|
||||
assertEquals("sync", result.scopeCode());
|
||||
assertNull(result.userId());
|
||||
assertNull(result.userName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void serviceEnsureExistingTokenReturnsDecryptedPlaintext() {
|
||||
AgentScope sync = syncScope();
|
||||
when(scopeMapper.selectOne(any())).thenReturn(sync);
|
||||
AgentApiKey existing = new AgentApiKey();
|
||||
existing.setId(11L);
|
||||
existing.setKeyPrefix("xqak_abcd1234");
|
||||
existing.setKeyCipher("packed-cipher");
|
||||
when(tokenService.findActiveServiceToken("xiaoqu-ai", 9L)).thenReturn(existing);
|
||||
when(tokenCipher.decrypt("packed-cipher")).thenReturn("xqak_abcd1234ffff");
|
||||
|
||||
OpenTokenController.EnsureResult result = service.ensure(
|
||||
new OpenTokenController.EnsureRequest(null, null, "sync", "service", "xiaoqu-ai"), SCOPED_SYNC);
|
||||
|
||||
assertFalse(result.created());
|
||||
assertEquals("xqak_abcd1234ffff", result.token());
|
||||
org.mockito.Mockito.verify(tokenService, never())
|
||||
.issueServiceToken(anyString(), anyString(), anyLong(), isNull(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void serviceEnsureWithoutCipherRotates() {
|
||||
AgentScope sync = syncScope();
|
||||
when(scopeMapper.selectOne(any())).thenReturn(sync);
|
||||
AgentApiKey existing = new AgentApiKey();
|
||||
existing.setId(11L);
|
||||
existing.setKeyPrefix("xqak_abcd1234");
|
||||
when(tokenService.findActiveServiceToken("xiaoqu-ai", 9L)).thenReturn(existing);
|
||||
AdminTokenService.TokenView view = new AdminTokenService.TokenView(
|
||||
12L, "xqak_efgh5678", "open-api 服务签发(sync)", (byte) 1,
|
||||
"service", "xiaoqu-ai", null, null, List.of(9L), List.of("数据同步"));
|
||||
when(tokenService.issueServiceToken(eq("xiaoqu-ai"), anyString(), eq(9L), isNull(), eq(0L)))
|
||||
.thenReturn(new AdminTokenService.IssuedToken("xqak_" + "d".repeat(48), view));
|
||||
|
||||
OpenTokenController.EnsureResult result = service.ensure(
|
||||
new OpenTokenController.EnsureRequest(null, null, "sync", "service", "xiaoqu-ai"), SCOPED_SYNC);
|
||||
|
||||
org.mockito.Mockito.verify(tokenService).status(11L, (byte) 0, 0L);
|
||||
assertTrue(result.created());
|
||||
}
|
||||
|
||||
@Test
|
||||
void serviceEnsureWithLegacyKeyForbidden() {
|
||||
when(scopeMapper.selectOne(any())).thenReturn(syncScope());
|
||||
|
||||
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.ensure(
|
||||
new OpenTokenController.EnsureRequest(null, null, "sync", "service", "xiaoqu-ai"), LEGACY));
|
||||
assertEquals(403, error.getStatusCode().value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void serviceEnsureRequiresServiceName() {
|
||||
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.ensure(
|
||||
new OpenTokenController.EnsureRequest(null, null, "sync", "service", null), SCOPED_SYNC));
|
||||
assertEquals(400, error.getStatusCode().value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void serviceEnsureForbiddenWhenScopeNotBound() {
|
||||
when(scopeMapper.selectOne(any())).thenReturn(syncScope());
|
||||
OpenClientProperties.ResolvedClient hardwareOnly =
|
||||
new OpenClientProperties.ResolvedClient("other", List.of("hardware"), false);
|
||||
|
||||
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.ensure(
|
||||
new OpenTokenController.EnsureRequest(null, null, "sync", "service", "xiaoqu-ai"),
|
||||
hardwareOnly));
|
||||
assertEquals(403, error.getStatusCode().value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void serviceEnsureRejectsNonSyncScope() {
|
||||
when(scopeMapper.selectOne(any())).thenReturn(scope);
|
||||
|
||||
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.ensure(
|
||||
new OpenTokenController.EnsureRequest(null, null, "operation", "service", "xiaoqu-ai"), SCOPED));
|
||||
assertEquals(403, error.getStatusCode().value());
|
||||
org.mockito.Mockito.verify(tokenService, never())
|
||||
.issueServiceToken(anyString(), anyString(), anyLong(), isNull(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void userEnsureRejectsSyncScope() {
|
||||
when(scopeMapper.selectOne(any())).thenReturn(syncScope());
|
||||
|
||||
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.ensure(
|
||||
new OpenTokenController.EnsureRequest("13800001111", null, "sync", null, null), SCOPED_SYNC));
|
||||
assertEquals(403, error.getStatusCode().value());
|
||||
org.mockito.Mockito.verify(userMapper, never()).selectList(any());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.springframework.mock.web.MockHttpServletResponse;
|
||||
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.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -39,6 +40,7 @@ class AkAuthFilterTest {
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
assertEquals(401, response.getStatus());
|
||||
assertTrue(response.getContentAsString().contains("UNAUTHENTICATED"));
|
||||
assertNull(chain.getRequest());
|
||||
verify(authService, never()).resolve(org.mockito.ArgumentMatchers.anyString());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package me.iiv.agent.security;
|
||||
|
||||
import me.iiv.agent.entity.m0.User;
|
||||
import me.iiv.agent.entity.svc.AgentApiKey;
|
||||
import me.iiv.agent.mapper.m0.UserMapper;
|
||||
import me.iiv.agent.mapper.svc.AgentApiKeyMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AuthServiceTest {
|
||||
|
||||
@Mock AgentApiKeyMapper agentApiKeyMapper;
|
||||
@Mock UserMapper userMapper;
|
||||
@Mock StringRedisTemplate redisTemplate;
|
||||
@Mock ValueOperations<String, String> valueOperations;
|
||||
|
||||
AuthService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new AuthService(agentApiKeyMapper, userMapper, redisTemplate);
|
||||
lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||
}
|
||||
|
||||
private AgentApiKey stubToken(String principalType, Long userId) {
|
||||
AgentApiKey token = new AgentApiKey();
|
||||
token.setId(11L);
|
||||
token.setStatus((byte) 1);
|
||||
token.setSoftDelete((byte) 0);
|
||||
token.setPrincipalType(principalType);
|
||||
token.setUserId(userId);
|
||||
when(agentApiKeyMapper.selectOne(any())).thenReturn(token);
|
||||
return token;
|
||||
}
|
||||
|
||||
@Test
|
||||
void userTokenWithActiveUserResolves() {
|
||||
stubToken("user", 7L);
|
||||
when(valueOperations.get(anyString())).thenReturn(null);
|
||||
User user = new User();
|
||||
user.setId(7L);
|
||||
user.setSoftDelete(0);
|
||||
when(userMapper.selectById(7L)).thenReturn(user);
|
||||
|
||||
AuthContext ctx = service.resolve("ak-active");
|
||||
|
||||
assertNotNull(ctx);
|
||||
assertEquals(7L, ctx.getUserId());
|
||||
verify(agentApiKeyMapper).updateById(any(AgentApiKey.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void userTokenWithDisabledUserRejected() {
|
||||
stubToken("user", 7L);
|
||||
when(valueOperations.get(anyString())).thenReturn(null);
|
||||
User user = new User();
|
||||
user.setId(7L);
|
||||
user.setSoftDelete(1);
|
||||
when(userMapper.selectById(7L)).thenReturn(user);
|
||||
|
||||
assertNull(service.resolve("ak-disabled"));
|
||||
// 人员已停用时不应更新 last_used_time
|
||||
verify(agentApiKeyMapper, never()).updateById(any(AgentApiKey.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void userStatusIsCachedLocallyForSecondResolve() {
|
||||
stubToken("user", 7L);
|
||||
when(valueOperations.get(anyString())).thenReturn(null);
|
||||
User user = new User();
|
||||
user.setId(7L);
|
||||
user.setSoftDelete(1);
|
||||
when(userMapper.selectById(7L)).thenReturn(user);
|
||||
|
||||
assertNull(service.resolve("ak-disabled"));
|
||||
assertNull(service.resolve("ak-disabled"));
|
||||
|
||||
// 60s TTL 内第二次解析命中本地缓存,不再回源 m0 库
|
||||
verify(userMapper, times(1)).selectById(7L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void serviceTokenResolvesWithoutUserLookup() {
|
||||
stubToken("service", null);
|
||||
|
||||
AuthContext ctx = service.resolve("ak-service");
|
||||
|
||||
assertNotNull(ctx);
|
||||
assertNull(ctx.getUser());
|
||||
assertEquals(11L, ctx.getTokenId());
|
||||
verify(userMapper, never()).selectById(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledTokenRejected() {
|
||||
AgentApiKey token = stubToken("user", 7L);
|
||||
token.setStatus((byte) 0);
|
||||
|
||||
assertNull(service.resolve("ak-revoked"));
|
||||
verify(userMapper, never()).selectById(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownTokenRejected() {
|
||||
when(agentApiKeyMapper.selectOne(any())).thenReturn(null);
|
||||
|
||||
assertNull(service.resolve("ak-unknown"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user