diff --git a/xiaoqu-agent-service/doc/agent-mcp-service-design.md b/xiaoqu-agent-service/doc/agent-mcp-service-design.md index b91e0b7..ea9415e 100644 --- a/xiaoqu-agent-service/doc/agent-mcp-service-design.md +++ b/xiaoqu-agent-service/doc/agent-mcp-service-design.md @@ -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. 技术栈与工程结构 diff --git a/xiaoqu-agent-service/sql/rollback/V4__token_principal_type.down.sql b/xiaoqu-agent-service/sql/rollback/V4__token_principal_type.down.sql new file mode 100644 index 0000000..bae7fe7 --- /dev/null +++ b/xiaoqu-agent-service/sql/rollback/V4__token_principal_type.down.sql @@ -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)'; diff --git a/xiaoqu-agent-service/sql/seed-dev.sql b/xiaoqu-agent-service/sql/seed-dev.sql index 13d686a..a0a3e42 100644 --- a/xiaoqu-agent-service/sql/seed-dev.sql +++ b/xiaoqu-agent-service/sql/seed-dev.sql @@ -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; -- 校验 diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminConsoleController.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminConsoleController.java index 333a7e1..53849fa 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminConsoleController.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminConsoleController.java @@ -156,6 +156,12 @@ public class AdminConsoleController { tokenService.delete(id, adminId(session)); } + /** 轮换:禁用旧把并按原主体/作用域签新把,明文仅在本次响应返回(REQ-20260911-0014)。 */ + @PostMapping("/tokens/{id}/rotate") + public List rotateToken(@PathVariable Long id, HttpSession session) { + return tokenService.rotate(id, adminId(session)); + } + @GetMapping("/tools") public List tools(@RequestParam(defaultValue = "") String category, @RequestParam(defaultValue = "") String kind, diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminTokenService.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminTokenService.java index efc796c..ac1ca9f 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminTokenService.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminTokenService.java @@ -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 rotate(Long tokenId, Long operatorId) { + AgentApiKey current = activeToken(tokenId); + List scopeIds = relationMapper.selectList(new LambdaQueryWrapper() + .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 tokenIds = relationMapper.selectList(new LambdaQueryWrapper() @@ -90,6 +133,25 @@ public class AdminTokenService { .stream().findFirst().orElse(null); } + /** 查找某服务主体在某作用域下的有效 Token(正常状态且未过期),取最新一把;无则 null。 */ + public AgentApiKey findActiveServiceToken(String serviceName, Long scopeId) { + List tokenIds = relationMapper.selectList(new LambdaQueryWrapper() + .eq(TokenScope::getScopeId, scopeId).eq(TokenScope::getSoftDelete, 0)) + .stream().map(TokenScope::getTokenId).toList(); + if (tokenIds.isEmpty()) return null; + return tokenMapper.selectList(new LambdaQueryWrapper() + .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 scopeIds, Date expireTime, Long adminId) { AgentApiKey current = activeToken(tokenId); @@ -133,6 +195,7 @@ public class AdminTokenService { new LambdaQueryWrapper().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 scopeIds, List scopeNames) {} public record IssuedToken(String plaintext, TokenView token) {} } diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/svc/AgentApiKey.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/svc/AgentApiKey.java index df9f7fc..006fe6b 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/svc/AgentApiKey.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/svc/AgentApiKey.java @@ -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) */ diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/IntegrationContextService.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/IntegrationContextService.java index 4b42088..03a230a 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/IntegrationContextService.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/IntegrationContextService.java @@ -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 enabledScopes(List bindings) { diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/open/OpenClientProperties.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/open/OpenClientProperties.java new file mode 100644 index 0000000..f2f0b38 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/open/OpenClientProperties.java @@ -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 clients = new ArrayList<>(); + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public List getClients() { + return clients; + } + + public void setClients(List clients) { + this.clients = clients; + } + + public static class Client { + + /** 密钥明文(X-Open-Key 头值) */ + private String key; + + /** 调用方名称(审计/排查用) */ + private String name; + + /** 允许操作的作用域编码;空列表 = 未绑定数据域,不能签发/操作任何 scope */ + private List 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 getScopes() { + return scopes; + } + + public void setScopes(List scopes) { + this.scopes = scopes; + } + } + + /** 鉴权通过的调用方;scopes 为 null 表示旧密钥(不限作用域,但不可签发 service Token) */ + public record ResolvedClient(String name, List scopes, boolean legacy) {} +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/open/OpenKeyFilter.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/open/OpenKeyFilter.java index 8ef74ef..198b759 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/open/OpenKeyFilter.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/open/OpenKeyFilter.java @@ -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()); diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/open/OpenTokenController.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/open/OpenTokenController.java index 8a2e760..6662089 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/open/OpenTokenController.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/open/OpenTokenController.java @@ -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) {} } diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/open/OpenTokenService.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/open/OpenTokenService.java index 1e6c67e..9b3d8f2 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/open/OpenTokenService.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/open/OpenTokenService.java @@ -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); } diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/AkAuthFilter.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/AkAuthFilter.java index 01c834c..4b4e710 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/AkAuthFilter.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/AkAuthFilter.java @@ -66,7 +66,8 @@ public class AkAuthFilter extends OncePerRequestFilter { response.setCharacterEncoding(StandardCharsets.UTF_8.name()); Map body = new LinkedHashMap<>(); body.put("error", "unauthorized"); - body.put("message", "缺少有效的 Authorization: Bearer ,或 ak 已禁用/过期"); + body.put("code", "UNAUTHENTICATED"); + body.put("message", "缺少有效的 Authorization: Bearer ,或 ak 已禁用/过期/人员已停用"); response.getWriter().write(objectMapper.writeValueAsString(body)); } } diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/AuthService.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/AuthService.java index 225cd09..a4c174e 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/AuthService.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/security/AuthService.java @@ -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 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() @@ -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"); diff --git a/xiaoqu-agent-service/src/main/resources/application-dev.yml b/xiaoqu-agent-service/src/main/resources/application-dev.yml index 2d698a7..168b17c 100644 --- a/xiaoqu-agent-service/src/main/resources/application-dev.yml +++ b/xiaoqu-agent-service/src/main/resources/application-dev.yml @@ -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 diff --git a/xiaoqu-agent-service/src/main/resources/application.yml b/xiaoqu-agent-service/src/main/resources/application.yml index 8d6bf83..17a69e6 100644 --- a/xiaoqu-agent-service/src/main/resources/application.yml +++ b/xiaoqu-agent-service/src/main/resources/application.yml @@ -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: diff --git a/xiaoqu-agent-service/src/main/resources/db/migration/V4__token_principal_type.sql b/xiaoqu-agent-service/src/main/resources/db/migration/V4__token_principal_type.sql new file mode 100644 index 0000000..90b97c7 --- /dev/null +++ b/xiaoqu-agent-service/src/main/resources/db/migration/V4__token_principal_type.sql @@ -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'; diff --git a/xiaoqu-agent-service/src/main/resources/db/migration/checksums.sha256 b/xiaoqu-agent-service/src/main/resources/db/migration/checksums.sha256 index 1b754bd..7a93e1c 100644 --- a/xiaoqu-agent-service/src/main/resources/db/migration/checksums.sha256 +++ b/xiaoqu-agent-service/src/main/resources/db/migration/checksums.sha256 @@ -1,3 +1,4 @@ f8ec43a4fda81382d5675bc59fe7f4339f2e38a5b4241c260c3569e33b43a26d V1__init.sql 0ad892601e891a18712b1b6a613c392c94e63492dc14f4f779375c092a3acab6 V2__p4_admin.sql 0b6e407106156911f5fe83a13b342eeec5986e2e1e5b37b6797431a8720e1332 V3__token_cipher.sql +5704691fa88406931c6aaf72bceaffd37cf6cc6982ce938c824ad8ecb94db1b5 V4__token_principal_type.sql diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/admin/AdminTokenServiceTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/admin/AdminTokenServiceTest.java index 12e2562..6220766 100644 --- a/xiaoqu-agent-service/src/test/java/me/iiv/agent/admin/AdminTokenServiceTest.java +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/admin/AdminTokenServiceTest.java @@ -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.getArgument(0).setId(21L); + return 1; + }); + + AdminTokenService.IssuedToken issued = service() + .issueServiceToken("xiaoqu-ai", "同步任务用", 3L, null, 0L); + + ArgumentCaptor 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.getArgument(0).setId(22L); + return 1; + }); + + List rotated = service().rotate(11L, 1L); + + assertEquals((byte) 0, old.getStatus()); + org.mockito.Mockito.verify(tokenMapper).updateById(old); + assertEquals(1, rotated.size()); + ArgumentCaptor 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.getArgument(0).setId(23L); + return 1; + }); + + List rotated = service().rotate(11L, 1L); + + assertEquals(1, rotated.size()); + ArgumentCaptor 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)); + } } diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/config/FlywayMigrationTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/config/FlywayMigrationTest.java index e457950..6cb7471 100644 --- a/xiaoqu-agent-service/src/test/java/me/iiv/agent/config/FlywayMigrationTest.java +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/config/FlywayMigrationTest.java @@ -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)); } diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/IntegrationContextServiceTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/IntegrationContextServiceTest.java index 5e12e07..59c360d 100644 --- a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/IntegrationContextServiceTest.java +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/IntegrationContextServiceTest.java @@ -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; } diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/open/OpenKeyFilterTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/open/OpenKeyFilterTest.java new file mode 100644 index 0000000..fa5d3e6 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/open/OpenKeyFilterTest.java @@ -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 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()); + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/open/OpenTokenServiceTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/open/OpenTokenServiceTest.java index de01b4f..ea6db77 100644 --- a/xiaoqu-agent-service/src/test/java/me/iiv/agent/open/OpenTokenServiceTest.java +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/open/OpenTokenServiceTest.java @@ -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()); } } diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/security/AkAuthFilterTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/security/AkAuthFilterTest.java index 2e34aa0..ef9c8ea 100644 --- a/xiaoqu-agent-service/src/test/java/me/iiv/agent/security/AkAuthFilterTest.java +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/security/AkAuthFilterTest.java @@ -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()); } diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/security/AuthServiceTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/security/AuthServiceTest.java new file mode 100644 index 0000000..cbdcbfa --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/security/AuthServiceTest.java @@ -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 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")); + } +}