From c10afd0cbec9ff6966821ce4590bbe34f59c5aa8 Mon Sep 17 00:00:00 2001 From: chenchuchuan <13554600537@163.com> Date: Fri, 16 Oct 2026 16:31:59 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(mcp):=20=E6=B7=BB=E5=8A=A0MCP=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E7=AE=A1=E7=90=86=E5=91=98=E6=9D=83=E9=99=90=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E4=B8=8E=E7=AB=AF=E7=82=B9=E9=89=B4=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 McpToolScanner 中增加管理类工具前缀配置,命中前缀的工具仅允许管理员调用 - 调用时优先复用请求上下文中的 TokenData,避免覆盖真实身份导致匿名执行 - 新增 buildDefaultTokenData 方法作为无请求上下文时的兜底 - 实现 isToolAllowed 与 isAdminOnlyTool 方法进行工具级授权判断 - 在 InterceptorConfig 中注册 McpAuthInterceptor,独立处理 MCP 端点鉴权 - 将 MCP 端点路径从主登录态拦截器中排除,避免密钥调用被 401 拦截 - common-botp 模块依赖版本更新,common-core 版本改为引用变量 - 在 common 父模块中注册 common-botp 子模块 --- .../tenantadmin/config/InterceptorConfig.java | 15 ++++- common/common-botp/pom.xml | 6 +- .../common/core/mcp/config/McpToolScanner.java | 67 +++++++++++++++++++++- common/pom.xml | 2 + 4 files changed, 83 insertions(+), 7 deletions(-) diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/config/InterceptorConfig.java b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/config/InterceptorConfig.java index 248604b..8e20f05 100644 --- a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/config/InterceptorConfig.java +++ b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/config/InterceptorConfig.java @@ -3,6 +3,7 @@ package apelet.tenantadmin.config; import apelet.common.openapi.config.OpenApiProperties; import apelet.common.openapi.interceptor.OpenApiAuthInterceptor; import apelet.tenantadmin.interceptor.AuthenticationInterceptor; +import apelet.tenantadmin.interceptor.McpAuthInterceptor; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; @@ -22,6 +23,8 @@ public class InterceptorConfig implements WebMvcConfigurer { private ObjectProvider openApiAuthInterceptorProvider; @Autowired private OpenApiProperties openApiProperties; + @Autowired + private McpAuthInterceptor mcpAuthInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { @@ -32,7 +35,13 @@ public class InterceptorConfig implements WebMvcConfigurer { // {prefix}/auth/token 是公开的签发接口。这里一并排除,避免调用方误带 Authorization 头时 // 被登录态拦截器抢先拦下(它注册在前,会先执行)。 .excludePathPatterns(openApiPrefix + "/v1/**") - .excludePathPatterns(openApiPrefix + "/auth/**"); + .excludePathPatterns(openApiPrefix + "/auth/**") + // MCP 端点由 McpAuthInterceptor 独立鉴权,支持登录态 JWT 与人员密钥两种凭证。 + // 必须在这里排除:HandlerInterceptor 没有"我处理完了后续跳过"的机制,若不排除, + // 未带 Authorization 的密钥调用会被登录态拦截器直接 401。 + // 注意两条都要写:/** 并不匹配不带尾斜杠的端点本身。 + .excludePathPatterns(McpAuthInterceptor.MCP_ENDPOINT_PATH) + .excludePathPatterns(McpAuthInterceptor.MCP_ENDPOINT_PATH + "/**"); // OpenAPI 对外开放路由鉴权({prefix}/v1/**):校验 accessToken 并把 TokenData 注入请求上下文。 // 注意 HandlerInterceptor 即使标了 @Component 也不会被 Spring Boot 自动注册,必须在这里显式挂上; // common-openapi.operationEnabled=false 时该 bean 不存在,getIfAvailable() 返回 null,跳过注册即可。 @@ -40,5 +49,9 @@ public class InterceptorConfig implements WebMvcConfigurer { if (openApiAuthInterceptor != null) { registry.addInterceptor(openApiAuthInterceptor).addPathPatterns(openApiPrefix + "/v1/**"); } + // MCP 端点鉴权(/tenantadmin/mcp 与 /tenantadmin/mcp/**)。 + registry.addInterceptor(mcpAuthInterceptor) + .addPathPatterns(McpAuthInterceptor.MCP_ENDPOINT_PATH) + .addPathPatterns(McpAuthInterceptor.MCP_ENDPOINT_PATH + "/**"); } } diff --git a/common/common-botp/pom.xml b/common/common-botp/pom.xml index 1fc7b12..f121380 100644 --- a/common/common-botp/pom.xml +++ b/common/common-botp/pom.xml @@ -17,17 +17,17 @@ apelet common-core - 1.0.0 + ${common-core.version} apelet common-generator - 1.0.3 + 1.0.5 apelet common-online - 1.0.3 + 1.0.6 compile diff --git a/common/common-core/src/main/java/apelet/common/core/mcp/config/McpToolScanner.java b/common/common-core/src/main/java/apelet/common/core/mcp/config/McpToolScanner.java index eaa15b8..63c995b 100644 --- a/common/common-core/src/main/java/apelet/common/core/mcp/config/McpToolScanner.java +++ b/common/common-core/src/main/java/apelet/common/core/mcp/config/McpToolScanner.java @@ -56,6 +56,11 @@ public class McpToolScanner { @Value("${apelet.mcp.default-user-id:0}") private Long defaultUserId; /** + * 管理类工具的名称前缀(逗号分隔),命中者仅允许管理员调用。默认覆盖 OpenAPI 应用/接口配置管理工具。 + */ + @Value("${apelet.mcp.admin-only-tool-prefixes:openapi_app_,openapi_config_}") + private String adminOnlyToolPrefixes; + /** * 已组装好的工具注册项(供 spec.tools 批量注入)。 */ @Getter @@ -326,9 +331,18 @@ public class McpToolScanner { */ private McpSchema.CallToolResult invoke(Object bean, Method method, List argInfos, Map args) { try { - //todo 这里要求带上token MCP 无 HTTP 登录态,注入默认用户 TokenData,避免服务内 takeFromRequest() 拿到 null 抛 NPE。 - TokenData tokenData = new TokenData(); - tokenData.setUserId(this.defaultUserId); + // 优先复用当前请求上下文中的 TokenData —— HTTP 调用链路上,拦截器已完成鉴权并注入真实身份, + // 这里若再造一个空对象会把真实身份覆盖掉,导致工具以匿名身份执行、下游数据权限取不到会话。 + // 只有在确实没有请求上下文时(如未经 HTTP 链路的直调),才回落默认用户,避免 takeFromRequest() 拿到 null 抛 NPE。 + TokenData existing = TokenData.takeFromRequest(); + TokenData tokenData = existing != null && existing.getUserId() != null ? existing : this.buildDefaultTokenData(); + McpToolClass ann = method.getAnnotation(McpToolClass.class); + String toolName = ann == null ? method.getName() : ann.name(); + if (!this.isToolAllowed(toolName, tokenData)) { + log.warn("MCP 工具 [{}] 调用被拒:当前身份无权调用管理类工具", toolName); + return new McpSchema.CallToolResult(Collections.singletonList( + new McpSchema.TextContent("当前身份无权调用该工具!管理类工具仅允许管理员调用。")), true); + } return this.withRequestContext(tokenData, () -> { Object[] invokeArgs = this.bindArgs(args, argInfos, method.getParameterCount()); Object result = method.invoke(bean, invokeArgs); @@ -343,6 +357,53 @@ public class McpToolScanner { } /** + * 构造兜底 TokenData:仅在请求上下文缺失时使用,保证服务内 {@code takeFromRequest()} 不返回 null。 + * + * @return 仅带默认用户Id的 TokenData。 + */ + private TokenData buildDefaultTokenData() { + TokenData tokenData = new TokenData(); + tokenData.setUserId(this.defaultUserId); + return tokenData; + } + + /** + * 工具级授权:管理类工具仅允许管理员调用。 + *

存在这条约束的原因:HTTP 调用链路上,MCP 端点已从登录态拦截器排除、不再做"菜单×URL"越权校验, + * 若不在工具层补一道,任何能通过端点鉴权的身份(含人员密钥)都能调用应用/接口配置等管理类工具, + * 进而读出应用明文密钥、提权为应用级凭证。

+ *

兜底 TokenData(匿名身份)的 isAdmin 为 null,同样会被拒绝。

+ * + * @param toolName 工具名称。 + * @param tokenData 当前调用身份。 + * @return 允许调用返回true,否则false。 + */ + private boolean isToolAllowed(String toolName, TokenData tokenData) { + if (!this.isAdminOnlyTool(toolName)) { + return true; + } + return Boolean.TRUE.equals(tokenData.getIsAdmin()); + } + + /** + * 判断工具是否属于"管理类",按配置的前缀匹配。 + * + * @param toolName 工具名称。 + * @return 属于管理类返回true,否则false。 + */ + private boolean isAdminOnlyTool(String toolName) { + if (StrUtil.isBlank(this.adminOnlyToolPrefixes) || StrUtil.isBlank(toolName)) { + return false; + } + for (String prefix : StrUtil.split(this.adminOnlyToolPrefixes, ',')) { + if (StrUtil.isNotBlank(prefix) && toolName.startsWith(prefix.trim())) { + return true; + } + } + return false; + } + + /** * 在指定 TokenData 的请求上下文下执行动作:当前线程没有请求上下文时挂动态代理的 request/response, * 执行完恢复原样。 */ diff --git a/common/pom.xml b/common/pom.xml index d79f21d..a2c592f 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -24,6 +24,8 @@ common-openapi common-core + + common-botp From 99694e8d20f4417200717ac57ab40a3a1b3dd1e0 Mon Sep 17 00:00:00 2001 From: chenchuchuan <13554600537@163.com> Date: Fri, 16 Oct 2026 16:32:14 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(mcp):=20=E6=B7=BB=E5=8A=A0MCP=E7=AB=AF?= =?UTF-8?q?=E7=82=B9=E4=BA=BA=E5=91=98=E5=AF=86=E9=92=A5=E9=89=B4=E6=9D=83?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增McpAuthInterceptor拦截器,支持JWT和人员密钥两种鉴权方式 - 新增SysUserSecret实体及对应的Mapper、Service、Controller完整CRUD - 实现密钥签发、轮换、启用/停用管理接口 - 密钥口令仅存SHA-256摘要,明文只在签发/轮换响应中出现一次 - 引入会话复用机制,避免密钥分支每次请求重建会话上下文 - 响应中自动脱敏secretHash和手机号字段 --- .../interceptor/McpAuthInterceptor.java | 262 +++++++++++++++++++++ .../upms/controller/SysUserSecretController.java | 171 ++++++++++++++ .../tenantadmin/upms/dao/SysUserSecretMapper.java | 25 ++ .../upms/dao/mapper/SysUserSecretMapper.xml | 66 ++++++ .../tenantadmin/upms/model/SysUserSecret.java | 98 ++++++++ .../upms/model/constant/SysUserSecretStatus.java | 44 ++++ .../upms/service/SysUserSecretService.java | 72 ++++++ .../upms/service/UserSessionContextService.java | 53 +++++ .../service/impl/SysUserSecretServiceImpl.java | 225 ++++++++++++++++++ .../impl/UserSessionContextServiceImpl.java | 134 +++++++++++ .../tenantadmin/upms/vo/SysUserSecretIssueVo.java | 47 ++++ 11 files changed, 1197 insertions(+) create mode 100644 application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/interceptor/McpAuthInterceptor.java create mode 100644 application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/controller/SysUserSecretController.java create mode 100644 application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/dao/SysUserSecretMapper.java create mode 100644 application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/dao/mapper/SysUserSecretMapper.xml create mode 100644 application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/model/SysUserSecret.java create mode 100644 application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/model/constant/SysUserSecretStatus.java create mode 100644 application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/SysUserSecretService.java create mode 100644 application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/UserSessionContextService.java create mode 100644 application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/impl/SysUserSecretServiceImpl.java create mode 100644 application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/impl/UserSessionContextServiceImpl.java create mode 100644 application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/vo/SysUserSecretIssueVo.java diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/interceptor/McpAuthInterceptor.java b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/interceptor/McpAuthInterceptor.java new file mode 100644 index 0000000..b74bfee --- /dev/null +++ b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/interceptor/McpAuthInterceptor.java @@ -0,0 +1,262 @@ +package apelet.tenantadmin.interceptor; + +import apelet.common.core.constant.AppDeviceType; +import apelet.common.core.constant.ApplicationConstant; +import apelet.common.core.constant.ErrorCodeEnum; +import apelet.common.core.object.ResponseResult; +import apelet.common.core.object.TokenData; +import apelet.common.core.util.JwtUtil; +import apelet.common.core.util.RedisKeyUtil; +import apelet.common.openapi.model.OpenApiApp; +import apelet.common.openapi.service.OpenApiAppService; +import apelet.tenantadmin.config.ApplicationConfig; +import apelet.tenantadmin.upms.model.SysUser; +import apelet.tenantadmin.upms.model.SysUserSecret; +import apelet.tenantadmin.upms.service.SysUserSecretService; +import apelet.tenantadmin.upms.service.SysUserService; +import apelet.tenantadmin.upms.service.UserSessionContextService; +import cn.hutool.core.util.StrUtil; +import com.alibaba.fastjson.JSON; +import io.jsonwebtoken.Claims; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RBucket; +import org.redisson.api.RedissonClient; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletRequestWrapper; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.PrintWriter; +import java.util.concurrent.TimeUnit; + +/** + * MCP 端点({@code /tenantadmin/mcp})的鉴权拦截器。 + *

支持两种调用方身份,按顺序判定:

+ *
    + *
  1. {@code Authorization: Bearer } —— 登录态,兼容现有调用方;
  2. + *
  3. {@code X-Mcp-User} + {@code X-Mcp-Secret} —— 人员级密钥,格式为 "keyId.secret",锁定到具体账号。
  4. + *
+ *

两者都带时以 Bearer 优先,避免调用方主动降级到密钥分支。

+ *

校验通过后统一注入 {@link TokenData},下游(在线表单、数据权限、流程)对来源无感知。

+ *

注意:本拦截器注册后,{@code /tenantadmin/mcp} 已从 AuthenticationInterceptor 的路径中排除, + * 因此 MCP 路径不做"菜单×URL"越权校验;数据权限(DATA_PERM)仍照常生效。

+ * + * @author chenchuchuan + * @date 2026-10-14 + */ +@Slf4j +@Component +public class McpAuthInterceptor implements HandlerInterceptor { + + /** + * MCP 端点路径。注意该路径硬编码在 common-core 的 McpStreamableController 上,两边需保持一致。 + */ + public static final String MCP_ENDPOINT_PATH = "/tenantadmin/mcp"; + + /** + * 人员手机号请求头。 + */ + public static final String HEADER_MCP_USER = "X-Mcp-User"; + /** + * 人员密钥请求头,值为 "keyId.secret"。 + */ + public static final String HEADER_MCP_SECRET = "X-Mcp-Secret"; + + /** + * 会话复用缓存前缀:一个账号在 TTL 内复用同一个会话,避免每个请求都重建会话上下文(含查库与多次Redis写入)。 + */ + private static final String MCP_SESSION_KEY_PREFIX = "MCP_SESSION:"; + /** + * 会话复用TTL(秒)。刻意短于会话本身的有效期,以限制角色/数据权限变更后的缓存陈旧窗口。 + */ + private static final long MCP_SESSION_TTL_SECONDS = 1800L; + /** + * JWT 中承载会话Id的 claim 名称,需与登录链路保持一致。 + */ + private static final String CLAIM_SESSION_ID = "sessionId"; + + @Autowired + private ApplicationConfig appConfig; + @Autowired + private RedissonClient redissonClient; + @Autowired + private SysUserSecretService sysUserSecretService; + @Autowired + private SysUserService sysUserService; + @Autowired + private OpenApiAppService openApiAppService; + @Autowired + private UserSessionContextService userSessionContextService; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + String authorization = request.getHeader(appConfig.getTokenHeaderKey()); + TokenData tokenData = null; + if (StrUtil.isNotBlank(authorization)) { + tokenData = this.authenticateByJwt(authorization); + if (tokenData == null) { + this.outputUnauthorized(response, "登录会话已过期或尚未登录,请重新登录!"); + return false; + } + } else { + String mobile = request.getHeader(HEADER_MCP_USER); + String credential = request.getHeader(HEADER_MCP_SECRET); + if (StrUtil.isBlank(mobile) && StrUtil.isBlank(credential)) { + this.outputUnauthorized(response, "缺少调用凭证,请提供 Authorization 或人员密钥!"); + return false; + } + ResponseResult authResult = sysUserSecretService.authenticate(mobile, credential); + if (!authResult.isSuccess()) { + log.warn("MCP 密钥鉴权失败,keyId={}, mobile={}, 原因={}", this.maskKeyId(credential), mobile, authResult.getErrorMessage()); + this.outputUnauthorized(response, authResult.getErrorMessage()); + return false; + } + SysUser user = sysUserService.getById(authResult.getData().getUserId()); + if (user == null) { + this.outputUnauthorized(response, "凭证无效!"); + return false; + } + tokenData = this.resolveKeyBranchTokenData(user, authResult.getData()); + } + TokenData.addToRequest(tokenData); + // 在线表单引擎需要 MenuId 头来选取数据权限规则分支;MCP 无菜单上下文,统一按"通用规则"处理。 + this.wrapRequestWithMenuId(request); + return true; + } + + /** + * 校验登录态 JWT 并取回会话中的 TokenData。口径与 AuthenticationInterceptor 保持一致。 + * + * @param token 请求头中的 JWT(含 Bearer 前缀)。 + * @return 校验通过返回 TokenData,否则返回null。 + */ + private TokenData authenticateByJwt(String token) { + Claims claims = JwtUtil.parseToken(token, appConfig.getTokenSigningKey()); + if (JwtUtil.isNullOrExpired(claims)) { + return null; + } + String sessionId = (String) claims.get(CLAIM_SESSION_ID); + if (StrUtil.isBlank(sessionId)) { + return null; + } + RBucket sessionData = redissonClient.getBucket(RedisKeyUtil.makeSessionIdKey(sessionId)); + if (!sessionData.isExists()) { + return null; + } + TokenData tokenData = JSON.parseObject(sessionData.get(), TokenData.class); + if (tokenData != null) { + tokenData.setToken(token); + } + return tokenData; + } + + /** + * 解决密钥分支的 TokenData:优先复用该账号已有的 MCP 会话,避免每个请求都重建会话上下文。 + * + * @param user 密钥绑定的账号。 + * @param userSecret 已通过校验的密钥记录。 + * @return 可直接注入请求上下文的 TokenData。 + */ + private TokenData resolveKeyBranchTokenData(SysUser user, SysUserSecret userSecret) { + RBucket sessionIdBucket = redissonClient.getBucket(MCP_SESSION_KEY_PREFIX + user.getUserId()); + String sessionId = sessionIdBucket.get(); + if (StrUtil.isNotBlank(sessionId)) { + RBucket sessionData = redissonClient.getBucket(RedisKeyUtil.makeSessionIdKey(sessionId)); + if (sessionData.isExists()) { + TokenData cached = JSON.parseObject(sessionData.get(), TokenData.class); + if (cached != null) { + return cached; + } + } + } + String appCode = this.resolveAppCode(userSecret.getAppId()); + String newSessionId = userSessionContextService.generateSessionId(user.getLoginName(), AppDeviceType.WEB); + TokenData tokenData = userSessionContextService.buildTokenData(user, newSessionId, AppDeviceType.WEB, appCode); + userSessionContextService.putSessionContext(tokenData, user); + sessionIdBucket.set(newSessionId, MCP_SESSION_TTL_SECONDS, TimeUnit.SECONDS); + return tokenData; + } + + /** + * 由密钥绑定的应用Id取回应用编码,供下游的开放接口授权校验使用。 + * + * @param appId 应用Id。 + * @return 应用编码;应用不存在或已停用时返回null,由下游给出"无权调用"的可读错误。 + */ + private String resolveAppCode(Long appId) { + if (appId == null) { + return null; + } + OpenApiApp app = openApiAppService.getById(appId); + if (app == null || app.getStatus() == null || app.getStatus() != 1) { + log.warn("MCP 密钥绑定的应用不存在或已停用,appId={}", appId); + return null; + } + return app.getAppCode(); + } + + /** + * 用 HttpServletRequestWrapper 覆盖 MenuId 请求头。 + *

拦截器无法修改已传入的 request 对象,只能包装后替换 RequestContextHolder 中的引用, + * 使下游通过 ContextUtil.getHttpRequest().getHeader("MenuId") 能取到值。

+ * + * @param request 当前请求。 + */ + private void wrapRequestWithMenuId(HttpServletRequest request) { + if (StrUtil.isNotBlank(request.getHeader(ApplicationConstant.HTTP_HEADER_MENU_ID))) { + return; + } + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes == null) { + return; + } + RequestContextHolder.setRequestAttributes( + new ServletRequestAttributes(new MenuIdHeaderRequestWrapper(request), attributes.getResponse())); + } + + /** + * 仅覆盖 MenuId 请求头,其余行为全部委托给原请求(含 attribute 读写,TokenData 依赖它)。 + */ + private static class MenuIdHeaderRequestWrapper extends HttpServletRequestWrapper { + + MenuIdHeaderRequestWrapper(HttpServletRequest request) { + super(request); + } + + @Override + public String getHeader(String name) { + if (ApplicationConstant.HTTP_HEADER_MENU_ID.equalsIgnoreCase(name)) { + return ApplicationConstant.DATA_PERM_ALL_MENU_ID; + } + return super.getHeader(name); + } + } + + /** + * 日志中只保留 keyId,避免密钥口令落入日志。 + * + * @param credential 完整凭证(keyId.secret)。 + * @return 仅含 keyId 的字符串。 + */ + private String maskKeyId(String credential) { + if (StrUtil.isBlank(credential)) { + return ""; + } + return StrUtil.subBefore(credential, SysUserSecretService.CREDENTIAL_SEPARATOR, false); + } + + private void outputUnauthorized(HttpServletResponse response, String message) throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setHeader("WWW-Authenticate", "Bearer"); + response.setContentType("application/json; charset=utf-8"); + PrintWriter out = response.getWriter(); + out.print(JSON.toJSONString(ResponseResult.error(ErrorCodeEnum.UNAUTHORIZED_LOGIN, message))); + out.flush(); + out.close(); + } +} diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/controller/SysUserSecretController.java b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/controller/SysUserSecretController.java new file mode 100644 index 0000000..27e4cf7 --- /dev/null +++ b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/controller/SysUserSecretController.java @@ -0,0 +1,171 @@ +package apelet.tenantadmin.upms.controller; + +import apelet.common.core.annotation.MyRequestBody; +import apelet.common.core.constant.ErrorCodeEnum; +import apelet.common.core.object.MyOrderParam; +import apelet.common.core.object.MyPageData; +import apelet.common.core.object.MyPageParam; +import apelet.common.core.object.ResponseResult; +import apelet.common.core.util.MyCommonUtil; +import apelet.common.core.util.MyPageUtil; +import apelet.common.log.annotation.OperationLog; +import apelet.common.log.model.constant.SysOperationLogType; +import apelet.tenantadmin.upms.model.SysUserSecret; +import apelet.tenantadmin.upms.model.constant.SysUserSecretStatus; +import apelet.tenantadmin.upms.service.SysUserSecretService; +import apelet.tenantadmin.upms.vo.SysUserSecretIssueVo; +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.util.StrUtil; +import com.github.pagehelper.Page; +import com.github.pagehelper.page.PageMethod; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Date; +import java.util.List; + +/** + * 用户对外密钥管理接口。 + *

密钥是"一人一把、锁定到具体账号"的长期凭证,签发与停用属于提权操作, + * 接口本身依赖登录态 + URL 权限控制,仅管理员角色应被授予。

+ *

响应中永远不返回 secretHash;明文 secret 只在签发/轮换的响应里出现一次, + * 且对应操作日志显式关闭了响应体记录(saveResponse=false),避免明文密钥落到日志。

+ * + * @author chenchuchuan + * @date 2026-10-14 + */ +@Tag(name = "对外密钥管理") +@Slf4j +@RestController +@RequestMapping("/tenantadmin/upms/sysUserSecret") +public class SysUserSecretController { + + @Autowired + private SysUserSecretService sysUserSecretService; + + /** + * 对外密钥分页列表。 + * + * @param sysUserSecretFilter 过滤条件(支持 loginName/mobile 模糊、userId/appId/status 精确)。 + * @param orderParam 排序参数。 + * @param pageParam 分页参数。 + * @return 应答结果对象,包含分页后的密钥列表。 + */ + @PostMapping("/list") + public ResponseResult> list( + @MyRequestBody SysUserSecret sysUserSecretFilter, + @MyRequestBody MyOrderParam orderParam, + @MyRequestBody MyPageParam pageParam) { + if (pageParam != null) { + PageMethod.startPage(pageParam.getPageNum(), pageParam.getPageSize()); + } + String orderBy = MyOrderParam.buildOrderBy(orderParam, SysUserSecret.class); + List secretList = sysUserSecretService.getSysUserSecretList(sysUserSecretFilter, orderBy); + long totalCount = 0L; + if (secretList instanceof Page) { + totalCount = ((Page) secretList).getTotal(); + } + secretList.forEach(this::maskForResponse); + return ResponseResult.success(MyPageUtil.makeResponseData(secretList, totalCount)); + } + + /** + * 查看指定密钥的详情。不返回 secretHash。 + * + * @param userSecretId 密钥记录主键Id。 + * @return 应答结果对象,包含密钥详情。 + */ + @PostMapping("/view") + public ResponseResult view(@MyRequestBody Long userSecretId) { + if (MyCommonUtil.existBlankArgument(userSecretId)) { + return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST); + } + SysUserSecret userSecret = sysUserSecretService.getById(userSecretId); + if (userSecret == null) { + return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, "对外密钥不存在,请刷新后重试!"); + } + this.maskForResponse(userSecret); + return ResponseResult.success(userSecret); + } + + /** + * 为指定账号签发一把对外密钥。明文只在本响应中出现一次,之后无法再查。 + * + * @param userId 绑定的账号Id。 + * @param appId 绑定的OpenAPI应用Id(决定该密钥可调用的开放接口范围)。 + * @param expireTime 过期时间字符串,留空表示永不过期。 + * @return 应答结果对象,包含 keyId、明文 secret 与完整凭证。 + */ + @OperationLog(type = SysOperationLogType.ADD, saveResponse = false) + @PostMapping("/issue") + public ResponseResult issue( + @MyRequestBody Long userId, + @MyRequestBody Long appId, + @MyRequestBody String expireTime) { + if (MyCommonUtil.existBlankArgument(userId, appId)) { + return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST); + } + Date expireDate = null; + if (StrUtil.isNotBlank(expireTime)) { + expireDate = DateUtil.parse(expireTime); + } + return ResponseResult.success(sysUserSecretService.issue(userId, appId, expireDate)); + } + + /** + * 轮换指定密钥的口令:keyId 不变,旧 secret 立即失效。明文只在本响应中出现一次。 + * + * @param userSecretId 密钥记录主键Id。 + * @return 应答结果对象,包含新的明文 secret 与完整凭证。 + */ + @OperationLog(type = SysOperationLogType.UPDATE, saveResponse = false) + @PostMapping("/resetSecret") + public ResponseResult resetSecret(@MyRequestBody Long userSecretId) { + if (MyCommonUtil.existBlankArgument(userSecretId)) { + return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST); + } + SysUserSecretIssueVo issueVo = sysUserSecretService.resetSecret(userSecretId); + if (issueVo == null) { + return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, "对外密钥不存在,请刷新后重试!"); + } + return ResponseResult.success(issueVo); + } + + /** + * 启用/停用指定密钥。停用后该密钥的调用一律拒绝。 + * + * @param userSecretId 密钥记录主键Id。 + * @param status 目标状态(1: 启用 0: 停用)。 + * @return 应答结果对象。 + */ + @OperationLog(type = SysOperationLogType.UPDATE, saveResponse = false) + @PostMapping("/updateStatus") + public ResponseResult updateStatus(@MyRequestBody Long userSecretId, @MyRequestBody Integer status) { + if (MyCommonUtil.existBlankArgument(userSecretId, status)) { + return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST); + } + if (!SysUserSecretStatus.isValid(status)) { + return ResponseResult.error(ErrorCodeEnum.INVALID_STATUS_ARGUMENT); + } + if (!sysUserSecretService.updateStatus(userSecretId, status)) { + return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, "对外密钥不存在,请刷新后重试!"); + } + return ResponseResult.success(); + } + + /** + * 抹掉不应对外返回的字段:密钥摘要一律清空,手机号脱敏。 + * + * @param userSecret 待处理的对象。 + */ + private void maskForResponse(SysUserSecret userSecret) { + userSecret.setSecretHash(null); + if (StrUtil.isNotBlank(userSecret.getMobile())) { + userSecret.setMobile(StrUtil.hide(userSecret.getMobile(), 3, 7)); + } + } +} diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/dao/SysUserSecretMapper.java b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/dao/SysUserSecretMapper.java new file mode 100644 index 0000000..852b7fb --- /dev/null +++ b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/dao/SysUserSecretMapper.java @@ -0,0 +1,25 @@ +package apelet.tenantadmin.upms.dao; + +import apelet.common.core.base.dao.BaseDaoMapper; +import apelet.tenantadmin.upms.model.SysUserSecret; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 用户对外密钥数据访问操作接口。 + * + * @author chenchuchuan + * @date 2026-10-14 + */ +public interface SysUserSecretMapper extends BaseDaoMapper { + + /** + * 获取用户对外密钥列表(带账号信息)。 + * + * @param sysUserSecretFilter 过滤对象。 + * @param orderBy 排序字符串。 + * @return 过滤后的对外密钥列表。 + */ + List getSysUserSecretList(@Param("sysUserSecretFilter") SysUserSecret sysUserSecretFilter, @Param("orderBy") String orderBy); +} diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/dao/mapper/SysUserSecretMapper.xml b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/dao/mapper/SysUserSecretMapper.xml new file mode 100644 index 0000000..5e8cb57 --- /dev/null +++ b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/dao/mapper/SysUserSecretMapper.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + AND xy_sys_user_secret.user_id = #{sysUserSecretFilter.userId} + + + AND xy_sys_user_secret.app_id = #{sysUserSecretFilter.appId} + + + AND xy_sys_user_secret.status = #{sysUserSecretFilter.status} + + + + AND IFNULL(xy_sys_user.login_name, '') LIKE #{safeLoginName} + + + + AND IFNULL(xy_sys_user.mobile, '') LIKE #{safeMobile} + + + AND xy_sys_user_secret.deleted_flag = ${@apelet.common.core.constant.GlobalDeletedFlag@NORMAL} + + + + diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/model/SysUserSecret.java b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/model/SysUserSecret.java new file mode 100644 index 0000000..3489e8b --- /dev/null +++ b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/model/SysUserSecret.java @@ -0,0 +1,98 @@ +package apelet.tenantadmin.upms.model; + +import apelet.common.core.base.model.BaseModel; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.Date; + +/** + * 用户对外密钥实体对象。 + *

