slark WIP snapshot (fence 1789174931867)

This commit is contained in:
xiaoqu
2026-09-12 09:02:12 +08:00
parent c596aa93b5
commit 2a324a7c67
24 changed files with 876 additions and 79 deletions
@@ -159,6 +159,13 @@ 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` 预建。
## 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)';
+8
View File
@@ -33,6 +33,14 @@ 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');
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 {
@@ -48,10 +50,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 +67,28 @@ public class OpenTokenService {
if (scope == null || !Byte.valueOf((byte) 1).equals(scope.getStatus())) {
bad(HttpStatus.BAD_REQUEST, "作用域不存在或已停用");
}
checkScopeBinding(client, scope, service);
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 +97,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 +115,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 +174,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: [operation, hardware, 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));
}
@@ -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,13 @@ 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);
@Mock UserMapper userMapper;
@Mock AgentScopeMapper scopeMapper;
@Mock AdminTokenService tokenService;
@@ -63,6 +70,13 @@ class OpenTokenServiceTest {
when(userMapper.selectList(any())).thenReturn(List.of(user));
}
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 +88,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 +106,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 +128,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 +136,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 +154,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 +164,124 @@ 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() {
when(scopeMapper.selectOne(any())).thenReturn(scope);
when(tokenService.findActiveServiceToken("xiaoqu-ai", 3L)).thenReturn(null);
AdminTokenService.TokenView view = new AdminTokenService.TokenView(
12L, "xqak_efgh5678", "open-api 服务签发(operation)", (byte) 1,
"service", "xiaoqu-ai", null, null, List.of(3L), List.of("运营"));
when(tokenService.issueServiceToken(eq("xiaoqu-ai"), anyString(), eq(3L), isNull(), eq(0L)))
.thenReturn(new AdminTokenService.IssuedToken("xqak_" + "c".repeat(48), view));
OpenTokenController.EnsureResult result = service.ensure(
new OpenTokenController.EnsureRequest(null, null, "operation", "service", "xiaoqu-ai"), SCOPED);
assertTrue(result.created());
assertEquals("service", result.principalType());
assertEquals("xiaoqu-ai", result.subjectName());
assertNull(result.userId());
assertNull(result.userName());
}
@Test
void serviceEnsureExistingTokenReturnsDecryptedPlaintext() {
when(scopeMapper.selectOne(any())).thenReturn(scope);
AgentApiKey existing = new AgentApiKey();
existing.setId(11L);
existing.setKeyPrefix("xqak_abcd1234");
existing.setKeyCipher("packed-cipher");
when(tokenService.findActiveServiceToken("xiaoqu-ai", 3L)).thenReturn(existing);
when(tokenCipher.decrypt("packed-cipher")).thenReturn("xqak_abcd1234ffff");
OpenTokenController.EnsureResult result = service.ensure(
new OpenTokenController.EnsureRequest(null, null, "operation", "service", "xiaoqu-ai"), SCOPED);
assertFalse(result.created());
assertEquals("xqak_abcd1234ffff", result.token());
org.mockito.Mockito.verify(tokenService, never())
.issueServiceToken(anyString(), anyString(), anyLong(), isNull(), anyLong());
}
@Test
void serviceEnsureWithoutCipherRotates() {
when(scopeMapper.selectOne(any())).thenReturn(scope);
AgentApiKey existing = new AgentApiKey();
existing.setId(11L);
existing.setKeyPrefix("xqak_abcd1234");
when(tokenService.findActiveServiceToken("xiaoqu-ai", 3L)).thenReturn(existing);
AdminTokenService.TokenView view = new AdminTokenService.TokenView(
12L, "xqak_efgh5678", "open-api 服务签发(operation)", (byte) 1,
"service", "xiaoqu-ai", null, null, List.of(3L), List.of("运营"));
when(tokenService.issueServiceToken(eq("xiaoqu-ai"), anyString(), eq(3L), isNull(), eq(0L)))
.thenReturn(new AdminTokenService.IssuedToken("xqak_" + "d".repeat(48), view));
OpenTokenController.EnsureResult result = service.ensure(
new OpenTokenController.EnsureRequest(null, null, "operation", "service", "xiaoqu-ai"), SCOPED);
org.mockito.Mockito.verify(tokenService).status(11L, (byte) 0, 0L);
assertTrue(result.created());
}
@Test
void serviceEnsureWithLegacyKeyForbidden() {
when(scopeMapper.selectOne(any())).thenReturn(scope);
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.ensure(
new OpenTokenController.EnsureRequest(null, null, "operation", "service", "xiaoqu-ai"), LEGACY));
assertEquals(403, error.getStatusCode().value());
}
@Test
void serviceEnsureRequiresServiceName() {
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.ensure(
new OpenTokenController.EnsureRequest(null, null, "operation", "service", null), SCOPED));
assertEquals(400, error.getStatusCode().value());
}
@Test
void serviceEnsureForbiddenWhenScopeNotBound() {
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(null, null, "operation", "service", "xiaoqu-ai"),
hardwareOnly));
assertEquals(403, error.getStatusCode().value());
}
}
@@ -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"));
}
}