一条记录绑定唯一的 (userId, appId):userId 决定"你是谁",appId 决定"你能调哪些开放接口"。 + * keyId 明文存储用于定位与审计,secret 只存 SHA-256 摘要,对外凭证形态为 "keyId.secret"。

+ * + * @author chenchuchuan + * @date 2026-10-14 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName(value = "xy_sys_user_secret") +public class SysUserSecret extends BaseModel { + + /** + * 主键Id。 + */ + @TableId(value = "user_secret_id") + private Long userSecretId; + + /** + * 对外可见的密钥标识(明文,用于定位与审计)。 + */ + @TableField(value = "key_id") + private String keyId; + + /** + * 绑定的用户Id。 + */ + @TableField(value = "user_id") + private Long userId; + + /** + * 绑定的OpenAPI应用Id(决定该密钥可调用的开放接口范围)。 + */ + @TableField(value = "app_id") + private Long appId; + + /** + * 密钥口令的SHA-256十六进制摘要。 + */ + @TableField(value = "secret_hash") + private String secretHash; + + /** + * 状态(1: 启用 0: 停用)。 + */ + @TableField(value = "status") + private Integer status; + + /** + * 过期时间(NULL表示永不过期)。 + */ + @TableField(value = "expire_time") + private Date expireTime; + + /** + * 最后一次使用时间。 + */ + @TableField(value = "last_used_time") + private Date lastUsedTime; + + /** + * 逻辑删除标记字段(1: 正常 -1: 已删除)。 + */ + @TableLogic + @TableField(value = "deleted_flag") + private Integer deletedFlag; + + /** + * 绑定的账号(非数据库字段,仅用于列表展示)。 + */ + @TableField(exist = false) + private String loginName; + + /** + * 绑定的账号显示名(非数据库字段,仅用于列表展示)。 + */ + @TableField(exist = false) + private String showName; + + /** + * 绑定的账号手机号(非数据库字段,仅用于列表展示,需脱敏)。 + */ + @TableField(exist = false) + private String mobile; +} diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/model/constant/SysUserSecretStatus.java b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/model/constant/SysUserSecretStatus.java new file mode 100644 index 0000000..215505b --- /dev/null +++ b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/model/constant/SysUserSecretStatus.java @@ -0,0 +1,44 @@ +package apelet.tenantadmin.upms.model.constant; + +import java.util.HashMap; +import java.util.Map; + +/** + * 用户对外密钥状态常量对象。 + * + * @author chenchuchuan + * @date 2026-10-14 + */ +public final class SysUserSecretStatus { + + /** + * 已停用。 + */ + public static final int STATUS_DISABLED = 0; + /** + * 启用中。 + */ + public static final int STATUS_ENABLED = 1; + + private static final Map DICT_MAP = new HashMap<>(2); + static { + DICT_MAP.put(STATUS_DISABLED, "已停用"); + DICT_MAP.put(STATUS_ENABLED, "启用中"); + } + + /** + * 判断参数是否为当前常量字典的合法值。 + * + * @param value 待验证的参数值。 + * @return 合法返回true,否则false。 + */ + public static boolean isValid(Integer value) { + return value != null && DICT_MAP.containsKey(value); + } + + /** + * 私有构造函数,明确标识该常量类的作用。 + */ + private SysUserSecretStatus() { + } +} diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/SysUserSecretService.java b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/SysUserSecretService.java new file mode 100644 index 0000000..84e9acd --- /dev/null +++ b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/SysUserSecretService.java @@ -0,0 +1,72 @@ +package apelet.tenantadmin.upms.service; + +import apelet.common.core.base.service.IBaseService; +import apelet.common.core.object.ResponseResult; +import apelet.tenantadmin.upms.model.SysUserSecret; +import apelet.tenantadmin.upms.vo.SysUserSecretIssueVo; + +import java.util.Date; +import java.util.List; + +/** + * 用户对外密钥数据服务接口。 + *

对外凭证形态为 "keyId.secret":keyId 明文存储用于定位,secret 只存 SHA-256 摘要。 + * 校验时由 keyId 定位记录、再由手机号比对确认调用方身份,密钥持有者无法代表他人。

+ * + * @author chenchuchuan + * @date 2026-10-14 + */ +public interface SysUserSecretService extends IBaseService { + + /** + * 凭证中 keyId 与 secret 的分隔符。 + */ + String CREDENTIAL_SEPARATOR = "."; + + /** + * 获取用户对外密钥列表(带账号信息)。 + * + * @param filter 过滤对象。 + * @param orderBy 排序字符串。 + * @return 过滤后的对外密钥列表。 + */ + List getSysUserSecretList(SysUserSecret filter, String orderBy); + + /** + * 为指定用户签发一把对外密钥,明文 secret 只在返回值中出现一次。 + * + * @param userId 绑定的用户Id。 + * @param appId 绑定的OpenAPI应用Id,决定该密钥可调用的开放接口范围。 + * @param expireTime 过期时间,null表示永不过期。 + * @return 签发结果,含明文 secret。 + */ + SysUserSecretIssueVo issue(Long userId, Long appId, Date expireTime); + + /** + * 轮换密钥口令:keyId 保持不变,旧 secret 立即失效。 + * + * @param userSecretId 密钥记录主键Id。 + * @return 轮换结果,含新的明文 secret;记录不存在时返回null。 + */ + SysUserSecretIssueVo resetSecret(Long userSecretId); + + /** + * 启用/停用指定密钥。 + * + * @param userSecretId 密钥记录主键Id。 + * @param status 目标状态。 + * @return 更新成功返回true,否则false。 + */ + boolean updateStatus(Long userSecretId, Integer status); + + /** + * 校验"手机号 + 密钥"。 + *

校验顺序:keyId 定位 → 状态与有效期 → secret 摘要比对 → 手机号与绑定账号比对。 + * "密钥不存在"与"secret 不匹配"返回同一错误信息,不泄露凭证存在性。

+ * + * @param mobile 调用方声明的手机号。 + * @param credential 完整凭证,格式为 keyId.secret。 + * @return 校验通过时 data 为对应的密钥记录,否则返回错误。 + */ + ResponseResult authenticate(String mobile, String credential); +} diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/UserSessionContextService.java b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/UserSessionContextService.java new file mode 100644 index 0000000..c8c76fb --- /dev/null +++ b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/UserSessionContextService.java @@ -0,0 +1,53 @@ +package apelet.tenantadmin.upms.service; + +import apelet.common.core.object.TokenData; +import apelet.tenantadmin.upms.model.SysUser; + +/** + * 用户会话上下文构建服务接口。 + *

把"登录成功后需要落到 Redis 的那几份缓存"收敛到一处,供登录链路与 MCP 密钥鉴权链路共用, + * 避免各自实现导致口径不一致。

+ * + * @author chenchuchuan + * @date 2026-10-14 + */ +public interface UserSessionContextService { + + /** + * 生成会话Id,规则与登录链路保持一致:登录名 + 设备类型 + UUID。 + * + * @param loginName 登录名。 + * @param deviceType 设备类型。 + * @return 会话Id。 + */ + String generateSessionId(String loginName, int deviceType); + + /** + * 依据账号信息组装 TokenData(含部门/岗位/角色)。 + * + * @param user 账号对象。 + * @param sessionId 会话Id。 + * @param deviceType 设备类型。 + * @param appCode 第三方开放应用编码,非开放调用时传null。 + * @return 组装好的 TokenData。 + */ + TokenData buildTokenData(SysUser user, String sessionId, int deviceType, String appCode); + + /** + * 写入会话上下文缓存。 + *

包含:SESSIONID(会话)、DATA_PERM(数据权限)、WHITELISTPERM(白名单URL)。

+ *

不写 PERM(URL权限集合):本方法当前服务于 MCP 路径,该路径已从登录态拦截器排除、 + * 不做"菜单×URL"越权校验;登录链路若复用本方法需另行写入 PERM。

+ * + * @param tokenData 已组装好的 TokenData。 + * @param user 账号对象。 + */ + void putSessionContext(TokenData tokenData, SysUser user); + + /** + * 清理指定会话的全部上下文缓存。 + * + * @param sessionId 会话Id。 + */ + void removeSessionContext(String sessionId); +} diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/impl/SysUserSecretServiceImpl.java b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/impl/SysUserSecretServiceImpl.java new file mode 100644 index 0000000..8a1252a --- /dev/null +++ b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/impl/SysUserSecretServiceImpl.java @@ -0,0 +1,225 @@ +package apelet.tenantadmin.upms.service.impl; + +import apelet.common.core.annotation.MyDataSource; +import apelet.common.core.base.dao.BaseDaoMapper; +import apelet.common.core.base.service.BaseService; +import apelet.common.core.constant.ErrorCodeEnum; +import apelet.common.core.constant.GlobalDeletedFlag; +import apelet.common.core.exception.MyRuntimeException; +import apelet.common.core.object.ResponseResult; +import apelet.common.core.util.MyModelUtil; +import apelet.common.sequence.wrapper.IdGeneratorWrapper; +import apelet.tenantadmin.config.DataSourceType; +import apelet.tenantadmin.upms.dao.SysUserSecretMapper; +import apelet.tenantadmin.upms.model.SysUser; +import apelet.tenantadmin.upms.model.SysUserSecret; +import apelet.tenantadmin.upms.model.constant.SysUserSecretStatus; +import apelet.tenantadmin.upms.model.constant.SysUserStatus; +import apelet.tenantadmin.upms.service.SysUserSecretService; +import apelet.tenantadmin.upms.service.SysUserService; +import apelet.tenantadmin.upms.vo.SysUserSecretIssueVo; +import cn.hutool.core.util.HexUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Date; +import java.util.List; +import java.util.Objects; + +/** + * 用户对外密钥数据服务类。 + *

对外凭证形态为 "keyId.secret":keyId 明文存储用于定位与审计,secret 只存 SHA-256 摘要。 + * 定位永远走 keyId(唯一索引),手机号只做比对,不参与定位——因为 xy_sys_user.mobile 无唯一索引。

+ * + * @author chenchuchuan + * @date 2026-10-14 + */ +@Slf4j +@MyDataSource(DataSourceType.TENANT_ADMIN) +@Service("sysUserSecretService") +public class SysUserSecretServiceImpl extends BaseService implements SysUserSecretService { + + /** + * 生成 keyId 与 secret 用的字符集。只使用大小写字母和数字,便于复制粘贴。 + */ + private static final String RANDOM_ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + private static final int KEY_ID_LENGTH = 32; + private static final int SECRET_LENGTH = 32; + /** + * 密钥属于长期凭证,必须使用密码学安全的随机源。 + */ + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + @Autowired + private SysUserSecretMapper sysUserSecretMapper; + @Autowired + private SysUserService sysUserService; + @Autowired + private IdGeneratorWrapper idGenerator; + + /** + * 返回主对象的Mapper对象。 + * + * @return 主对象的Mapper对象。 + */ + @Override + protected BaseDaoMapper mapper() { + return sysUserSecretMapper; + } + + @Override + public List getSysUserSecretList(SysUserSecret filter, String orderBy) { + return sysUserSecretMapper.getSysUserSecretList(filter, orderBy); + } + + @Override + public SysUserSecretIssueVo issue(Long userId, Long appId, Date expireTime) { + SysUser user = sysUserService.getById(userId); + if (user == null) { + throw new MyRuntimeException("签发失败,指定的用户不存在!"); + } + if (StrUtil.isBlank(user.getMobile())) { + throw new MyRuntimeException("签发失败,该账号未维护手机号,无法用于外部调用身份比对!"); + } + String keyId = this.randomString(KEY_ID_LENGTH); + String secret = this.randomString(SECRET_LENGTH); + SysUserSecret userSecret = new SysUserSecret(); + userSecret.setUserSecretId(idGenerator.nextLongId()); + userSecret.setKeyId(keyId); + userSecret.setUserId(userId); + userSecret.setAppId(appId); + userSecret.setSecretHash(this.sha256Hex(secret)); + userSecret.setStatus(SysUserSecretStatus.STATUS_ENABLED); + userSecret.setExpireTime(expireTime); + MyModelUtil.fillCommonsForInsert(userSecret); + userSecret.setDeletedFlag(GlobalDeletedFlag.NORMAL); + sysUserSecretMapper.insert(userSecret); + return this.buildIssueVo(userSecret, secret); + } + + @Override + public SysUserSecretIssueVo resetSecret(Long userSecretId) { + SysUserSecret original = this.getById(userSecretId); + if (original == null) { + return null; + } + String secret = this.randomString(SECRET_LENGTH); + SysUserSecret updating = new SysUserSecret(); + updating.setUserSecretId(userSecretId); + updating.setSecretHash(this.sha256Hex(secret)); + MyModelUtil.fillCommonsForUpdate(updating, original); + sysUserSecretMapper.updateById(updating); + return this.buildIssueVo(original, secret); + } + + @Override + public boolean updateStatus(Long userSecretId, Integer status) { + if (!SysUserSecretStatus.isValid(status)) { + throw new MyRuntimeException("停用失败,无效的状态参数值!"); + } + SysUserSecret original = this.getById(userSecretId); + if (original == null) { + return false; + } + SysUserSecret updating = new SysUserSecret(); + updating.setUserSecretId(userSecretId); + updating.setStatus(status); + MyModelUtil.fillCommonsForUpdate(updating, original); + return sysUserSecretMapper.updateById(updating) == 1; + } + + @Override + public ResponseResult authenticate(String mobile, String credential) { + if (StrUtil.hasBlank(mobile, credential)) { + return ResponseResult.error(ErrorCodeEnum.UNAUTHORIZED_LOGIN, "凭证缺失,请提供手机号与密钥!"); + } + int separatorIndex = credential.indexOf(CREDENTIAL_SEPARATOR); + if (separatorIndex <= 0 || separatorIndex == credential.length() - 1) { + // 与"密钥无效"保持同一个提示,不泄露凭证格式信息。 + return ResponseResult.error(ErrorCodeEnum.INVALID_ACCESS_TOKEN, "凭证无效!"); + } + String keyId = credential.substring(0, separatorIndex); + String secret = credential.substring(separatorIndex + 1); + SysUserSecret keyFilter = new SysUserSecret(); + keyFilter.setKeyId(keyId); + SysUserSecret userSecret = this.getOne(new QueryWrapper<>(keyFilter)); + if (userSecret == null) { + // 密钥不存在与密钥口令错误返回同一提示,避免暴露凭证是否存在。 + return ResponseResult.error(ErrorCodeEnum.INVALID_ACCESS_TOKEN, "凭证无效!"); + } + String expectedHash = userSecret.getSecretHash(); + if (StrUtil.isBlank(expectedHash) || !MessageDigest.isEqual(expectedHash.getBytes(StandardCharsets.UTF_8), this.sha256Hex(secret).getBytes(StandardCharsets.UTF_8))) { + return ResponseResult.error(ErrorCodeEnum.INVALID_ACCESS_TOKEN, "凭证无效!"); + } + if (!Objects.equals(userSecret.getStatus(), SysUserSecretStatus.STATUS_ENABLED)) { + return ResponseResult.error(ErrorCodeEnum.INVALID_ACCESS_TOKEN, "凭证已停用,请联系管理员!"); + } + if (userSecret.getExpireTime() != null && userSecret.getExpireTime().before(new Date())) { + return ResponseResult.error(ErrorCodeEnum.INVALID_ACCESS_TOKEN, "凭证已过期,请联系管理员!"); + } + SysUser user = sysUserService.getById(userSecret.getUserId()); + if (user == null) { + return ResponseResult.error(ErrorCodeEnum.INVALID_ACCESS_TOKEN, "凭证无效!"); + } + if (user.getUserStatus() != null && user.getUserStatus() == SysUserStatus.STATUS_LOCKED) { + return ResponseResult.error(ErrorCodeEnum.INVALID_USER_STATUS, "登录失败,用户账号被锁定!"); + } + if (!Objects.equals(mobile, user.getMobile())) { + return ResponseResult.error(ErrorCodeEnum.INVALID_ACCESS_TOKEN, "凭证与用户不匹配!"); + } + userSecret.setLoginName(user.getLoginName()); + userSecret.setShowName(user.getShowName()); + this.touchLastUsedTime(userSecret.getUserSecretId()); + return ResponseResult.success(userSecret); + } + + /** + * 更新最后一次使用时间。该动作不参与鉴权结果,失败只记日志。 + * + * @param userSecretId 密钥记录主键Id。 + */ + private void touchLastUsedTime(Long userSecretId) { + try { + SysUserSecret updating = new SysUserSecret(); + updating.setUserSecretId(userSecretId); + updating.setLastUsedTime(new Date()); + sysUserSecretMapper.updateById(updating); + } catch (Exception e) { + log.warn("更新对外密钥最后使用时间失败,userSecretId={}", userSecretId, e); + } + } + + private SysUserSecretIssueVo buildIssueVo(SysUserSecret userSecret, String secret) { + SysUserSecretIssueVo result = new SysUserSecretIssueVo(); + result.setUserSecretId(userSecret.getUserSecretId()); + result.setKeyId(userSecret.getKeyId()); + result.setSecret(secret); + result.setCredential(userSecret.getKeyId() + CREDENTIAL_SEPARATOR + secret); + result.setExpireTime(userSecret.getExpireTime()); + return result; + } + + private String randomString(int length) { + StringBuilder sb = new StringBuilder(length); + for (int i = 0; i < length; i++) { + sb.append(RANDOM_ALPHABET.charAt(SECURE_RANDOM.nextInt(RANDOM_ALPHABET.length()))); + } + return sb.toString(); + } + + private String sha256Hex(String text) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexUtil.encodeHexStr(digest.digest(text.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new MyRuntimeException("当前运行环境不支持 SHA-256 摘要算法!"); + } + } +} diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/impl/UserSessionContextServiceImpl.java b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/impl/UserSessionContextServiceImpl.java new file mode 100644 index 0000000..8862959 --- /dev/null +++ b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/impl/UserSessionContextServiceImpl.java @@ -0,0 +1,134 @@ +package apelet.tenantadmin.upms.service.impl; + +import apelet.common.core.annotation.MyDataSource; +import apelet.common.core.object.TokenData; +import apelet.common.core.util.ContextUtil; +import apelet.common.core.util.IpUtil; +import apelet.common.core.util.MyCommonUtil; +import apelet.common.core.util.RedisKeyUtil; +import apelet.common.datafilter.config.DataFilterProperties; +import apelet.common.redis.cache.SessionCacheHelper; +import apelet.tenantadmin.config.ApplicationConfig; +import apelet.tenantadmin.config.DataSourceType; +import apelet.tenantadmin.upms.model.SysUser; +import apelet.tenantadmin.upms.model.SysUserPost; +import apelet.tenantadmin.upms.model.SysUserRole; +import apelet.tenantadmin.upms.model.constant.SysUserType; +import apelet.tenantadmin.upms.service.SysDataPermService; +import apelet.tenantadmin.upms.service.SysPermService; +import apelet.tenantadmin.upms.service.SysPermWhitelistService; +import apelet.tenantadmin.upms.service.SysPostService; +import apelet.tenantadmin.upms.service.SysRoleService; +import apelet.tenantadmin.upms.service.UserSessionContextService; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.BooleanUtil; +import cn.hutool.core.util.StrUtil; +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.serializer.SerializerFeature; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RBucket; +import org.redisson.api.RedissonClient; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.Date; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * 用户会话上下文构建服务实现类。 + *

TokenData 的字段来源、会话Id 生成规则、会话缓存写入方式均对齐登录链路的既有做法, + * 保证"用密钥进来的身份"和"用密码登录的身份"在下游完全等价。

+ * + * @author chenchuchuan + * @date 2026-10-14 + */ +@Slf4j +@MyDataSource(DataSourceType.TENANT_ADMIN) +@Service("userSessionContextService") +public class UserSessionContextServiceImpl implements UserSessionContextService { + + @Autowired + private ApplicationConfig appConfig; + @Autowired + private RedissonClient redissonClient; + @Autowired + private SessionCacheHelper cacheHelper; + @Autowired + private DataFilterProperties dataFilterProperties; + @Autowired + private SysPostService sysPostService; + @Autowired + private SysRoleService sysRoleService; + @Autowired + private SysDataPermService sysDataPermService; + @Autowired + private SysPermService sysPermService; + @Autowired + private SysPermWhitelistService sysPermWhitelistService; + + @Override + public String generateSessionId(String loginName, int deviceType) { + return loginName + "_" + deviceType + "_" + MyCommonUtil.generateUuid(); + } + + @Override + public TokenData buildTokenData(SysUser user, String sessionId, int deviceType, String appCode) { + TokenData tokenData = new TokenData(); + tokenData.setSessionId(sessionId); + tokenData.setUserId(user.getUserId()); + tokenData.setDeptId(user.getDeptId()); + tokenData.setLoginName(user.getLoginName()); + tokenData.setShowName(user.getShowName()); + tokenData.setIsAdmin(user.getUserType().equals(SysUserType.TYPE_ADMIN)); + tokenData.setLoginIp(IpUtil.getRemoteIpAddress(ContextUtil.getHttpRequest())); + tokenData.setLoginTime(new Date()); + tokenData.setDeviceType(deviceType); + tokenData.setHeadImageUrl(user.getHeadImageUrl()); + tokenData.setAppCode(appCode); + List userPostList = sysPostService.getSysUserPostListByUserId(user.getUserId()); + if (CollUtil.isNotEmpty(userPostList)) { + Set deptPostIdSet = userPostList.stream().map(SysUserPost::getDeptPostId).collect(Collectors.toSet()); + tokenData.setDeptPostIds(StrUtil.join(",", deptPostIdSet)); + Set postIdSet = userPostList.stream().map(SysUserPost::getPostId).collect(Collectors.toSet()); + tokenData.setPostIds(StrUtil.join(",", postIdSet)); + } + // 2026-07-28 角色用户关联表,增加组织,导致角色id 可能为空 + List userRoleList = sysRoleService.getSysUserRoleListByUserId(user.getUserId()); + if (CollUtil.isNotEmpty(userRoleList)) { + Set userRoleIdSet = userRoleList.stream().map(SysUserRole::getRoleId).filter(Objects::nonNull).collect(Collectors.toSet()); + tokenData.setRoleIds(StrUtil.join(",", userRoleIdSet)); + } + return tokenData; + } + + @Override + public void putSessionContext(TokenData tokenData, SysUser user) { + String sessionId = tokenData.getSessionId(); + long expiredSeconds = appConfig.getSessionExpiredSeconds(); + RBucket sessionBucket = redissonClient.getBucket(RedisKeyUtil.makeSessionIdKey(sessionId)); + sessionBucket.set(JSON.toJSONString(tokenData, SerializerFeature.WriteNonStringValueAsString)); + sessionBucket.expire(expiredSeconds, TimeUnit.SECONDS); + // 数据权限缓存必须有:在线表单的 processDataPerm 读不到它会直接抛异常。 + sysDataPermService.putDataPermCache(sessionId, user.getUserId(), user.getDeptId()); + // 白名单URL缓存用于 verifyMenuPerm 的兜底比对,同样在读不到时抛异常。 + if (BooleanUtil.isTrue(dataFilterProperties.getEnableMenuPermVerify())) { + RBucket whitelistBucket = redissonClient.getBucket(RedisKeyUtil.makeSessionWhiteListPermKey(sessionId)); + whitelistBucket.set(JSON.toJSONString(sysPermWhitelistService.getWhitelistPermList())); + whitelistBucket.expire(expiredSeconds, TimeUnit.SECONDS); + } + } + + @Override + public void removeSessionContext(String sessionId) { + redissonClient.getBucket(RedisKeyUtil.makeSessionIdKey(sessionId)).deleteAsync(); + redissonClient.getBucket(RedisKeyUtil.makeSessionWhiteListPermKey(sessionId)).deleteAsync(); + redissonClient.getKeys().deleteByPatternAsync(RedisKeyUtil.getSessionMenuPermPrefix(sessionId) + "*"); + sysDataPermService.removeDataPermCache(sessionId); + sysPermService.removeUserSysPermCache(sessionId); + cacheHelper.removeAllSessionCache(sessionId); + } +} diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/vo/SysUserSecretIssueVo.java b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/vo/SysUserSecretIssueVo.java new file mode 100644 index 0000000..a215cc1 --- /dev/null +++ b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/vo/SysUserSecretIssueVo.java @@ -0,0 +1,47 @@ +package apelet.tenantadmin.upms.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.Date; + +/** + * 用户对外密钥签发结果对象。 + *

明文 secret 只在签发/轮换的响应中出现一次,之后任何接口都查不到。

+ * + * @author chenchuchuan + * @date 2026-10-14 + */ +@Data +public class SysUserSecretIssueVo { + + /** + * 主键Id。 + */ + @Schema(description = "主键Id") + private Long userSecretId; + + /** + * 密钥标识(明文,可用于定位与审计)。 + */ + @Schema(description = "密钥标识") + private String keyId; + + /** + * 密钥口令明文,仅本次响应可见。 + */ + @Schema(description = "密钥口令明文,仅本次响应可见,请妥善保存") + private String secret; + + /** + * 完整凭证(keyId + "." + secret),可直接交给调用方配置。 + */ + @Schema(description = "完整凭证,格式为 keyId.secret") + private String credential; + + /** + * 过期时间(null表示永不过期)。 + */ + @Schema(description = "过期时间,null表示永不过期") + private Date expireTime; +} From dffc4f8f3977b3d392c74d3894c34a2787918c7c Mon Sep 17 00:00:00 2001 From: chenchuchuan <13554600537@163.com> Date: Mon, 19 Oct 2026 18:05:18 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(mcp-auth):=20=E6=B7=BB=E5=8A=A0=20MCP?= =?UTF-8?q?=20=E7=AB=AF=E7=82=B9=E9=89=B4=E6=9D=83=E6=8B=A6=E6=88=AA?= =?UTF-8?q?=E5=99=A8=E4=B8=8E=E4=BA=BA=E5=91=98=E5=AF=86=E9=92=A5=E7=AE=A1?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 McpAuthInterceptor,支持 Bearer JWT 与人员密钥两种鉴权方式 - 定义 McpAuthService、McpAccountProvider 等 SPI 契约,由应用侧实现 - 新增 McpWebMvcConfig 自动注册拦截器,未实现 SPI 时 fail-closed 返回 401 - 新增 SysUserSecret 实体及完整 CRUD,支持密钥签发、轮换、启停 - 实现凭证校验逻辑:keyId 定位、状态与有效期检查、SHA-256 摘要比对、手机号绑定校验 - 为 McpStreamableController 和 McpToolConfig 添加 apelet.mcp.enabled 开关控制 - 非管理员只能查看和操作自己的密钥,响应中不返回 secretHash 并脱敏手机号 --- .../tenantadmin/config/InterceptorConfig.java | 13 +- .../interceptor/McpAuthInterceptor.java | 262 --------------------- .../mcp/TenantMcpAccountProviderImpl.java | 67 ++++++ .../tenantadmin/mcp/TenantMcpAuthServiceImpl.java | 106 +++++++++ .../upms/controller/SysUserSecretController.java | 171 -------------- .../tenantadmin/upms/dao/SysUserSecretMapper.java | 25 -- .../upms/dao/mapper/SysUserSecretMapper.xml | 66 ------ .../upms/service/UserSessionContextService.java | 53 ----- .../impl/UserSessionContextServiceImpl.java | 98 ++++++-- .../src/main/resources/tenant-admin-dev.yml | 13 + .../core/mcp/auth/config/McpWebMvcConfig.java | 42 ++++ .../mcp/auth/interceptor/McpAuthInterceptor.java | 142 +++++++++++ .../auth/session/UserSessionContextService.java | 33 +++ .../common/core/mcp/auth/spi/McpAccountInfo.java | 91 +++++++ .../core/mcp/auth/spi/McpAccountProvider.java | 31 +++ .../common/core/mcp/auth/spi/McpAuthResult.java | 56 +++++ .../common/core/mcp/auth/spi/McpAuthService.java | 40 ++++ .../common/core/mcp/config/McpToolConfig.java | 4 + .../mcp/controller/McpStreamableController.java | 4 + .../common/openapi/constant/SysUserSecretSpec.java | 41 ++++ .../openapi}/constant/SysUserSecretStatus.java | 2 +- .../controller/SysUserSecretController.java | 245 +++++++++++++++++++ .../common/openapi/dao/SysUserSecretMapper.java | 13 + .../common/openapi}/model/SysUserSecret.java | 2 +- .../openapi}/service/SysUserSecretService.java | 24 +- .../service/impl/SysUserSecretServiceImpl.java | 187 +++++++++++---- .../common/openapi}/vo/SysUserSecretIssueVo.java | 8 +- 27 files changed, 1172 insertions(+), 667 deletions(-) delete mode 100644 application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/interceptor/McpAuthInterceptor.java create mode 100644 application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/mcp/TenantMcpAccountProviderImpl.java create mode 100644 application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/mcp/TenantMcpAuthServiceImpl.java delete mode 100644 application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/controller/SysUserSecretController.java delete mode 100644 application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/dao/SysUserSecretMapper.java delete mode 100644 application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/dao/mapper/SysUserSecretMapper.xml delete mode 100644 application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/UserSessionContextService.java create mode 100644 common/common-core/src/main/java/apelet/common/core/mcp/auth/config/McpWebMvcConfig.java create mode 100644 common/common-core/src/main/java/apelet/common/core/mcp/auth/interceptor/McpAuthInterceptor.java create mode 100644 common/common-core/src/main/java/apelet/common/core/mcp/auth/session/UserSessionContextService.java create mode 100644 common/common-core/src/main/java/apelet/common/core/mcp/auth/spi/McpAccountInfo.java create mode 100644 common/common-core/src/main/java/apelet/common/core/mcp/auth/spi/McpAccountProvider.java create mode 100644 common/common-core/src/main/java/apelet/common/core/mcp/auth/spi/McpAuthResult.java create mode 100644 common/common-core/src/main/java/apelet/common/core/mcp/auth/spi/McpAuthService.java create mode 100644 common/common-openapi/src/main/java/apelet/common/openapi/constant/SysUserSecretSpec.java rename {application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/model => common/common-openapi/src/main/java/apelet/common/openapi}/constant/SysUserSecretStatus.java (95%) create mode 100644 common/common-openapi/src/main/java/apelet/common/openapi/controller/SysUserSecretController.java create mode 100644 common/common-openapi/src/main/java/apelet/common/openapi/dao/SysUserSecretMapper.java rename {application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms => common/common-openapi/src/main/java/apelet/common/openapi}/model/SysUserSecret.java (98%) rename {application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms => common/common-openapi/src/main/java/apelet/common/openapi}/service/SysUserSecretService.java (68%) rename {application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms => common/common-openapi/src/main/java/apelet/common/openapi}/service/impl/SysUserSecretServiceImpl.java (52%) rename {application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms => common/common-openapi/src/main/java/apelet/common/openapi}/vo/SysUserSecretIssueVo.java (66%) diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/config/InterceptorConfig.java b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/config/InterceptorConfig.java index 8e20f05..dc4f4fa 100644 --- a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/config/InterceptorConfig.java +++ b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/config/InterceptorConfig.java @@ -1,9 +1,9 @@ package apelet.tenantadmin.config; +import apelet.common.core.mcp.auth.interceptor.McpAuthInterceptor; import apelet.common.openapi.config.OpenApiProperties; import apelet.common.openapi.interceptor.OpenApiAuthInterceptor; import apelet.tenantadmin.interceptor.AuthenticationInterceptor; -import apelet.tenantadmin.interceptor.McpAuthInterceptor; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; @@ -23,8 +23,6 @@ public class InterceptorConfig implements WebMvcConfigurer { private ObjectProvider openApiAuthInterceptorProvider; @Autowired private OpenApiProperties openApiProperties; - @Autowired - private McpAuthInterceptor mcpAuthInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { @@ -36,8 +34,9 @@ public class InterceptorConfig implements WebMvcConfigurer { // 被登录态拦截器抢先拦下(它注册在前,会先执行)。 .excludePathPatterns(openApiPrefix + "/v1/**") .excludePathPatterns(openApiPrefix + "/auth/**") - // MCP 端点由 McpAuthInterceptor 独立鉴权,支持登录态 JWT 与人员密钥两种凭证。 - // 必须在这里排除:HandlerInterceptor 没有"我处理完了后续跳过"的机制,若不排除, + // MCP 端点由 core 的 McpAuthInterceptor 独立鉴权(支持登录态与人员密钥两种凭证), + // 该拦截器由 common-core 的 McpWebMvcConfig 在 apelet.mcp.enabled=true 时自动注册。 + // 这里必须排除:HandlerInterceptor 没有"我处理完了后续跳过"的机制,若不排除, // 未带 Authorization 的密钥调用会被登录态拦截器直接 401。 // 注意两条都要写:/** 并不匹配不带尾斜杠的端点本身。 .excludePathPatterns(McpAuthInterceptor.MCP_ENDPOINT_PATH) @@ -49,9 +48,5 @@ public class InterceptorConfig implements WebMvcConfigurer { if (openApiAuthInterceptor != null) { registry.addInterceptor(openApiAuthInterceptor).addPathPatterns(openApiPrefix + "/v1/**"); } - // MCP 端点鉴权(/tenantadmin/mcp 与 /tenantadmin/mcp/**)。 - registry.addInterceptor(mcpAuthInterceptor) - .addPathPatterns(McpAuthInterceptor.MCP_ENDPOINT_PATH) - .addPathPatterns(McpAuthInterceptor.MCP_ENDPOINT_PATH + "/**"); } } diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/interceptor/McpAuthInterceptor.java b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/interceptor/McpAuthInterceptor.java deleted file mode 100644 index b74bfee..0000000 --- a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/interceptor/McpAuthInterceptor.java +++ /dev/null @@ -1,262 +0,0 @@ -package apelet.tenantadmin.interceptor; - -import apelet.common.core.constant.AppDeviceType; -import apelet.common.core.constant.ApplicationConstant; -import apelet.common.core.constant.ErrorCodeEnum; -import apelet.common.core.object.ResponseResult; -import apelet.common.core.object.TokenData; -import apelet.common.core.util.JwtUtil; -import apelet.common.core.util.RedisKeyUtil; -import apelet.common.openapi.model.OpenApiApp; -import apelet.common.openapi.service.OpenApiAppService; -import apelet.tenantadmin.config.ApplicationConfig; -import apelet.tenantadmin.upms.model.SysUser; -import apelet.tenantadmin.upms.model.SysUserSecret; -import apelet.tenantadmin.upms.service.SysUserSecretService; -import apelet.tenantadmin.upms.service.SysUserService; -import apelet.tenantadmin.upms.service.UserSessionContextService; -import cn.hutool.core.util.StrUtil; -import com.alibaba.fastjson.JSON; -import io.jsonwebtoken.Claims; -import lombok.extern.slf4j.Slf4j; -import org.redisson.api.RBucket; -import org.redisson.api.RedissonClient; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; -import org.springframework.web.context.request.RequestContextHolder; -import org.springframework.web.context.request.ServletRequestAttributes; -import org.springframework.web.servlet.HandlerInterceptor; - -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletRequestWrapper; -import javax.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.io.PrintWriter; -import java.util.concurrent.TimeUnit; - -/** - * MCP 端点({@code /tenantadmin/mcp})的鉴权拦截器。 - *

支持两种调用方身份,按顺序判定:

- *
    - *
  1. {@code Authorization: Bearer } —— 登录态,兼容现有调用方;
  2. - *
  3. {@code X-Mcp-User} + {@code X-Mcp-Secret} —— 人员级密钥,格式为 "keyId.secret",锁定到具体账号。
  4. - *
- *

两者都带时以 Bearer 优先,避免调用方主动降级到密钥分支。

- *

校验通过后统一注入 {@link TokenData},下游(在线表单、数据权限、流程)对来源无感知。

- *

注意:本拦截器注册后,{@code /tenantadmin/mcp} 已从 AuthenticationInterceptor 的路径中排除, - * 因此 MCP 路径不做"菜单×URL"越权校验;数据权限(DATA_PERM)仍照常生效。

- * - * @author chenchuchuan - * @date 2026-10-14 - */ -@Slf4j -@Component -public class McpAuthInterceptor implements HandlerInterceptor { - - /** - * MCP 端点路径。注意该路径硬编码在 common-core 的 McpStreamableController 上,两边需保持一致。 - */ - public static final String MCP_ENDPOINT_PATH = "/tenantadmin/mcp"; - - /** - * 人员手机号请求头。 - */ - public static final String HEADER_MCP_USER = "X-Mcp-User"; - /** - * 人员密钥请求头,值为 "keyId.secret"。 - */ - public static final String HEADER_MCP_SECRET = "X-Mcp-Secret"; - - /** - * 会话复用缓存前缀:一个账号在 TTL 内复用同一个会话,避免每个请求都重建会话上下文(含查库与多次Redis写入)。 - */ - private static final String MCP_SESSION_KEY_PREFIX = "MCP_SESSION:"; - /** - * 会话复用TTL(秒)。刻意短于会话本身的有效期,以限制角色/数据权限变更后的缓存陈旧窗口。 - */ - private static final long MCP_SESSION_TTL_SECONDS = 1800L; - /** - * JWT 中承载会话Id的 claim 名称,需与登录链路保持一致。 - */ - private static final String CLAIM_SESSION_ID = "sessionId"; - - @Autowired - private ApplicationConfig appConfig; - @Autowired - private RedissonClient redissonClient; - @Autowired - private SysUserSecretService sysUserSecretService; - @Autowired - private SysUserService sysUserService; - @Autowired - private OpenApiAppService openApiAppService; - @Autowired - private UserSessionContextService userSessionContextService; - - @Override - public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { - String authorization = request.getHeader(appConfig.getTokenHeaderKey()); - TokenData tokenData = null; - if (StrUtil.isNotBlank(authorization)) { - tokenData = this.authenticateByJwt(authorization); - if (tokenData == null) { - this.outputUnauthorized(response, "登录会话已过期或尚未登录,请重新登录!"); - return false; - } - } else { - String mobile = request.getHeader(HEADER_MCP_USER); - String credential = request.getHeader(HEADER_MCP_SECRET); - if (StrUtil.isBlank(mobile) && StrUtil.isBlank(credential)) { - this.outputUnauthorized(response, "缺少调用凭证,请提供 Authorization 或人员密钥!"); - return false; - } - ResponseResult authResult = sysUserSecretService.authenticate(mobile, credential); - if (!authResult.isSuccess()) { - log.warn("MCP 密钥鉴权失败,keyId={}, mobile={}, 原因={}", this.maskKeyId(credential), mobile, authResult.getErrorMessage()); - this.outputUnauthorized(response, authResult.getErrorMessage()); - return false; - } - SysUser user = sysUserService.getById(authResult.getData().getUserId()); - if (user == null) { - this.outputUnauthorized(response, "凭证无效!"); - return false; - } - tokenData = this.resolveKeyBranchTokenData(user, authResult.getData()); - } - TokenData.addToRequest(tokenData); - // 在线表单引擎需要 MenuId 头来选取数据权限规则分支;MCP 无菜单上下文,统一按"通用规则"处理。 - this.wrapRequestWithMenuId(request); - return true; - } - - /** - * 校验登录态 JWT 并取回会话中的 TokenData。口径与 AuthenticationInterceptor 保持一致。 - * - * @param token 请求头中的 JWT(含 Bearer 前缀)。 - * @return 校验通过返回 TokenData,否则返回null。 - */ - private TokenData authenticateByJwt(String token) { - Claims claims = JwtUtil.parseToken(token, appConfig.getTokenSigningKey()); - if (JwtUtil.isNullOrExpired(claims)) { - return null; - } - String sessionId = (String) claims.get(CLAIM_SESSION_ID); - if (StrUtil.isBlank(sessionId)) { - return null; - } - RBucket sessionData = redissonClient.getBucket(RedisKeyUtil.makeSessionIdKey(sessionId)); - if (!sessionData.isExists()) { - return null; - } - TokenData tokenData = JSON.parseObject(sessionData.get(), TokenData.class); - if (tokenData != null) { - tokenData.setToken(token); - } - return tokenData; - } - - /** - * 解决密钥分支的 TokenData:优先复用该账号已有的 MCP 会话,避免每个请求都重建会话上下文。 - * - * @param user 密钥绑定的账号。 - * @param userSecret 已通过校验的密钥记录。 - * @return 可直接注入请求上下文的 TokenData。 - */ - private TokenData resolveKeyBranchTokenData(SysUser user, SysUserSecret userSecret) { - RBucket sessionIdBucket = redissonClient.getBucket(MCP_SESSION_KEY_PREFIX + user.getUserId()); - String sessionId = sessionIdBucket.get(); - if (StrUtil.isNotBlank(sessionId)) { - RBucket sessionData = redissonClient.getBucket(RedisKeyUtil.makeSessionIdKey(sessionId)); - if (sessionData.isExists()) { - TokenData cached = JSON.parseObject(sessionData.get(), TokenData.class); - if (cached != null) { - return cached; - } - } - } - String appCode = this.resolveAppCode(userSecret.getAppId()); - String newSessionId = userSessionContextService.generateSessionId(user.getLoginName(), AppDeviceType.WEB); - TokenData tokenData = userSessionContextService.buildTokenData(user, newSessionId, AppDeviceType.WEB, appCode); - userSessionContextService.putSessionContext(tokenData, user); - sessionIdBucket.set(newSessionId, MCP_SESSION_TTL_SECONDS, TimeUnit.SECONDS); - return tokenData; - } - - /** - * 由密钥绑定的应用Id取回应用编码,供下游的开放接口授权校验使用。 - * - * @param appId 应用Id。 - * @return 应用编码;应用不存在或已停用时返回null,由下游给出"无权调用"的可读错误。 - */ - private String resolveAppCode(Long appId) { - if (appId == null) { - return null; - } - OpenApiApp app = openApiAppService.getById(appId); - if (app == null || app.getStatus() == null || app.getStatus() != 1) { - log.warn("MCP 密钥绑定的应用不存在或已停用,appId={}", appId); - return null; - } - return app.getAppCode(); - } - - /** - * 用 HttpServletRequestWrapper 覆盖 MenuId 请求头。 - *

拦截器无法修改已传入的 request 对象,只能包装后替换 RequestContextHolder 中的引用, - * 使下游通过 ContextUtil.getHttpRequest().getHeader("MenuId") 能取到值。

- * - * @param request 当前请求。 - */ - private void wrapRequestWithMenuId(HttpServletRequest request) { - if (StrUtil.isNotBlank(request.getHeader(ApplicationConstant.HTTP_HEADER_MENU_ID))) { - return; - } - ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); - if (attributes == null) { - return; - } - RequestContextHolder.setRequestAttributes( - new ServletRequestAttributes(new MenuIdHeaderRequestWrapper(request), attributes.getResponse())); - } - - /** - * 仅覆盖 MenuId 请求头,其余行为全部委托给原请求(含 attribute 读写,TokenData 依赖它)。 - */ - private static class MenuIdHeaderRequestWrapper extends HttpServletRequestWrapper { - - MenuIdHeaderRequestWrapper(HttpServletRequest request) { - super(request); - } - - @Override - public String getHeader(String name) { - if (ApplicationConstant.HTTP_HEADER_MENU_ID.equalsIgnoreCase(name)) { - return ApplicationConstant.DATA_PERM_ALL_MENU_ID; - } - return super.getHeader(name); - } - } - - /** - * 日志中只保留 keyId,避免密钥口令落入日志。 - * - * @param credential 完整凭证(keyId.secret)。 - * @return 仅含 keyId 的字符串。 - */ - private String maskKeyId(String credential) { - if (StrUtil.isBlank(credential)) { - return ""; - } - return StrUtil.subBefore(credential, SysUserSecretService.CREDENTIAL_SEPARATOR, false); - } - - private void outputUnauthorized(HttpServletResponse response, String message) throws IOException { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.setHeader("WWW-Authenticate", "Bearer"); - response.setContentType("application/json; charset=utf-8"); - PrintWriter out = response.getWriter(); - out.print(JSON.toJSONString(ResponseResult.error(ErrorCodeEnum.UNAUTHORIZED_LOGIN, message))); - out.flush(); - out.close(); - } -} diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/mcp/TenantMcpAccountProviderImpl.java b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/mcp/TenantMcpAccountProviderImpl.java new file mode 100644 index 0000000..0db5a3c --- /dev/null +++ b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/mcp/TenantMcpAccountProviderImpl.java @@ -0,0 +1,67 @@ +package apelet.tenantadmin.mcp; + +import apelet.common.core.mcp.auth.spi.McpAccountInfo; +import apelet.common.core.mcp.auth.spi.McpAccountProvider; +import apelet.tenantadmin.upms.model.SysUser; +import apelet.tenantadmin.upms.model.constant.SysUserStatus; +import apelet.tenantadmin.upms.model.constant.SysUserType; +import apelet.tenantadmin.upms.service.SysUserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * MCP 账号信息查询 SPI 的租户端实现。 + * + * @author chenchuchuan + * @date 2026-10-14 + */ +@Service("mcpAccountProvider") +public class TenantMcpAccountProviderImpl implements McpAccountProvider { + + @Autowired + private SysUserService sysUserService; + + @Override + public McpAccountInfo getById(Long userId) { + if (userId == null) { + return null; + } + return this.convert(sysUserService.getById(userId)); + } + + @Override + public List listByIds(Collection userIds) { + if (userIds == null || userIds.isEmpty()) { + return Collections.emptyList(); + } + return sysUserService.listByIds(userIds).stream().map(this::convert).collect(Collectors.toList()); + } + + /** + * 把应用侧的账号模型折算成 core 侧的账号信息。 + * + * @param user 账号对象。 + * @return 账号信息;入参为null时返回null。 + */ + private McpAccountInfo convert(SysUser user) { + if (user == null) { + return null; + } + McpAccountInfo info = new McpAccountInfo(); + info.setUserId(user.getUserId()); + info.setLoginName(user.getLoginName()); + info.setShowName(user.getShowName()); + info.setDeptId(user.getDeptId()); + info.setMobile(user.getMobile()); + info.setHeadImageUrl(user.getHeadImageUrl()); + // 用户类型/状态枚举属应用侧概念,在这里折算成布尔值交给 core。 + info.setIsAdmin(user.getUserType() != null && user.getUserType() == SysUserType.TYPE_ADMIN); + info.setEnabled(user.getUserStatus() == null || user.getUserStatus() != SysUserStatus.STATUS_LOCKED); + return info; + } +} diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/mcp/TenantMcpAuthServiceImpl.java b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/mcp/TenantMcpAuthServiceImpl.java new file mode 100644 index 0000000..9b3d8f2 --- /dev/null +++ b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/mcp/TenantMcpAuthServiceImpl.java @@ -0,0 +1,106 @@ +package apelet.tenantadmin.mcp; + +import apelet.common.core.constant.AppDeviceType; +import apelet.common.core.mcp.auth.session.UserSessionContextService; +import apelet.common.core.mcp.auth.spi.McpAuthResult; +import apelet.common.core.mcp.auth.spi.McpAuthService; +import apelet.common.core.object.ResponseResult; +import apelet.common.core.object.TokenData; +import apelet.common.core.util.JwtUtil; +import apelet.common.core.util.RedisKeyUtil; +import apelet.common.openapi.model.OpenApiApp; +import apelet.common.openapi.model.SysUserSecret; +import apelet.common.openapi.service.OpenApiAppService; +import apelet.common.openapi.service.SysUserSecretService; +import apelet.tenantadmin.config.ApplicationConfig; +import cn.hutool.core.util.StrUtil; +import com.alibaba.fastjson.JSON; +import io.jsonwebtoken.Claims; +import org.redisson.api.RBucket; +import org.redisson.api.RedissonClient; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +/** + * MCP 鉴权 SPI 的租户端实现。 + *

两条分支的落点一致:都产出一份 {@code TokenData},由拦截器注入请求上下文,下游无感知。

+ * + * @author chenchuchuan + * @date 2026-10-14 + */ +@Service("mcpAuthService") +public class TenantMcpAuthServiceImpl implements McpAuthService { + + private static final int APP_STATUS_ENABLED = 1; + /** + * JWT 中承载会话Id的 claim 名称,需与登录链路保持一致。 + */ + private static final String CLAIM_SESSION_ID = "sessionId"; + + @Autowired + private ApplicationConfig appConfig; + @Autowired + private RedissonClient redissonClient; + @Autowired + private SysUserSecretService sysUserSecretService; + @Autowired + private OpenApiAppService openApiAppService; + @Autowired + private UserSessionContextService userSessionContextService; + + @Override + public String getBearerHeaderName() { + return appConfig.getTokenHeaderKey(); + } + + @Override + public McpAuthResult authenticateByJwt(String token) { + Claims claims = JwtUtil.parseToken(token, appConfig.getTokenSigningKey()); + if (JwtUtil.isNullOrExpired(claims)) { + return McpAuthResult.fail("登录会话已过期或尚未登录,请重新登录!"); + } + String sessionId = (String) claims.get(CLAIM_SESSION_ID); + if (StrUtil.isBlank(sessionId)) { + return McpAuthResult.fail("登录会话无效,请重新登录!"); + } + RBucket sessionData = redissonClient.getBucket(RedisKeyUtil.makeSessionIdKey(sessionId)); + if (!sessionData.isExists()) { + return McpAuthResult.fail("登录会话已失效,请重新登录!"); + } + TokenData tokenData = JSON.parseObject(sessionData.get(), TokenData.class); + if (tokenData == null) { + return McpAuthResult.fail("登录会话已失效,请重新登录!"); + } + tokenData.setToken(token); + return McpAuthResult.ok(tokenData); + } + + @Override + public McpAuthResult authenticateByUserSecret(String mobile, String credential) { + ResponseResult authResult = sysUserSecretService.authenticate(mobile, credential); + if (!authResult.isSuccess() || authResult.getData() == null) { + return McpAuthResult.fail(authResult.getErrorMessage()); + } + SysUserSecret userSecret = authResult.getData(); + String appCode = this.resolveAppCode(userSecret.getAppId()); + TokenData tokenData = userSessionContextService.buildAndCacheSession(userSecret.getUserId(), appCode, AppDeviceType.WEB); + return McpAuthResult.ok(tokenData, userSecret.getKeyId()); + } + + /** + * 由密钥绑定的应用Id取回应用编码,供下游的开放接口授权校验使用。 + * + * @param appId 应用Id。 + * @return 应用编码;应用不存在或已停用时返回null,由下游给出"无权调用"的可读错误。 + */ + private String resolveAppCode(Long appId) { + if (appId == null) { + return null; + } + OpenApiApp app = openApiAppService.getById(appId); + if (app == null || app.getStatus() == null || app.getStatus() != APP_STATUS_ENABLED) { + return null; + } + return app.getAppCode(); + } +} diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/controller/SysUserSecretController.java b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/controller/SysUserSecretController.java deleted file mode 100644 index 27e4cf7..0000000 --- a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/controller/SysUserSecretController.java +++ /dev/null @@ -1,171 +0,0 @@ -package apelet.tenantadmin.upms.controller; - -import apelet.common.core.annotation.MyRequestBody; -import apelet.common.core.constant.ErrorCodeEnum; -import apelet.common.core.object.MyOrderParam; -import apelet.common.core.object.MyPageData; -import apelet.common.core.object.MyPageParam; -import apelet.common.core.object.ResponseResult; -import apelet.common.core.util.MyCommonUtil; -import apelet.common.core.util.MyPageUtil; -import apelet.common.log.annotation.OperationLog; -import apelet.common.log.model.constant.SysOperationLogType; -import apelet.tenantadmin.upms.model.SysUserSecret; -import apelet.tenantadmin.upms.model.constant.SysUserSecretStatus; -import apelet.tenantadmin.upms.service.SysUserSecretService; -import apelet.tenantadmin.upms.vo.SysUserSecretIssueVo; -import cn.hutool.core.date.DateUtil; -import cn.hutool.core.util.StrUtil; -import com.github.pagehelper.Page; -import com.github.pagehelper.page.PageMethod; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import java.util.Date; -import java.util.List; - -/** - * 用户对外密钥管理接口。 - *

密钥是"一人一把、锁定到具体账号"的长期凭证,签发与停用属于提权操作, - * 接口本身依赖登录态 + URL 权限控制,仅管理员角色应被授予。

- *

响应中永远不返回 secretHash;明文 secret 只在签发/轮换的响应里出现一次, - * 且对应操作日志显式关闭了响应体记录(saveResponse=false),避免明文密钥落到日志。

- * - * @author chenchuchuan - * @date 2026-10-14 - */ -@Tag(name = "对外密钥管理") -@Slf4j -@RestController -@RequestMapping("/tenantadmin/upms/sysUserSecret") -public class SysUserSecretController { - - @Autowired - private SysUserSecretService sysUserSecretService; - - /** - * 对外密钥分页列表。 - * - * @param sysUserSecretFilter 过滤条件(支持 loginName/mobile 模糊、userId/appId/status 精确)。 - * @param orderParam 排序参数。 - * @param pageParam 分页参数。 - * @return 应答结果对象,包含分页后的密钥列表。 - */ - @PostMapping("/list") - public ResponseResult> list( - @MyRequestBody SysUserSecret sysUserSecretFilter, - @MyRequestBody MyOrderParam orderParam, - @MyRequestBody MyPageParam pageParam) { - if (pageParam != null) { - PageMethod.startPage(pageParam.getPageNum(), pageParam.getPageSize()); - } - String orderBy = MyOrderParam.buildOrderBy(orderParam, SysUserSecret.class); - List secretList = sysUserSecretService.getSysUserSecretList(sysUserSecretFilter, orderBy); - long totalCount = 0L; - if (secretList instanceof Page) { - totalCount = ((Page) secretList).getTotal(); - } - secretList.forEach(this::maskForResponse); - return ResponseResult.success(MyPageUtil.makeResponseData(secretList, totalCount)); - } - - /** - * 查看指定密钥的详情。不返回 secretHash。 - * - * @param userSecretId 密钥记录主键Id。 - * @return 应答结果对象,包含密钥详情。 - */ - @PostMapping("/view") - public ResponseResult view(@MyRequestBody Long userSecretId) { - if (MyCommonUtil.existBlankArgument(userSecretId)) { - return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST); - } - SysUserSecret userSecret = sysUserSecretService.getById(userSecretId); - if (userSecret == null) { - return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, "对外密钥不存在,请刷新后重试!"); - } - this.maskForResponse(userSecret); - return ResponseResult.success(userSecret); - } - - /** - * 为指定账号签发一把对外密钥。明文只在本响应中出现一次,之后无法再查。 - * - * @param userId 绑定的账号Id。 - * @param appId 绑定的OpenAPI应用Id(决定该密钥可调用的开放接口范围)。 - * @param expireTime 过期时间字符串,留空表示永不过期。 - * @return 应答结果对象,包含 keyId、明文 secret 与完整凭证。 - */ - @OperationLog(type = SysOperationLogType.ADD, saveResponse = false) - @PostMapping("/issue") - public ResponseResult issue( - @MyRequestBody Long userId, - @MyRequestBody Long appId, - @MyRequestBody String expireTime) { - if (MyCommonUtil.existBlankArgument(userId, appId)) { - return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST); - } - Date expireDate = null; - if (StrUtil.isNotBlank(expireTime)) { - expireDate = DateUtil.parse(expireTime); - } - return ResponseResult.success(sysUserSecretService.issue(userId, appId, expireDate)); - } - - /** - * 轮换指定密钥的口令:keyId 不变,旧 secret 立即失效。明文只在本响应中出现一次。 - * - * @param userSecretId 密钥记录主键Id。 - * @return 应答结果对象,包含新的明文 secret 与完整凭证。 - */ - @OperationLog(type = SysOperationLogType.UPDATE, saveResponse = false) - @PostMapping("/resetSecret") - public ResponseResult resetSecret(@MyRequestBody Long userSecretId) { - if (MyCommonUtil.existBlankArgument(userSecretId)) { - return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST); - } - SysUserSecretIssueVo issueVo = sysUserSecretService.resetSecret(userSecretId); - if (issueVo == null) { - return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, "对外密钥不存在,请刷新后重试!"); - } - return ResponseResult.success(issueVo); - } - - /** - * 启用/停用指定密钥。停用后该密钥的调用一律拒绝。 - * - * @param userSecretId 密钥记录主键Id。 - * @param status 目标状态(1: 启用 0: 停用)。 - * @return 应答结果对象。 - */ - @OperationLog(type = SysOperationLogType.UPDATE, saveResponse = false) - @PostMapping("/updateStatus") - public ResponseResult updateStatus(@MyRequestBody Long userSecretId, @MyRequestBody Integer status) { - if (MyCommonUtil.existBlankArgument(userSecretId, status)) { - return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST); - } - if (!SysUserSecretStatus.isValid(status)) { - return ResponseResult.error(ErrorCodeEnum.INVALID_STATUS_ARGUMENT); - } - if (!sysUserSecretService.updateStatus(userSecretId, status)) { - return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, "对外密钥不存在,请刷新后重试!"); - } - return ResponseResult.success(); - } - - /** - * 抹掉不应对外返回的字段:密钥摘要一律清空,手机号脱敏。 - * - * @param userSecret 待处理的对象。 - */ - private void maskForResponse(SysUserSecret userSecret) { - userSecret.setSecretHash(null); - if (StrUtil.isNotBlank(userSecret.getMobile())) { - userSecret.setMobile(StrUtil.hide(userSecret.getMobile(), 3, 7)); - } - } -} diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/dao/SysUserSecretMapper.java b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/dao/SysUserSecretMapper.java deleted file mode 100644 index 852b7fb..0000000 --- a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/dao/SysUserSecretMapper.java +++ /dev/null @@ -1,25 +0,0 @@ -package apelet.tenantadmin.upms.dao; - -import apelet.common.core.base.dao.BaseDaoMapper; -import apelet.tenantadmin.upms.model.SysUserSecret; -import org.apache.ibatis.annotations.Param; - -import java.util.List; - -/** - * 用户对外密钥数据访问操作接口。 - * - * @author chenchuchuan - * @date 2026-10-14 - */ -public interface SysUserSecretMapper extends BaseDaoMapper { - - /** - * 获取用户对外密钥列表(带账号信息)。 - * - * @param sysUserSecretFilter 过滤对象。 - * @param orderBy 排序字符串。 - * @return 过滤后的对外密钥列表。 - */ - List getSysUserSecretList(@Param("sysUserSecretFilter") SysUserSecret sysUserSecretFilter, @Param("orderBy") String orderBy); -} diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/dao/mapper/SysUserSecretMapper.xml b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/dao/mapper/SysUserSecretMapper.xml deleted file mode 100644 index 5e8cb57..0000000 --- a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/dao/mapper/SysUserSecretMapper.xml +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - AND xy_sys_user_secret.user_id = #{sysUserSecretFilter.userId} - - - AND xy_sys_user_secret.app_id = #{sysUserSecretFilter.appId} - - - AND xy_sys_user_secret.status = #{sysUserSecretFilter.status} - - - - AND IFNULL(xy_sys_user.login_name, '') LIKE #{safeLoginName} - - - - AND IFNULL(xy_sys_user.mobile, '') LIKE #{safeMobile} - - - AND xy_sys_user_secret.deleted_flag = ${@apelet.common.core.constant.GlobalDeletedFlag@NORMAL} - - - - diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/UserSessionContextService.java b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/UserSessionContextService.java deleted file mode 100644 index c8c76fb..0000000 --- a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/UserSessionContextService.java +++ /dev/null @@ -1,53 +0,0 @@ -package apelet.tenantadmin.upms.service; - -import apelet.common.core.object.TokenData; -import apelet.tenantadmin.upms.model.SysUser; - -/** - * 用户会话上下文构建服务接口。 - *

把"登录成功后需要落到 Redis 的那几份缓存"收敛到一处,供登录链路与 MCP 密钥鉴权链路共用, - * 避免各自实现导致口径不一致。

- * - * @author chenchuchuan - * @date 2026-10-14 - */ -public interface UserSessionContextService { - - /** - * 生成会话Id,规则与登录链路保持一致:登录名 + 设备类型 + UUID。 - * - * @param loginName 登录名。 - * @param deviceType 设备类型。 - * @return 会话Id。 - */ - String generateSessionId(String loginName, int deviceType); - - /** - * 依据账号信息组装 TokenData(含部门/岗位/角色)。 - * - * @param user 账号对象。 - * @param sessionId 会话Id。 - * @param deviceType 设备类型。 - * @param appCode 第三方开放应用编码,非开放调用时传null。 - * @return 组装好的 TokenData。 - */ - TokenData buildTokenData(SysUser user, String sessionId, int deviceType, String appCode); - - /** - * 写入会话上下文缓存。 - *

包含:SESSIONID(会话)、DATA_PERM(数据权限)、WHITELISTPERM(白名单URL)。

- *

不写 PERM(URL权限集合):本方法当前服务于 MCP 路径,该路径已从登录态拦截器排除、 - * 不做"菜单×URL"越权校验;登录链路若复用本方法需另行写入 PERM。

- * - * @param tokenData 已组装好的 TokenData。 - * @param user 账号对象。 - */ - void putSessionContext(TokenData tokenData, SysUser user); - - /** - * 清理指定会话的全部上下文缓存。 - * - * @param sessionId 会话Id。 - */ - void removeSessionContext(String sessionId); -} diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/impl/UserSessionContextServiceImpl.java b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/impl/UserSessionContextServiceImpl.java index 8862959..4a7b3c0 100644 --- a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/impl/UserSessionContextServiceImpl.java +++ b/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/impl/UserSessionContextServiceImpl.java @@ -1,6 +1,8 @@ package apelet.tenantadmin.upms.service.impl; import apelet.common.core.annotation.MyDataSource; +import apelet.common.core.exception.MyRuntimeException; +import apelet.common.core.mcp.auth.session.UserSessionContextService; import apelet.common.core.object.TokenData; import apelet.common.core.util.ContextUtil; import apelet.common.core.util.IpUtil; @@ -19,7 +21,7 @@ import apelet.tenantadmin.upms.service.SysPermService; import apelet.tenantadmin.upms.service.SysPermWhitelistService; import apelet.tenantadmin.upms.service.SysPostService; import apelet.tenantadmin.upms.service.SysRoleService; -import apelet.tenantadmin.upms.service.UserSessionContextService; +import apelet.tenantadmin.upms.service.SysUserService; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.BooleanUtil; import cn.hutool.core.util.StrUtil; @@ -39,9 +41,11 @@ import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; /** - * 用户会话上下文构建服务实现类。 + * 会话上下文构建 SPI 的租户端实现。 *

TokenData 的字段来源、会话Id 生成规则、会话缓存写入方式均对齐登录链路的既有做法, * 保证"用密钥进来的身份"和"用密码登录的身份"在下游完全等价。

+ *

带会话复用:同一账号在 TTL 内重复调用返回同一份会话,避免每个请求都重建 + * (一次重建包含若干次查库与多次 Redis 写入)。

* * @author chenchuchuan * @date 2026-10-14 @@ -51,6 +55,15 @@ import java.util.stream.Collectors; @Service("userSessionContextService") public class UserSessionContextServiceImpl implements UserSessionContextService { + /** + * 会话复用缓存前缀:一个账号在 TTL 内复用同一个会话。 + */ + private static final String SESSION_REUSE_KEY_PREFIX = "MCP_SESSION:"; + /** + * 会话复用TTL(秒)。刻意短于会话本身的有效期,以限制角色/数据权限变更后的缓存陈旧窗口。 + */ + private static final long SESSION_REUSE_TTL_SECONDS = 1800L; + @Autowired private ApplicationConfig appConfig; @Autowired @@ -60,6 +73,8 @@ public class UserSessionContextServiceImpl implements UserSessionContextService @Autowired private DataFilterProperties dataFilterProperties; @Autowired + private SysUserService sysUserService; + @Autowired private SysPostService sysPostService; @Autowired private SysRoleService sysRoleService; @@ -71,12 +86,63 @@ public class UserSessionContextServiceImpl implements UserSessionContextService private SysPermWhitelistService sysPermWhitelistService; @Override - public String generateSessionId(String loginName, int deviceType) { - return loginName + "_" + deviceType + "_" + MyCommonUtil.generateUuid(); + public TokenData buildAndCacheSession(Long userId, String appCode, int deviceType) { + RBucket reuseBucket = redissonClient.getBucket(SESSION_REUSE_KEY_PREFIX + userId); + String cachedSessionId = reuseBucket.get(); + if (StrUtil.isNotBlank(cachedSessionId)) { + RBucket cachedSession = redissonClient.getBucket(RedisKeyUtil.makeSessionIdKey(cachedSessionId)); + if (cachedSession.isExists()) { + TokenData cachedTokenData = JSON.parseObject(cachedSession.get(), TokenData.class); + if (cachedTokenData != null) { + return cachedTokenData; + } + } + } + SysUser user = sysUserService.getById(userId); + if (user == null) { + throw new MyRuntimeException("构建会话失败,账号不存在!"); + } + String sessionId = this.generateSessionId(user.getLoginName(), deviceType); + TokenData tokenData = this.buildTokenData(user, sessionId, deviceType, appCode); + this.putSessionContext(tokenData, user); + reuseBucket.set(sessionId, SESSION_REUSE_TTL_SECONDS, TimeUnit.SECONDS); + return tokenData; } @Override - public TokenData buildTokenData(SysUser user, String sessionId, int deviceType, String appCode) { + public void removeSessionContext(String sessionId) { + if (StrUtil.isBlank(sessionId)) { + return; + } + redissonClient.getBucket(RedisKeyUtil.makeSessionIdKey(sessionId)).deleteAsync(); + redissonClient.getBucket(RedisKeyUtil.makeSessionWhiteListPermKey(sessionId)).deleteAsync(); + redissonClient.getKeys().deleteByPatternAsync(RedisKeyUtil.getSessionMenuPermPrefix(sessionId) + "*"); + sysDataPermService.removeDataPermCache(sessionId); + sysPermService.removeUserSysPermCache(sessionId); + cacheHelper.removeAllSessionCache(sessionId); + } + + /** + * 生成会话Id,规则与登录链路保持一致:登录名 + 设备类型 + UUID。 + * + * @param loginName 登录名。 + * @param deviceType 设备类型。 + * @return 会话Id。 + */ + private String generateSessionId(String loginName, int deviceType) { + return loginName + "_" + deviceType + "_" + MyCommonUtil.generateUuid(); + } + + /** + * 依据账号信息组装 TokenData(含部门/岗位/角色)。 + * + * @param user 账号对象。 + * @param sessionId 会话Id。 + * @param deviceType 设备类型。 + * @param appCode 第三方开放应用编码,非开放调用时传null。 + * @return 组装好的 TokenData。 + */ + private TokenData buildTokenData(SysUser user, String sessionId, int deviceType, String appCode) { TokenData tokenData = new TokenData(); tokenData.setSessionId(sessionId); tokenData.setUserId(user.getUserId()); @@ -105,8 +171,16 @@ public class UserSessionContextServiceImpl implements UserSessionContextService return tokenData; } - @Override - public void putSessionContext(TokenData tokenData, SysUser user) { + /** + * 写入会话上下文缓存。 + *

包含:SESSIONID(会话)、DATA_PERM(数据权限)、WHITELISTPERM(白名单URL)。

+ *

不写 PERM(URL权限集合):MCP 路径已从登录态拦截器排除、不做"菜单×URL"越权校验; + * 登录链路若复用本方法需另行写入 PERM。

+ * + * @param tokenData 已组装好的 TokenData。 + * @param user 账号对象。 + */ + private void putSessionContext(TokenData tokenData, SysUser user) { String sessionId = tokenData.getSessionId(); long expiredSeconds = appConfig.getSessionExpiredSeconds(); RBucket sessionBucket = redissonClient.getBucket(RedisKeyUtil.makeSessionIdKey(sessionId)); @@ -121,14 +195,4 @@ public class UserSessionContextServiceImpl implements UserSessionContextService whitelistBucket.expire(expiredSeconds, TimeUnit.SECONDS); } } - - @Override - public void removeSessionContext(String sessionId) { - redissonClient.getBucket(RedisKeyUtil.makeSessionIdKey(sessionId)).deleteAsync(); - redissonClient.getBucket(RedisKeyUtil.makeSessionWhiteListPermKey(sessionId)).deleteAsync(); - redissonClient.getKeys().deleteByPatternAsync(RedisKeyUtil.getSessionMenuPermPrefix(sessionId) + "*"); - sysDataPermService.removeDataPermCache(sessionId); - sysPermService.removeUserSysPermCache(sessionId); - cacheHelper.removeAllSessionCache(sessionId); - } } diff --git a/application-tenant/tenant-admin/src/main/resources/tenant-admin-dev.yml b/application-tenant/tenant-admin/src/main/resources/tenant-admin-dev.yml index 48ebc60..90b21ca 100644 --- a/application-tenant/tenant-admin/src/main/resources/tenant-admin-dev.yml +++ b/application-tenant/tenant-admin/src/main/resources/tenant-admin-dev.yml @@ -7,6 +7,19 @@ logging: server: port: 8075 +apelet: + mcp: + # 是否启用 MCP 端点(/tenantadmin/mcp)。MCP 端点、工具装配与鉴权拦截器都定义在 common-core, + # 默认关闭:若不显式开启,任何引入 common-core 的应用都会暴露一个 MCP 端点。 + enabled: true + # 人员密钥的承载应用编码(必填):所有人员密钥共用该应用, + # 它决定密钥能调哪些开放接口;该应用的 appSecret 严禁对外分发。 + app-code: AIMCP + # 管理类工具名称前缀(逗号分隔),仅管理员可调用;用于防止人员密钥提权 + admin-only-tool-prefixes: openapi_app_,openapi_config_ + # 存量配置:请求上下文缺失时 MCP 工具使用的兜底用户Id + default-user-id: 0 + application: # Jwt令牌加密的签名值。该值的长度要超过10个字符(过短会报错)。 tokenSigningKey: tenantadmin-signing-key-signing-key diff --git a/common/common-core/src/main/java/apelet/common/core/mcp/auth/config/McpWebMvcConfig.java b/common/common-core/src/main/java/apelet/common/core/mcp/auth/config/McpWebMvcConfig.java new file mode 100644 index 0000000..dbb2b96 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/mcp/auth/config/McpWebMvcConfig.java @@ -0,0 +1,42 @@ +package apelet.common.core.mcp.auth.config; + +import apelet.common.core.mcp.auth.interceptor.McpAuthInterceptor; +import apelet.common.core.mcp.auth.spi.McpAuthService; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * MCP 端点鉴权的自动装配。 + *

MCP 端点本身在 core,鉴权也应随端点一起生效,否则引入了 core 的应用会暴露一个未鉴权端点。 + * 因此拦截器在这里自动注册,应用无需再写配置;只需要提供 {@link McpAuthService} 实现。

+ *

开关:仅当 {@code apelet.mcp.enabled=true} 时生效。默认关闭,避免引入 core 的应用 + * (如同步服务、鉴权中心等)无意间暴露 MCP 端点。

+ *

应用侧仍需做一件事:若应用有自己的登录态拦截器注册在 {@code /tenantadmin/**} 上, + * 必须把 {@code /tenantadmin/mcp} 与 {@code /tenantadmin/mcp/**} 排除,否则未带登录令牌的密钥调用 + * 会被登录态拦截器抢先拒绝。

+ * + * @author chenchuchuan + * @date 2026-10-14 + */ +@Configuration +@ConditionalOnProperty(name = "apelet.mcp.enabled", havingValue = "true") +public class McpWebMvcConfig implements WebMvcConfigurer { + + private final ObjectProvider mcpAuthServiceProvider; + + public McpWebMvcConfig(ObjectProvider mcpAuthServiceProvider) { + this.mcpAuthServiceProvider = mcpAuthServiceProvider; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + // HandlerInterceptor 即使标了 @Component 也不会被 Spring Boot 自动注册,必须在这里显式挂上。 + // getIfAvailable() 允许应用暂时不实现 SPI:此时拦截器仍会注册,但所有请求一律 401(fail-closed)。 + registry.addInterceptor(new McpAuthInterceptor(mcpAuthServiceProvider.getIfAvailable())) + .addPathPatterns(McpAuthInterceptor.MCP_ENDPOINT_PATH) + .addPathPatterns(McpAuthInterceptor.MCP_ENDPOINT_PATH + "/**"); + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/mcp/auth/interceptor/McpAuthInterceptor.java b/common/common-core/src/main/java/apelet/common/core/mcp/auth/interceptor/McpAuthInterceptor.java new file mode 100644 index 0000000..26daf3f --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/mcp/auth/interceptor/McpAuthInterceptor.java @@ -0,0 +1,142 @@ +package apelet.common.core.mcp.auth.interceptor; + +import apelet.common.core.constant.ApplicationConstant; +import apelet.common.core.constant.ErrorCodeEnum; +import apelet.common.core.mcp.auth.spi.McpAuthResult; +import apelet.common.core.mcp.auth.spi.McpAuthService; +import apelet.common.core.object.ResponseResult; +import apelet.common.core.object.TokenData; +import cn.hutool.core.util.StrUtil; +import com.alibaba.fastjson.JSON; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpServletRequestWrapper; +import java.io.IOException; +import java.io.PrintWriter; + +/** + * MCP 端点({@code /tenantadmin/mcp})的鉴权拦截器。 + *

支持两种调用方身份,按顺序判定:

+ *
    + *
  1. {@code Authorization: Bearer } —— 登录态,兼容以登录 token 连接 MCP 的现有调用方;
  2. + *
  3. 人员标识头 + 人员密钥头 —— 人员级凭证,锁定到具体账号(头名称由 {@link McpAuthService} 约定)。
  4. + *
+ *

两者都带时以 Bearer 优先,避免调用方主动降级到密钥分支。

+ *

凭证校验全部委托给 {@link McpAuthService}:JWT 需要应用的签名密钥,密钥需要账号表与应用表, + * 这些都属于应用侧能力,core 不直接依赖。

+ *

本拦截器由 {@code McpWebMvcConfig} 自动注册,应用只需提供 {@link McpAuthService} 实现。 + * 若应用未实现该 SPI,所有请求一律 401(fail-closed)。

+ * + * @author chenchuchuan + * @date 2026-10-14 + */ +@Slf4j +public class McpAuthInterceptor implements HandlerInterceptor { + + /** + * MCP 端点路径。该路径同时被 {@code McpStreamableController} 使用,两边必须保持一致。 + */ + public static final String MCP_ENDPOINT_PATH = "/tenantadmin/mcp"; + + /** + * 人员钥匙凭证请求头的默认名称;实际名称由 {@link McpAuthService} 约定,此处仅用于读取。 + */ + public static final String HEADER_MCP_USER = "X-Mcp-User"; + public static final String HEADER_MCP_SECRET = "X-Mcp-Secret"; + + /** + * 登录态请求头兜底名称(MCP 规范要求使用 Authorization)。 + */ + private static final String DEFAULT_BEARER_HEADER = "Authorization"; + + private final McpAuthService mcpAuthService; + + public McpAuthInterceptor(McpAuthService mcpAuthService) { + this.mcpAuthService = mcpAuthService; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + if (mcpAuthService == null) { + log.error("MCP 端点鉴权未配置:应用未提供 McpAuthService 实现。"); + this.outputUnauthorized(response, "MCP 鉴权未配置,请联系管理员!"); + return false; + } + String bearerHeader = StrUtil.blankToDefault(mcpAuthService.getBearerHeaderName(), DEFAULT_BEARER_HEADER); + String authorization = request.getHeader(bearerHeader); + McpAuthResult authResult; + if (StrUtil.isNotBlank(authorization)) { + authResult = mcpAuthService.authenticateByJwt(authorization); + } else { + String mobile = request.getHeader(HEADER_MCP_USER); + String credential = request.getHeader(HEADER_MCP_SECRET); + if (StrUtil.isBlank(mobile) && StrUtil.isBlank(credential)) { + this.outputUnauthorized(response, "缺少调用凭证,请提供登录令牌或人员密钥!"); + return false; + } + authResult = mcpAuthService.authenticateByUserSecret(mobile, credential); + } + if (authResult == null || !authResult.isSuccess() || authResult.getTokenData() == null) { + String message = authResult == null ? "鉴权失败!" : authResult.getErrorMessage(); + log.warn("MCP 鉴权失败,keyId={}, 原因={}", authResult == null ? null : authResult.getKeyId(), message); + this.outputUnauthorized(response, message); + return false; + } + TokenData.addToRequest(authResult.getTokenData()); + // 在线表单引擎需要 MenuId 头来选取数据权限规则分支;MCP 无菜单上下文,统一按"通用规则"处理。 + this.wrapRequestWithMenuId(request); + return true; + } + + /** + * 用 HttpServletRequestWrapper 覆盖 MenuId 请求头。 + *

拦截器无法修改已传入的 request 对象,只能包装后替换 RequestContextHolder 中的引用, + * 使下游通过 ContextUtil.getHttpRequest().getHeader("MenuId") 能取到值。

+ * + * @param request 当前请求。 + */ + private void wrapRequestWithMenuId(HttpServletRequest request) { + if (StrUtil.isNotBlank(request.getHeader(ApplicationConstant.HTTP_HEADER_MENU_ID))) { + return; + } + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes == null) { + return; + } + RequestContextHolder.setRequestAttributes( + new ServletRequestAttributes(new MenuIdHeaderRequestWrapper(request), attributes.getResponse())); + } + + /** + * 仅覆盖 MenuId 请求头,其余行为全部委托给原请求(含 attribute 读写,TokenData 依赖它)。 + */ + private static class MenuIdHeaderRequestWrapper extends HttpServletRequestWrapper { + + MenuIdHeaderRequestWrapper(HttpServletRequest request) { + super(request); + } + + @Override + public String getHeader(String name) { + if (ApplicationConstant.HTTP_HEADER_MENU_ID.equalsIgnoreCase(name)) { + return ApplicationConstant.DATA_PERM_ALL_MENU_ID; + } + return super.getHeader(name); + } + } + + private void outputUnauthorized(HttpServletResponse response, String message) throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setHeader("WWW-Authenticate", "Bearer"); + response.setContentType("application/json; charset=utf-8"); + PrintWriter out = response.getWriter(); + out.print(JSON.toJSONString(ResponseResult.error(ErrorCodeEnum.UNAUTHORIZED_LOGIN, message))); + out.flush(); + out.close(); + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/mcp/auth/session/UserSessionContextService.java b/common/common-core/src/main/java/apelet/common/core/mcp/auth/session/UserSessionContextService.java new file mode 100644 index 0000000..5b34ba1 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/mcp/auth/session/UserSessionContextService.java @@ -0,0 +1,33 @@ +package apelet.common.core.mcp.auth.session; + +import apelet.common.core.object.TokenData; + +/** + * 会话上下文构建 SPI。 + *

登录成功后需要写入 Redis 的那组缓存(会话、数据权限、白名单等)依赖应用的账号/角色/权限体系, + * core 不能直接实现,故只定义契约。

+ *

实现方应自带会话复用:同一账号在有效期内重复调用应返回同一份会话,避免每个请求都重建 + * (一次重建包含若干次查库与多次 Redis 写入)。

+ * + * @author chenchuchuan + * @date 2026-10-14 + */ +public interface UserSessionContextService { + + /** + * 为指定账号构建会话上下文并写入缓存。 + * + * @param userId 用户Id。 + * @param appCode 第三方开放应用编码,非开放调用时传null。 + * @param deviceType 设备类型。 + * @return 可直接注入请求上下文的 TokenData。 + */ + TokenData buildAndCacheSession(Long userId, String appCode, int deviceType); + + /** + * 清理指定会话的全部上下文缓存。 + * + * @param sessionId 会话Id。 + */ + void removeSessionContext(String sessionId); +} diff --git a/common/common-core/src/main/java/apelet/common/core/mcp/auth/spi/McpAccountInfo.java b/common/common-core/src/main/java/apelet/common/core/mcp/auth/spi/McpAccountInfo.java new file mode 100644 index 0000000..3370a8f --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/mcp/auth/spi/McpAccountInfo.java @@ -0,0 +1,91 @@ +package apelet.common.core.mcp.auth.spi; + +/** + * MCP 鉴权所需的账号信息。 + *

只暴露构建 {@code TokenData} 与做手机号比对所必需的字段,不暴露密码等敏感信息。 + * {@code isAdmin} 由应用侧按自己的用户类型枚举计算后填入,避免 core 依赖具体应用的枚举。

+ * + * @author chenchuchuan + * @date 2026-10-14 + */ +public class McpAccountInfo { + + private Long userId; + private String loginName; + private String showName; + private Long deptId; + private String mobile; + private String headImageUrl; + /** + * 是否管理员。应用侧按自己的用户类型判定后填入。 + */ + private Boolean isAdmin; + /** + * 账号当前是否可用(未锁定/未停用)。应用侧按自己的用户状态判定后填入。 + */ + private Boolean enabled; + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public String getLoginName() { + return loginName; + } + + public void setLoginName(String loginName) { + this.loginName = loginName; + } + + public String getShowName() { + return showName; + } + + public void setShowName(String showName) { + this.showName = showName; + } + + public Long getDeptId() { + return deptId; + } + + public void setDeptId(Long deptId) { + this.deptId = deptId; + } + + public String getMobile() { + return mobile; + } + + public void setMobile(String mobile) { + this.mobile = mobile; + } + + public String getHeadImageUrl() { + return headImageUrl; + } + + public void setHeadImageUrl(String headImageUrl) { + this.headImageUrl = headImageUrl; + } + + public Boolean getIsAdmin() { + return isAdmin; + } + + public void setIsAdmin(Boolean isAdmin) { + this.isAdmin = isAdmin; + } + + public Boolean getEnabled() { + return enabled; + } + + public void setEnabled(Boolean enabled) { + this.enabled = enabled; + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/mcp/auth/spi/McpAccountProvider.java b/common/common-core/src/main/java/apelet/common/core/mcp/auth/spi/McpAccountProvider.java new file mode 100644 index 0000000..9a7380e --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/mcp/auth/spi/McpAccountProvider.java @@ -0,0 +1,31 @@ +package apelet.common.core.mcp.auth.spi; + +import java.util.Collection; +import java.util.List; + +/** + * MCP 账号信息查询 SPI。 + *

人员密钥的校验需要读账号的手机号与状态,密钥列表需要展示账号姓名,但账号模型属于应用侧, + * core 与 common 模块都不能直接依赖,因此由应用实现本接口注入。

+ * + * @author chenchuchuan + * @date 2026-10-14 + */ +public interface McpAccountProvider { + + /** + * 按用户Id查询 MCP 鉴权所需的账号信息。 + * + * @param userId 用户Id。 + * @return 账号信息;不存在时返回null。 + */ + McpAccountInfo getById(Long userId); + + /** + * 按用户Id批量查询账号信息,用于列表展示补全。 + * + * @param userIds 用户Id集合。 + * @return 账号信息列表;入参为空时返回空列表。 + */ + List listByIds(Collection userIds); +} diff --git a/common/common-core/src/main/java/apelet/common/core/mcp/auth/spi/McpAuthResult.java b/common/common-core/src/main/java/apelet/common/core/mcp/auth/spi/McpAuthResult.java new file mode 100644 index 0000000..b8d3075 --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/mcp/auth/spi/McpAuthResult.java @@ -0,0 +1,56 @@ +package apelet.common.core.mcp.auth.spi; + +import apelet.common.core.object.TokenData; + +/** + * MCP 鉴权结果。 + *

成功时携带已注入请求上下文所需的 {@code TokenData};失败时携带可直接返回给调用方的错误信息。

+ * + * @author chenchuchuan + * @date 2026-10-14 + */ +public class McpAuthResult { + + private boolean success; + private TokenData tokenData; + /** + * 通过人员密钥鉴权时的 keyId,仅用于日志审计;登录态分支为null。 + */ + private String keyId; + private String errorMessage; + + public static McpAuthResult ok(TokenData tokenData) { + return ok(tokenData, null); + } + + public static McpAuthResult ok(TokenData tokenData, String keyId) { + McpAuthResult result = new McpAuthResult(); + result.success = true; + result.tokenData = tokenData; + result.keyId = keyId; + return result; + } + + public static McpAuthResult fail(String errorMessage) { + McpAuthResult result = new McpAuthResult(); + result.success = false; + result.errorMessage = errorMessage; + return result; + } + + public boolean isSuccess() { + return success; + } + + public TokenData getTokenData() { + return tokenData; + } + + public String getKeyId() { + return keyId; + } + + public String getErrorMessage() { + return errorMessage; + } +} diff --git a/common/common-core/src/main/java/apelet/common/core/mcp/auth/spi/McpAuthService.java b/common/common-core/src/main/java/apelet/common/core/mcp/auth/spi/McpAuthService.java new file mode 100644 index 0000000..d1dfe3c --- /dev/null +++ b/common/common-core/src/main/java/apelet/common/core/mcp/auth/spi/McpAuthService.java @@ -0,0 +1,40 @@ +package apelet.common.core.mcp.auth.spi; + +/** + * MCP 端点鉴权 SPI。 + *

MCP 端点在 core({@code McpStreamableController}),但两种凭证的校验都依赖应用侧能力: + * 登录态 JWT 需要应用的签名密钥与会话存储,人员密钥需要账号表与应用表。 + * 因此 core 只定义契约,由应用实现。

+ *

实现方必须 fail-closed:任何无法识别的凭证都要返回失败,不能放行。

+ * + * @author chenchuchuan + * @date 2026-10-14 + */ +public interface McpAuthService { + + /** + * 登录态 JWT 所在的请求头名称。 + *

MCP 规范要求使用 {@code Authorization};由应用返回自己的配置值,避免 core 硬编码。 + * 应用未配置时返回 null,拦截器按 {@code Authorization} 兜底。

+ * + * @return 请求头名称。 + */ + String getBearerHeaderName(); + + /** + * 校验登录态 JWT 并取回会话中的身份。 + * + * @param token 请求头中的完整取值(含 Bearer 前缀)。 + * @return 鉴权结果。 + */ + McpAuthResult authenticateByJwt(String token); + + /** + * 校验"手机号 + 人员密钥"。 + * + * @param mobile 调用方声明的手机号。 + * @param credential 完整凭证,格式为 "keyId.secret"。 + * @return 鉴权结果。 + */ + McpAuthResult authenticateByUserSecret(String mobile, String credential); +} diff --git a/common/common-core/src/main/java/apelet/common/core/mcp/config/McpToolConfig.java b/common/common-core/src/main/java/apelet/common/core/mcp/config/McpToolConfig.java index b6af926..4d25a36 100644 --- a/common/common-core/src/main/java/apelet/common/core/mcp/config/McpToolConfig.java +++ b/common/common-core/src/main/java/apelet/common/core/mcp/config/McpToolConfig.java @@ -7,6 +7,7 @@ import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.server.transport.WebMvcSseServerTransportProvider; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.function.RouterFunction; @@ -20,11 +21,14 @@ import java.util.List; * 该 SDK 不提供 listTools(),故注册时同步收集一份工具清单供调试接口读取(见 McpToolScanner.getTools())。 * 双传输并存:SSE(/mcp/sse + /mcp/message)+ Streamable HTTP(/mcp 单端点)。 * 工具由 McpToolScanner 启动时扫描 @McpToolClass 自动生成并统一注入。 + *

仅在 {@code apelet.mcp.enabled=true} 时装配:本类位于 common-core,引入 core 的应用不应 + * 无条件获得 MCP 端点。

* * @author chenchuchuan * @date 2026-08-27 */ @Configuration +@ConditionalOnProperty(name = "apelet.mcp.enabled", havingValue = "true") public class McpToolConfig { /** diff --git a/common/common-core/src/main/java/apelet/common/core/mcp/controller/McpStreamableController.java b/common/common-core/src/main/java/apelet/common/core/mcp/controller/McpStreamableController.java index 08878ec..f481fbb 100644 --- a/common/common-core/src/main/java/apelet/common/core/mcp/controller/McpStreamableController.java +++ b/common/common-core/src/main/java/apelet/common/core/mcp/controller/McpStreamableController.java @@ -2,6 +2,7 @@ package apelet.common.core.mcp.controller; import apelet.common.core.mcp.transport.StreamableHttpServerTransportProvider; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.util.StreamUtils; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestHeader; @@ -17,12 +18,15 @@ import java.nio.charset.StandardCharsets; * Streamable HTTP 单端点(POST /tenantadmin/mcp)。 * 直接读写 HttpServletRequest/Response,序列化由 provider 完成并原样写入, * 不经过 Spring 消息转换器(应用 ObjectMapper 会丢 @JsonTypeInfo 的 type 判别符 / 双重编码 JsonNode)。 + *

仅在 {@code apelet.mcp.enabled=true} 时暴露:本类位于 common-core,任何引入 core 的应用都会被 + * @ComponentScan 扫到,若无条件暴露就等于凭空多出一个未鉴权端点。

* * @author chenchuchuan * @date 2026-08-28 */ @RestController @RequestMapping("/tenantadmin/mcp") +@ConditionalOnProperty(name = "apelet.mcp.enabled", havingValue = "true") public class McpStreamableController { @Autowired diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/constant/SysUserSecretSpec.java b/common/common-openapi/src/main/java/apelet/common/openapi/constant/SysUserSecretSpec.java new file mode 100644 index 0000000..a9e59e9 --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/constant/SysUserSecretSpec.java @@ -0,0 +1,41 @@ +package apelet.common.openapi.constant; + +/** + * 人员密钥的凭证规格常量。 + *

本类中的参数是安全参数,不是运维参数,不要改成配置项。 + * 理由:secret 只存 SHA-256 摘要(不是 BCrypt 这类慢哈希,无法用计算代价补偿短口令), + * 长度就是唯一的强度来源;keyId 的长度还受数据库列宽约束。改这里等于改安全强度, + * 必须走代码评审而不是改配置。

+ * + * @author chenchuchuan + * @date 2026-10-14 + */ +public final class SysUserSecretSpec { + + /** + * keyId 与 secret 的字符集:大小写字母 + 数字,共 62 个字符。 + *

不用特殊符号是为了便于复制粘贴、避免转义与手输歧义。 + * 调小字符集等于按比例削减熵,例如去掉大写字母会让 32 位的熵从约 190 bit 降到约 165 bit。

+ */ + public static final String RANDOM_ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + + /** + * keyId 长度。 + *

受存储约束:`xy_sys_user_secret.key_id` 为 `varchar(32)`,调大必须同步修改建表脚本, + * 否则插入会被截断或直接报错。

+ */ + public static final int KEY_ID_LENGTH = 32; + + /** + * secret 长度。 + *

这是安全强度本身。32 位 × 字符集 62 ⇒ 约 190 bit 熵,远超需要。 + * 若降到 8 位则只有约 48 bit,在摘要泄漏的前提下可被离线爆破。

+ */ + public static final int SECRET_LENGTH = 32; + + /** + * 私有构造函数,明确标识该常量类的作用。 + */ + private SysUserSecretSpec() { + } +} diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/model/constant/SysUserSecretStatus.java b/common/common-openapi/src/main/java/apelet/common/openapi/constant/SysUserSecretStatus.java similarity index 95% rename from application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/model/constant/SysUserSecretStatus.java rename to common/common-openapi/src/main/java/apelet/common/openapi/constant/SysUserSecretStatus.java index 215505b..bef1989 100644 --- a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/model/constant/SysUserSecretStatus.java +++ b/common/common-openapi/src/main/java/apelet/common/openapi/constant/SysUserSecretStatus.java @@ -1,4 +1,4 @@ -package apelet.tenantadmin.upms.model.constant; +package apelet.common.openapi.constant; import java.util.HashMap; import java.util.Map; diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/controller/SysUserSecretController.java b/common/common-openapi/src/main/java/apelet/common/openapi/controller/SysUserSecretController.java new file mode 100644 index 0000000..f4f984c --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/controller/SysUserSecretController.java @@ -0,0 +1,245 @@ +package apelet.common.openapi.controller; + +import apelet.common.core.annotation.MyRequestBody; +import apelet.common.core.constant.ErrorCodeEnum; +import apelet.common.core.mcp.auth.spi.McpAccountInfo; +import apelet.common.core.mcp.auth.spi.McpAccountProvider; +import apelet.common.core.object.MyPageData; +import apelet.common.core.object.MyPageParam; +import apelet.common.core.object.ResponseResult; +import apelet.common.core.object.TokenData; +import apelet.common.core.util.MyCommonUtil; +import apelet.common.core.util.MyPageUtil; +import apelet.common.log.annotation.OperationLog; +import apelet.common.log.model.constant.SysOperationLogType; +import apelet.common.openapi.constant.SysUserSecretStatus; +import apelet.common.openapi.model.SysUserSecret; +import apelet.common.openapi.service.SysUserSecretService; +import apelet.common.openapi.vo.SysUserSecretIssueVo; +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.util.StrUtil; +import com.github.pagehelper.Page; +import com.github.pagehelper.page.PageMethod; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 用户对外密钥管理接口。 + *

签发是自助的:密钥只能签发给当前登录人自己(userId 取自登录态),不需要管理员代发, + * 明文也不经过第三人。绑定的 OpenAPI 应用取自配置项 {@code apelet.mcp.app-code}。

+ *

归属校验:非管理员只能看到、操作自己的密钥。这层校验是必需的——自助签发意味着这些接口 + * 会对普通登录用户开放,而 {@code resetSecret} 会返回明文新密钥,若不校验归属, + * 任何人都能通过传入他人的记录Id拿到他人的凭证。

+ *

响应中永远不返回 secretHash;明文 secret 只在签发/轮换的响应里出现一次, + * 且对应操作日志显式关闭了响应体记录(saveResponse=false),避免明文密钥落到日志。

+ *

账号姓名/手机号属于应用侧数据,通过 {@link McpAccountProvider} SPI 补全, + * 本模块不依赖具体应用的账号模型。

+ * + * @author chenchuchuan + * @date 2026-10-14 + */ +@Tag(name = "对外密钥管理") +@Slf4j +@RestController +@RequestMapping("${common-openapi.urlPrefix:/openapi}/userSecret") +public class SysUserSecretController { + + @Autowired + private SysUserSecretService sysUserSecretService; + @Autowired(required = false) + private McpAccountProvider mcpAccountProvider; + + /** + * 对外密钥分页列表。非管理员只返回自己的密钥。 + * + * @param sysUserSecretFilter 过滤条件(userId/appId/status)。 + * @param pageParam 分页参数。 + * @return 应答结果对象,包含分页后的密钥列表。 + */ + @PostMapping("/list") + public ResponseResult> list( + @MyRequestBody SysUserSecret sysUserSecretFilter, + @MyRequestBody MyPageParam pageParam) { + TokenData tokenData = TokenData.takeFromRequest(); + if (tokenData == null || tokenData.getUserId() == null) { + return ResponseResult.error(ErrorCodeEnum.UNAUTHORIZED_LOGIN); + } + SysUserSecret filter = sysUserSecretFilter == null ? new SysUserSecret() : sysUserSecretFilter; + if (!Boolean.TRUE.equals(tokenData.getIsAdmin())) { + // 非管理员只能看自己的,忽略其传入的 userId 条件。 + filter.setUserId(tokenData.getUserId()); + } + if (pageParam != null) { + PageMethod.startPage(pageParam.getPageNum(), pageParam.getPageSize()); + } + List secretList = sysUserSecretService.getSysUserSecretList(filter); + long totalCount = 0L; + if (secretList instanceof Page) { + totalCount = ((Page) secretList).getTotal(); + } + this.fillAccountInfo(secretList); + secretList.forEach(this::maskForResponse); + return ResponseResult.success(MyPageUtil.makeResponseData(secretList, totalCount)); + } + + /** + * 查看指定密钥的详情。非管理员只能查看自己的。不返回 secretHash。 + * + * @param userSecretId 密钥记录主键Id。 + * @return 应答结果对象,包含密钥详情。 + */ + @PostMapping("/view") + public ResponseResult view(@MyRequestBody Long userSecretId) { + if (MyCommonUtil.existBlankArgument(userSecretId)) { + return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST); + } + SysUserSecret userSecret = sysUserSecretService.getById(userSecretId); + if (userSecret == null) { + return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, "对外密钥不存在,请刷新后重试!"); + } + if (!this.isSelfOrAdmin(userSecret)) { + return ResponseResult.error(ErrorCodeEnum.NO_OPERATION_PERMISSION); + } + this.fillAccountInfo(Collections.singletonList(userSecret)); + this.maskForResponse(userSecret); + return ResponseResult.success(userSecret); + } + + /** + * 为当前登录人签发一把对外密钥。明文只在本响应中出现一次,之后无法再查。 + *

不能标注 {@code @NoAuthInterface}:本方法依赖 {@code TokenData.takeFromRequest()} 取当前用户, + * 而该方法在没有请求属性时会回退去解析请求头 {@code tokenData} 的 JSON,客户端可以伪造整个身份。 + * 普通用户要能调本接口,正确做法是把本 URL 加入权限白名单(见建表脚本), + * 白名单只跳过 URL 权限校验、不跳过登录校验。

+ * + * @param expireTime 过期时间字符串,留空表示永不过期。 + * @return 应答结果对象,包含 keyId、明文 secret 与完整凭证。 + */ + @OperationLog(type = SysOperationLogType.ADD, saveResponse = false) + @PostMapping("/issue") + public ResponseResult issue(@MyRequestBody String expireTime) { + TokenData tokenData = TokenData.takeFromRequest(); + if (tokenData == null || tokenData.getUserId() == null) { + return ResponseResult.error(ErrorCodeEnum.UNAUTHORIZED_LOGIN); + } + Date expireDate = null; + if (StrUtil.isNotBlank(expireTime)) { + expireDate = DateUtil.parse(expireTime); + } + return ResponseResult.success(sysUserSecretService.issue(tokenData.getUserId(), expireDate)); + } + + /** + * 轮换指定密钥的口令:keyId 不变,旧 secret 立即失效。明文只在本响应中出现一次。 + *

非管理员只能轮换自己的密钥——该接口会返回明文,归属校验不可省略。

+ * + * @param userSecretId 密钥记录主键Id。 + * @return 应答结果对象,包含新的明文 secret 与完整凭证。 + */ + @OperationLog(type = SysOperationLogType.UPDATE, saveResponse = false) + @PostMapping("/resetSecret") + public ResponseResult resetSecret(@MyRequestBody Long userSecretId) { + if (MyCommonUtil.existBlankArgument(userSecretId)) { + return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST); + } + SysUserSecret userSecret = sysUserSecretService.getById(userSecretId); + if (userSecret == null) { + return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, "对外密钥不存在,请刷新后重试!"); + } + if (!this.isSelfOrAdmin(userSecret)) { + return ResponseResult.error(ErrorCodeEnum.NO_OPERATION_PERMISSION); + } + return ResponseResult.success(sysUserSecretService.resetSecret(userSecretId)); + } + + /** + * 启用/停用指定密钥。停用后该密钥的调用一律拒绝。非管理员只能操作自己的密钥。 + * + * @param userSecretId 密钥记录主键Id。 + * @param status 目标状态(1: 启用 0: 停用)。 + * @return 应答结果对象。 + */ + @OperationLog(type = SysOperationLogType.UPDATE, saveResponse = false) + @PostMapping("/updateStatus") + public ResponseResult updateStatus(@MyRequestBody Long userSecretId, @MyRequestBody Integer status) { + if (MyCommonUtil.existBlankArgument(userSecretId, status)) { + return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST); + } + if (!SysUserSecretStatus.isValid(status)) { + return ResponseResult.error(ErrorCodeEnum.INVALID_STATUS_ARGUMENT); + } + SysUserSecret userSecret = sysUserSecretService.getById(userSecretId); + if (userSecret == null) { + return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, "对外密钥不存在,请刷新后重试!"); + } + if (!this.isSelfOrAdmin(userSecret)) { + return ResponseResult.error(ErrorCodeEnum.NO_OPERATION_PERMISSION); + } + if (!sysUserSecretService.updateStatus(userSecretId, status)) { + return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, "对外密钥不存在,请刷新后重试!"); + } + return ResponseResult.success(); + } + + /** + * 补全账号展示字段。密钥表本身不含账号信息,通过 SPI 批量补齐。 + * + * @param secretList 密钥列表。 + */ + private void fillAccountInfo(List secretList) { + if (secretList == null || secretList.isEmpty() || mcpAccountProvider == null) { + return; + } + Set userIdSet = secretList.stream().map(SysUserSecret::getUserId).filter(Objects::nonNull).collect(Collectors.toSet()); + if (userIdSet.isEmpty()) { + return; + } + Map accountMap = mcpAccountProvider.listByIds(userIdSet).stream() + .collect(Collectors.toMap(McpAccountInfo::getUserId, Function.identity(), (a, b) -> a)); + for (SysUserSecret userSecret : secretList) { + McpAccountInfo account = accountMap.get(userSecret.getUserId()); + if (account != null) { + userSecret.setLoginName(account.getLoginName()); + userSecret.setShowName(account.getShowName()); + userSecret.setMobile(account.getMobile()); + } + } + } + + /** + * 判断当前登录人是否有权操作该密钥:管理员放行,否则要求是该密钥的绑定账号。 + * + * @param userSecret 目标密钥记录。 + * @return 有权操作返回true,否则false。 + */ + private boolean isSelfOrAdmin(SysUserSecret userSecret) { + TokenData tokenData = TokenData.takeFromRequest(); + if (tokenData == null || tokenData.getUserId() == null) { + return false; + } + if (Boolean.TRUE.equals(tokenData.getIsAdmin())) { + return true; + } + return Objects.equals(tokenData.getUserId(), userSecret.getUserId()); + } + + /** + * 抹掉不应对外返回的字段:密钥摘要一律清空,手机号脱敏。 + * + * @param userSecret 待处理的对象。 + */ + private void maskForResponse(SysUserSecret userSecret) { + userSecret.setSecretHash(null); + if (StrUtil.isNotBlank(userSecret.getMobile())) { + userSecret.setMobile(StrUtil.hide(userSecret.getMobile(), 3, 7)); + } + } +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/dao/SysUserSecretMapper.java b/common/common-openapi/src/main/java/apelet/common/openapi/dao/SysUserSecretMapper.java new file mode 100644 index 0000000..aadcfdf --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/dao/SysUserSecretMapper.java @@ -0,0 +1,13 @@ +package apelet.common.openapi.dao; + +import apelet.common.core.base.dao.BaseDaoMapper; +import apelet.common.openapi.model.SysUserSecret; + +/** + * 用户对外密钥数据访问操作接口。 + * + * @author chenchuchuan + * @date 2026-10-14 + */ +public interface SysUserSecretMapper extends BaseDaoMapper { +} diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/model/SysUserSecret.java b/common/common-openapi/src/main/java/apelet/common/openapi/model/SysUserSecret.java similarity index 98% rename from application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/model/SysUserSecret.java rename to common/common-openapi/src/main/java/apelet/common/openapi/model/SysUserSecret.java index 3489e8b..c197b8c 100644 --- a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/model/SysUserSecret.java +++ b/common/common-openapi/src/main/java/apelet/common/openapi/model/SysUserSecret.java @@ -1,4 +1,4 @@ -package apelet.tenantadmin.upms.model; +package apelet.common.openapi.model; import apelet.common.core.base.model.BaseModel; import com.baomidou.mybatisplus.annotation.TableField; diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/SysUserSecretService.java b/common/common-openapi/src/main/java/apelet/common/openapi/service/SysUserSecretService.java similarity index 68% rename from application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/SysUserSecretService.java rename to common/common-openapi/src/main/java/apelet/common/openapi/service/SysUserSecretService.java index 84e9acd..3a967a7 100644 --- a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/SysUserSecretService.java +++ b/common/common-openapi/src/main/java/apelet/common/openapi/service/SysUserSecretService.java @@ -1,9 +1,9 @@ -package apelet.tenantadmin.upms.service; +package apelet.common.openapi.service; import apelet.common.core.base.service.IBaseService; import apelet.common.core.object.ResponseResult; -import apelet.tenantadmin.upms.model.SysUserSecret; -import apelet.tenantadmin.upms.vo.SysUserSecretIssueVo; +import apelet.common.openapi.model.SysUserSecret; +import apelet.common.openapi.vo.SysUserSecretIssueVo; import java.util.Date; import java.util.List; @@ -24,23 +24,27 @@ public interface SysUserSecretService extends IBaseService String CREDENTIAL_SEPARATOR = "."; /** - * 获取用户对外密钥列表(带账号信息)。 + * 获取用户对外密钥列表。 + *

只按密钥表自身的字段过滤(用户Id/应用Id/状态),不关联账号表——账号信息由调用方按需补充。 + * 排序固定为创建时间倒序,不接受外部排序串,避免拼接SQL。

* - * @param filter 过滤对象。 - * @param orderBy 排序字符串。 + * @param filter 过滤对象。 * @return 过滤后的对外密钥列表。 */ - List getSysUserSecretList(SysUserSecret filter, String orderBy); + List getSysUserSecretList(SysUserSecret filter); /** - * 为指定用户签发一把对外密钥,明文 secret 只在返回值中出现一次。 + * 为指定账号签发一把对外密钥,明文 secret 只在返回值中出现一次。 + *

全生命周期只允许签发一次:该账号已有记录(无论启用/停用/过期)时直接拒绝, + * 更换密钥需由管理员重置。

+ *

绑定的 OpenAPI 应用取自配置项 {@code apelet.mcp.app-code}:所有人员密钥共用一个承载应用, + * 由该应用在"开放接口配置"里的授权范围决定密钥能调哪些接口。

* * @param userId 绑定的用户Id。 - * @param appId 绑定的OpenAPI应用Id,决定该密钥可调用的开放接口范围。 * @param expireTime 过期时间,null表示永不过期。 * @return 签发结果,含明文 secret。 */ - SysUserSecretIssueVo issue(Long userId, Long appId, Date expireTime); + SysUserSecretIssueVo issue(Long userId, Date expireTime); /** * 轮换密钥口令:keyId 保持不变,旧 secret 立即失效。 diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/impl/SysUserSecretServiceImpl.java b/common/common-openapi/src/main/java/apelet/common/openapi/service/impl/SysUserSecretServiceImpl.java similarity index 52% rename from application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/impl/SysUserSecretServiceImpl.java rename to common/common-openapi/src/main/java/apelet/common/openapi/service/impl/SysUserSecretServiceImpl.java index 8a1252a..489af9f 100644 --- a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/service/impl/SysUserSecretServiceImpl.java +++ b/common/common-openapi/src/main/java/apelet/common/openapi/service/impl/SysUserSecretServiceImpl.java @@ -1,28 +1,29 @@ -package apelet.tenantadmin.upms.service.impl; +package apelet.common.openapi.service.impl; -import apelet.common.core.annotation.MyDataSource; import apelet.common.core.base.dao.BaseDaoMapper; import apelet.common.core.base.service.BaseService; import apelet.common.core.constant.ErrorCodeEnum; import apelet.common.core.constant.GlobalDeletedFlag; import apelet.common.core.exception.MyRuntimeException; +import apelet.common.core.mcp.auth.spi.McpAccountInfo; +import apelet.common.core.mcp.auth.spi.McpAccountProvider; import apelet.common.core.object.ResponseResult; -import apelet.common.core.util.MyModelUtil; -import apelet.common.sequence.wrapper.IdGeneratorWrapper; -import apelet.tenantadmin.config.DataSourceType; -import apelet.tenantadmin.upms.dao.SysUserSecretMapper; -import apelet.tenantadmin.upms.model.SysUser; -import apelet.tenantadmin.upms.model.SysUserSecret; -import apelet.tenantadmin.upms.model.constant.SysUserSecretStatus; -import apelet.tenantadmin.upms.model.constant.SysUserStatus; -import apelet.tenantadmin.upms.service.SysUserSecretService; -import apelet.tenantadmin.upms.service.SysUserService; -import apelet.tenantadmin.upms.vo.SysUserSecretIssueVo; +import apelet.common.core.object.TokenData; +import apelet.common.openapi.constant.SysUserSecretSpec; +import apelet.common.openapi.constant.SysUserSecretStatus; +import apelet.common.openapi.dao.SysUserSecretMapper; +import apelet.common.openapi.model.OpenApiApp; +import apelet.common.openapi.model.SysUserSecret; +import apelet.common.openapi.service.OpenApiAppService; +import apelet.common.openapi.service.SysUserSecretService; +import apelet.common.openapi.vo.SysUserSecretIssueVo; import cn.hutool.core.util.HexUtil; +import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import java.nio.charset.StandardCharsets; @@ -36,22 +37,19 @@ import java.util.Objects; /** * 用户对外密钥数据服务类。 *

对外凭证形态为 "keyId.secret":keyId 明文存储用于定位与审计,secret 只存 SHA-256 摘要。 - * 定位永远走 keyId(唯一索引),手机号只做比对,不参与定位——因为 xy_sys_user.mobile 无唯一索引。

+ * 定位永远走 keyId(唯一索引),手机号只做比对、不参与定位——因为账号表的手机号不一定有唯一约束。

+ *

账号信息(手机号/是否可用)依赖应用的账号体系,通过 {@link McpAccountProvider} SPI 获取; + * 承载应用信息则直接使用本模块的 {@link OpenApiAppService}。

* * @author chenchuchuan * @date 2026-10-14 */ @Slf4j -@MyDataSource(DataSourceType.TENANT_ADMIN) @Service("sysUserSecretService") public class SysUserSecretServiceImpl extends BaseService implements SysUserSecretService { - /** - * 生成 keyId 与 secret 用的字符集。只使用大小写字母和数字,便于复制粘贴。 - */ - private static final String RANDOM_ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - private static final int KEY_ID_LENGTH = 32; - private static final int SECRET_LENGTH = 32; + private static final int APP_STATUS_ENABLED = 1; + /** * 密钥属于长期凭证,必须使用密码学安全的随机源。 */ @@ -60,9 +58,15 @@ public class SysUserSecretServiceImpl extends BaseService i @Autowired private SysUserSecretMapper sysUserSecretMapper; @Autowired - private SysUserService sysUserService; - @Autowired - private IdGeneratorWrapper idGenerator; + private OpenApiAppService openApiAppService; + @Autowired(required = false) + private McpAccountProvider mcpAccountProvider; + + /** + * 人员密钥的承载应用编码。所有人员密钥共用一个 OpenAPI 应用,由它承载"能调哪些开放接口"的授权关系。 + */ + @Value("${apelet.mcp.app-code:}") + private String mcpAppCode; /** * 返回主对象的Mapper对象。 @@ -75,30 +79,40 @@ public class SysUserSecretServiceImpl extends BaseService i } @Override - public List getSysUserSecretList(SysUserSecret filter, String orderBy) { - return sysUserSecretMapper.getSysUserSecretList(filter, orderBy); + public List getSysUserSecretList(SysUserSecret filter) { + SysUserSecret condition = new SysUserSecret(); + if (filter != null) { + condition.setUserId(filter.getUserId()); + condition.setAppId(filter.getAppId()); + condition.setStatus(filter.getStatus()); + } + QueryWrapper wrapper = new QueryWrapper<>(condition); + wrapper.orderByDesc("create_time"); + return this.list(wrapper); } @Override - public SysUserSecretIssueVo issue(Long userId, Long appId, Date expireTime) { - SysUser user = sysUserService.getById(userId); - if (user == null) { + public SysUserSecretIssueVo issue(Long userId, Date expireTime) { + McpAccountInfo account = this.getAccount(userId); + if (account == null) { throw new MyRuntimeException("签发失败,指定的用户不存在!"); } - if (StrUtil.isBlank(user.getMobile())) { + if (StrUtil.isBlank(account.getMobile())) { throw new MyRuntimeException("签发失败,该账号未维护手机号,无法用于外部调用身份比对!"); } - String keyId = this.randomString(KEY_ID_LENGTH); - String secret = this.randomString(SECRET_LENGTH); + this.assertNoSecretIssued(userId); + Long appId = this.resolveMcpAppId(); + String keyId = this.randomString(SysUserSecretSpec.KEY_ID_LENGTH); + String secret = this.randomString(SysUserSecretSpec.SECRET_LENGTH); SysUserSecret userSecret = new SysUserSecret(); - userSecret.setUserSecretId(idGenerator.nextLongId()); + userSecret.setUserSecretId(IdUtil.getSnowflakeNextId()); userSecret.setKeyId(keyId); userSecret.setUserId(userId); userSecret.setAppId(appId); userSecret.setSecretHash(this.sha256Hex(secret)); userSecret.setStatus(SysUserSecretStatus.STATUS_ENABLED); userSecret.setExpireTime(expireTime); - MyModelUtil.fillCommonsForInsert(userSecret); + this.fillCommonsWithCurrentUser(userSecret, true); userSecret.setDeletedFlag(GlobalDeletedFlag.NORMAL); sysUserSecretMapper.insert(userSecret); return this.buildIssueVo(userSecret, secret); @@ -110,11 +124,11 @@ public class SysUserSecretServiceImpl extends BaseService i if (original == null) { return null; } - String secret = this.randomString(SECRET_LENGTH); + String secret = this.randomString(SysUserSecretSpec.SECRET_LENGTH); SysUserSecret updating = new SysUserSecret(); updating.setUserSecretId(userSecretId); updating.setSecretHash(this.sha256Hex(secret)); - MyModelUtil.fillCommonsForUpdate(updating, original); + this.fillCommonsWithCurrentUser(updating, false); sysUserSecretMapper.updateById(updating); return this.buildIssueVo(original, secret); } @@ -131,10 +145,26 @@ public class SysUserSecretServiceImpl extends BaseService i SysUserSecret updating = new SysUserSecret(); updating.setUserSecretId(userSecretId); updating.setStatus(status); - MyModelUtil.fillCommonsForUpdate(updating, original); + this.fillCommonsWithCurrentUser(updating, false); return sysUserSecretMapper.updateById(updating) == 1; } + /** + * 校验该账号是否已经生成过对外密钥。 + *

全生命周期只允许生成一次:不论已有记录是启用、停用还是已过期,都拒绝再次生成。 + * 需要更换密钥时由管理员执行重置(轮换 secret,keyId 不变),或由管理员停用后另行处理。

+ *

这样"密钥 ⇄ 人"始终一一对应,吊销、轮换、审计都不会有歧义。

+ * + * @param userId 用户Id。 + */ + private void assertNoSecretIssued(Long userId) { + SysUserSecret condition = new SysUserSecret(); + condition.setUserId(userId); + if (!this.list(new QueryWrapper<>(condition)).isEmpty()) { + throw new MyRuntimeException("该账号已生成过对外密钥,不可重复生成;如需重置请联系管理员!"); + } + } + @Override public ResponseResult authenticate(String mobile, String credential) { if (StrUtil.hasBlank(mobile, credential)) { @@ -164,23 +194,74 @@ public class SysUserSecretServiceImpl extends BaseService i if (userSecret.getExpireTime() != null && userSecret.getExpireTime().before(new Date())) { return ResponseResult.error(ErrorCodeEnum.INVALID_ACCESS_TOKEN, "凭证已过期,请联系管理员!"); } - SysUser user = sysUserService.getById(userSecret.getUserId()); - if (user == null) { + McpAccountInfo account = this.getAccount(userSecret.getUserId()); + if (account == null) { return ResponseResult.error(ErrorCodeEnum.INVALID_ACCESS_TOKEN, "凭证无效!"); } - if (user.getUserStatus() != null && user.getUserStatus() == SysUserStatus.STATUS_LOCKED) { - return ResponseResult.error(ErrorCodeEnum.INVALID_USER_STATUS, "登录失败,用户账号被锁定!"); + if (Boolean.FALSE.equals(account.getEnabled())) { + return ResponseResult.error(ErrorCodeEnum.INVALID_USER_STATUS, "账号不可用,请联系管理员!"); } - if (!Objects.equals(mobile, user.getMobile())) { + if (!Objects.equals(mobile, account.getMobile())) { return ResponseResult.error(ErrorCodeEnum.INVALID_ACCESS_TOKEN, "凭证与用户不匹配!"); } - userSecret.setLoginName(user.getLoginName()); - userSecret.setShowName(user.getShowName()); + userSecret.setLoginName(account.getLoginName()); + userSecret.setShowName(account.getShowName()); this.touchLastUsedTime(userSecret.getUserSecretId()); return ResponseResult.success(userSecret); } /** + * 解析人员密钥的承载应用Id。 + *

所有人员密钥共用一个 OpenAPI 应用(配置项 {@code apelet.mcp.app-code}):该应用本身不对任何调用方分发 + * appSecret,只用于承载"这批密钥能调哪些开放接口"的授权关系。

+ * + * @return 承载应用的主键Id。 + */ + private Long resolveMcpAppId() { + if (StrUtil.isBlank(mcpAppCode)) { + throw new MyRuntimeException("签发失败,未配置 MCP 承载应用编码(apelet.mcp.app-code)!"); + } + OpenApiApp appFilter = new OpenApiApp(); + appFilter.setAppCode(mcpAppCode.trim()); + OpenApiApp app = openApiAppService.getOne(new QueryWrapper<>(appFilter)); + if (app == null || app.getStatus() == null || app.getStatus() != APP_STATUS_ENABLED) { + throw new MyRuntimeException("签发失败,配置的 MCP 承载应用不存在或已停用,appCode=" + mcpAppCode + "!"); + } + return app.getId(); + } + + /** + * 按用户Id取账号信息。 + * + * @param userId 用户Id。 + * @return 账号信息;未提供 SPI 实现或账号不存在时返回null。 + */ + private McpAccountInfo getAccount(Long userId) { + if (mcpAccountProvider == null || userId == null) { + return null; + } + return mcpAccountProvider.getById(userId); + } + + /** + * 填充审计字段。应用侧可能没有登录态(如直调工具),因此不依赖 TokenData 一定存在。 + * + * @param data 实体对象。 + * @param isInsert 是否为新增。 + */ + private void fillCommonsWithCurrentUser(SysUserSecret data, boolean isInsert) { + Date now = new Date(); + TokenData tokenData = TokenData.takeFromRequest(); + Long currentUserId = tokenData == null ? null : tokenData.getUserId(); + if (isInsert) { + data.setCreateTime(now); + data.setCreateUserId(currentUserId); + } + data.setUpdateTime(now); + data.setUpdateUserId(currentUserId); + } + + /** * 更新最后一次使用时间。该动作不参与鉴权结果,失败只记日志。 * * @param userSecretId 密钥记录主键Id。 @@ -206,14 +287,30 @@ public class SysUserSecretServiceImpl extends BaseService i return result; } + /** + * 生成指定长度的随机串,字符集见 {@link SysUserSecretSpec#RANDOM_ALPHABET}。 + *

使用 {@link SecureRandom} 而非普通随机数:密钥是长期凭证,可预测的随机源等于没有随机。 + * 长度等规格是安全参数,集中在 {@link SysUserSecretSpec},不接配置项。

+ * + * @param length 目标长度。 + * @return 随机串。 + */ private String randomString(int length) { StringBuilder sb = new StringBuilder(length); for (int i = 0; i < length; i++) { - sb.append(RANDOM_ALPHABET.charAt(SECURE_RANDOM.nextInt(RANDOM_ALPHABET.length()))); + sb.append(SysUserSecretSpec.RANDOM_ALPHABET.charAt(SECURE_RANDOM.nextInt(SysUserSecretSpec.RANDOM_ALPHABET.length()))); } return sb.toString(); } + /** + * 计算 secret 的 SHA-256 十六进制摘要。 + *

刻意不用 BCrypt:该摘要每个请求都要参与校验,BCrypt 单次约百毫秒不可接受; + * 32 位随机串的熵足够,无需靠慢哈希抵抗爆破。

+ * + * @param text 待摘要的明文。 + * @return 64 位十六进制摘要。 + */ private String sha256Hex(String text) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); diff --git a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/vo/SysUserSecretIssueVo.java b/common/common-openapi/src/main/java/apelet/common/openapi/vo/SysUserSecretIssueVo.java similarity index 66% rename from application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/vo/SysUserSecretIssueVo.java rename to common/common-openapi/src/main/java/apelet/common/openapi/vo/SysUserSecretIssueVo.java index a215cc1..67583dc 100644 --- a/application-tenant/tenant-admin/src/main/java/apelet/tenantadmin/upms/vo/SysUserSecretIssueVo.java +++ b/common/common-openapi/src/main/java/apelet/common/openapi/vo/SysUserSecretIssueVo.java @@ -1,6 +1,5 @@ -package apelet.tenantadmin.upms.vo; +package apelet.common.openapi.vo; -import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.util.Date; @@ -18,30 +17,25 @@ public class SysUserSecretIssueVo { /** * 主键Id。 */ - @Schema(description = "主键Id") private Long userSecretId; /** * 密钥标识(明文,可用于定位与审计)。 */ - @Schema(description = "密钥标识") private String keyId; /** * 密钥口令明文,仅本次响应可见。 */ - @Schema(description = "密钥口令明文,仅本次响应可见,请妥善保存") private String secret; /** * 完整凭证(keyId + "." + secret),可直接交给调用方配置。 */ - @Schema(description = "完整凭证,格式为 keyId.secret") private String credential; /** * 过期时间(null表示永不过期)。 */ - @Schema(description = "过期时间,null表示永不过期") private Date expireTime; } From 080f3ce61b122f2a9a4d7b23fdcbe3ea205dace0 Mon Sep 17 00:00:00 2001 From: chenchuchuan <13554600537@163.com> Date: Tue, 20 Oct 2026 10:28:31 +0800 Subject: [PATCH 4/4] =?UTF-8?q?refactor(common):=20=E7=A7=BB=E9=99=A4commo?= =?UTF-8?q?n-botp=E6=A8=A1=E5=9D=97=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 从common/pom.xml中删除了common-botp模块的声明 - 该模块已从项目结构中移除 --- common/pom.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/common/pom.xml b/common/pom.xml index cd7c4aa..a2c592f 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -15,7 +15,6 @@ common-flow common-flow-online common-online - common-botp common-tenant common-tenant-online