24 changed files with 1780 additions and 5 deletions
@ -0,0 +1,67 @@
@@ -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<McpAccountInfo> listByIds(Collection<Long> 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; |
||||
} |
||||
} |
||||
@ -0,0 +1,106 @@
@@ -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 的租户端实现。 |
||||
* <p>两条分支的落点一致:都产出一份 {@code TokenData},由拦截器注入请求上下文,下游无感知。</p> |
||||
* |
||||
* @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<String> 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<SysUserSecret> 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(); |
||||
} |
||||
} |
||||
@ -0,0 +1,198 @@
@@ -0,0 +1,198 @@
|
||||
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; |
||||
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.SysUserService; |
||||
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; |
||||
|
||||
/** |
||||
* 会话上下文构建 SPI 的租户端实现。 |
||||
* <p>TokenData 的字段来源、会话Id 生成规则、会话缓存写入方式均对齐登录链路的既有做法, |
||||
* 保证"用密钥进来的身份"和"用密码登录的身份"在下游完全等价。</p> |
||||
* <p>带会话复用:同一账号在 TTL 内重复调用返回同一份会话,避免每个请求都重建 |
||||
* (一次重建包含若干次查库与多次 Redis 写入)。</p> |
||||
* |
||||
* @author chenchuchuan |
||||
* @date 2026-10-14 |
||||
*/ |
||||
@Slf4j |
||||
@MyDataSource(DataSourceType.TENANT_ADMIN) |
||||
@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 |
||||
private RedissonClient redissonClient; |
||||
@Autowired |
||||
private SessionCacheHelper cacheHelper; |
||||
@Autowired |
||||
private DataFilterProperties dataFilterProperties; |
||||
@Autowired |
||||
private SysUserService sysUserService; |
||||
@Autowired |
||||
private SysPostService sysPostService; |
||||
@Autowired |
||||
private SysRoleService sysRoleService; |
||||
@Autowired |
||||
private SysDataPermService sysDataPermService; |
||||
@Autowired |
||||
private SysPermService sysPermService; |
||||
@Autowired |
||||
private SysPermWhitelistService sysPermWhitelistService; |
||||
|
||||
@Override |
||||
public TokenData buildAndCacheSession(Long userId, String appCode, int deviceType) { |
||||
RBucket<String> reuseBucket = redissonClient.getBucket(SESSION_REUSE_KEY_PREFIX + userId); |
||||
String cachedSessionId = reuseBucket.get(); |
||||
if (StrUtil.isNotBlank(cachedSessionId)) { |
||||
RBucket<String> 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 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()); |
||||
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<SysUserPost> userPostList = sysPostService.getSysUserPostListByUserId(user.getUserId()); |
||||
if (CollUtil.isNotEmpty(userPostList)) { |
||||
Set<Long> deptPostIdSet = userPostList.stream().map(SysUserPost::getDeptPostId).collect(Collectors.toSet()); |
||||
tokenData.setDeptPostIds(StrUtil.join(",", deptPostIdSet)); |
||||
Set<Long> postIdSet = userPostList.stream().map(SysUserPost::getPostId).collect(Collectors.toSet()); |
||||
tokenData.setPostIds(StrUtil.join(",", postIdSet)); |
||||
} |
||||
// 2026-07-28 角色用户关联表,增加组织,导致角色id 可能为空
|
||||
List<SysUserRole> userRoleList = sysRoleService.getSysUserRoleListByUserId(user.getUserId()); |
||||
if (CollUtil.isNotEmpty(userRoleList)) { |
||||
Set<Long> userRoleIdSet = userRoleList.stream().map(SysUserRole::getRoleId).filter(Objects::nonNull).collect(Collectors.toSet()); |
||||
tokenData.setRoleIds(StrUtil.join(",", userRoleIdSet)); |
||||
} |
||||
return tokenData; |
||||
} |
||||
|
||||
/** |
||||
* 写入会话上下文缓存。 |
||||
* <p>包含:SESSIONID(会话)、DATA_PERM(数据权限)、WHITELISTPERM(白名单URL)。</p> |
||||
* <p><b>不写 PERM(URL权限集合)</b>:MCP 路径已从登录态拦截器排除、不做"菜单×URL"越权校验; |
||||
* 登录链路若复用本方法需另行写入 PERM。</p> |
||||
* |
||||
* @param tokenData 已组装好的 TokenData。 |
||||
* @param user 账号对象。 |
||||
*/ |
||||
private void putSessionContext(TokenData tokenData, SysUser user) { |
||||
String sessionId = tokenData.getSessionId(); |
||||
long expiredSeconds = appConfig.getSessionExpiredSeconds(); |
||||
RBucket<String> 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<String> whitelistBucket = redissonClient.getBucket(RedisKeyUtil.makeSessionWhiteListPermKey(sessionId)); |
||||
whitelistBucket.set(JSON.toJSONString(sysPermWhitelistService.getWhitelistPermList())); |
||||
whitelistBucket.expire(expiredSeconds, TimeUnit.SECONDS); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,42 @@
@@ -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 端点鉴权的自动装配。 |
||||
* <p>MCP 端点本身在 core,鉴权也应随端点一起生效,否则引入了 core 的应用会暴露一个未鉴权端点。 |
||||
* 因此拦截器在这里自动注册,应用无需再写配置;只需要提供 {@link McpAuthService} 实现。</p> |
||||
* <p><b>开关</b>:仅当 {@code apelet.mcp.enabled=true} 时生效。默认关闭,避免引入 core 的应用 |
||||
* (如同步服务、鉴权中心等)无意间暴露 MCP 端点。</p> |
||||
* <p><b>应用侧仍需做一件事</b>:若应用有自己的登录态拦截器注册在 {@code /tenantadmin/**} 上, |
||||
* 必须把 {@code /tenantadmin/mcp} 与 {@code /tenantadmin/mcp/**} 排除,否则未带登录令牌的密钥调用 |
||||
* 会被登录态拦截器抢先拒绝。</p> |
||||
* |
||||
* @author chenchuchuan |
||||
* @date 2026-10-14 |
||||
*/ |
||||
@Configuration |
||||
@ConditionalOnProperty(name = "apelet.mcp.enabled", havingValue = "true") |
||||
public class McpWebMvcConfig implements WebMvcConfigurer { |
||||
|
||||
private final ObjectProvider<McpAuthService> mcpAuthServiceProvider; |
||||
|
||||
public McpWebMvcConfig(ObjectProvider<McpAuthService> 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 + "/**"); |
||||
} |
||||
} |
||||
@ -0,0 +1,142 @@
@@ -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})的鉴权拦截器。 |
||||
* <p>支持两种调用方身份,按顺序判定:</p> |
||||
* <ol> |
||||
* <li>{@code Authorization: Bearer <jwt>} —— 登录态,兼容以登录 token 连接 MCP 的现有调用方;</li> |
||||
* <li>人员标识头 + 人员密钥头 —— 人员级凭证,锁定到具体账号(头名称由 {@link McpAuthService} 约定)。</li> |
||||
* </ol> |
||||
* <p>两者都带时以 Bearer 优先,避免调用方主动降级到密钥分支。</p> |
||||
* <p>凭证校验全部委托给 {@link McpAuthService}:JWT 需要应用的签名密钥,密钥需要账号表与应用表, |
||||
* 这些都属于应用侧能力,core 不直接依赖。</p> |
||||
* <p><b>本拦截器由 {@code McpWebMvcConfig} 自动注册</b>,应用只需提供 {@link McpAuthService} 实现。 |
||||
* 若应用未实现该 SPI,所有请求一律 401(fail-closed)。</p> |
||||
* |
||||
* @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 请求头。 |
||||
* <p>拦截器无法修改已传入的 request 对象,只能包装后替换 RequestContextHolder 中的引用, |
||||
* 使下游通过 ContextUtil.getHttpRequest().getHeader("MenuId") 能取到值。</p> |
||||
* |
||||
* @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(); |
||||
} |
||||
} |
||||
@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
|
||||
package apelet.common.core.mcp.auth.session; |
||||
|
||||
import apelet.common.core.object.TokenData; |
||||
|
||||
/** |
||||
* 会话上下文构建 SPI。 |
||||
* <p>登录成功后需要写入 Redis 的那组缓存(会话、数据权限、白名单等)依赖应用的账号/角色/权限体系, |
||||
* core 不能直接实现,故只定义契约。</p> |
||||
* <p>实现方应自带会话复用:同一账号在有效期内重复调用应返回同一份会话,避免每个请求都重建 |
||||
* (一次重建包含若干次查库与多次 Redis 写入)。</p> |
||||
* |
||||
* @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); |
||||
} |
||||
@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
|
||||
package apelet.common.core.mcp.auth.spi; |
||||
|
||||
/** |
||||
* MCP 鉴权所需的账号信息。 |
||||
* <p>只暴露构建 {@code TokenData} 与做手机号比对所必需的字段,不暴露密码等敏感信息。 |
||||
* {@code isAdmin} 由应用侧按自己的用户类型枚举计算后填入,避免 core 依赖具体应用的枚举。</p> |
||||
* |
||||
* @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; |
||||
} |
||||
} |
||||
@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
|
||||
package apelet.common.core.mcp.auth.spi; |
||||
|
||||
import java.util.Collection; |
||||
import java.util.List; |
||||
|
||||
/** |
||||
* MCP 账号信息查询 SPI。 |
||||
* <p>人员密钥的校验需要读账号的手机号与状态,密钥列表需要展示账号姓名,但账号模型属于应用侧, |
||||
* core 与 common 模块都不能直接依赖,因此由应用实现本接口注入。</p> |
||||
* |
||||
* @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<McpAccountInfo> listByIds(Collection<Long> userIds); |
||||
} |
||||
@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
|
||||
package apelet.common.core.mcp.auth.spi; |
||||
|
||||
import apelet.common.core.object.TokenData; |
||||
|
||||
/** |
||||
* MCP 鉴权结果。 |
||||
* <p>成功时携带已注入请求上下文所需的 {@code TokenData};失败时携带可直接返回给调用方的错误信息。</p> |
||||
* |
||||
* @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; |
||||
} |
||||
} |
||||
@ -0,0 +1,40 @@
@@ -0,0 +1,40 @@
|
||||
package apelet.common.core.mcp.auth.spi; |
||||
|
||||
/** |
||||
* MCP 端点鉴权 SPI。 |
||||
* <p>MCP 端点在 core({@code McpStreamableController}),但两种凭证的校验都依赖应用侧能力: |
||||
* 登录态 JWT 需要应用的签名密钥与会话存储,人员密钥需要账号表与应用表。 |
||||
* 因此 core 只定义契约,由应用实现。</p> |
||||
* <p><b>实现方必须 fail-closed</b>:任何无法识别的凭证都要返回失败,不能放行。</p> |
||||
* |
||||
* @author chenchuchuan |
||||
* @date 2026-10-14 |
||||
*/ |
||||
public interface McpAuthService { |
||||
|
||||
/** |
||||
* 登录态 JWT 所在的请求头名称。 |
||||
* <p>MCP 规范要求使用 {@code Authorization};由应用返回自己的配置值,避免 core 硬编码。 |
||||
* 应用未配置时返回 null,拦截器按 {@code Authorization} 兜底。</p> |
||||
* |
||||
* @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); |
||||
} |
||||
@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
|
||||
package apelet.common.openapi.constant; |
||||
|
||||
/** |
||||
* 人员密钥的凭证规格常量。 |
||||
* <p><b>本类中的参数是安全参数,不是运维参数,不要改成配置项。</b> |
||||
* 理由:secret 只存 SHA-256 摘要(不是 BCrypt 这类慢哈希,无法用计算代价补偿短口令), |
||||
* 长度就是唯一的强度来源;keyId 的长度还受数据库列宽约束。改这里等于改安全强度, |
||||
* 必须走代码评审而不是改配置。</p> |
||||
* |
||||
* @author chenchuchuan |
||||
* @date 2026-10-14 |
||||
*/ |
||||
public final class SysUserSecretSpec { |
||||
|
||||
/** |
||||
* keyId 与 secret 的字符集:大小写字母 + 数字,共 62 个字符。 |
||||
* <p>不用特殊符号是为了便于复制粘贴、避免转义与手输歧义。 |
||||
* <b>调小字符集等于按比例削减熵</b>,例如去掉大写字母会让 32 位的熵从约 190 bit 降到约 165 bit。</p> |
||||
*/ |
||||
public static final String RANDOM_ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; |
||||
|
||||
/** |
||||
* keyId 长度。 |
||||
* <p><b>受存储约束</b>:`xy_sys_user_secret.key_id` 为 `varchar(32)`,调大必须同步修改建表脚本, |
||||
* 否则插入会被截断或直接报错。</p> |
||||
*/ |
||||
public static final int KEY_ID_LENGTH = 32; |
||||
|
||||
/** |
||||
* secret 长度。 |
||||
* <p><b>这是安全强度本身</b>。32 位 × 字符集 62 ⇒ 约 190 bit 熵,远超需要。 |
||||
* 若降到 8 位则只有约 48 bit,在摘要泄漏的前提下可被离线爆破。</p> |
||||
*/ |
||||
public static final int SECRET_LENGTH = 32; |
||||
|
||||
/** |
||||
* 私有构造函数,明确标识该常量类的作用。 |
||||
*/ |
||||
private SysUserSecretSpec() { |
||||
} |
||||
} |
||||
@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
|
||||
package apelet.common.openapi.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<Object, String> 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() { |
||||
} |
||||
} |
||||
@ -0,0 +1,245 @@
@@ -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; |
||||
|
||||
/** |
||||
* 用户对外密钥管理接口。 |
||||
* <p><b>签发是自助的</b>:密钥只能签发给当前登录人自己(userId 取自登录态),不需要管理员代发, |
||||
* 明文也不经过第三人。绑定的 OpenAPI 应用取自配置项 {@code apelet.mcp.app-code}。</p> |
||||
* <p><b>归属校验</b>:非管理员只能看到、操作自己的密钥。这层校验是必需的——自助签发意味着这些接口 |
||||
* 会对普通登录用户开放,而 {@code resetSecret} 会返回明文新密钥,若不校验归属, |
||||
* 任何人都能通过传入他人的记录Id拿到他人的凭证。</p> |
||||
* <p>响应中永远不返回 secretHash;明文 secret 只在签发/轮换的响应里出现一次, |
||||
* 且对应操作日志显式关闭了响应体记录(saveResponse=false),避免明文密钥落到日志。</p> |
||||
* <p>账号姓名/手机号属于应用侧数据,通过 {@link McpAccountProvider} SPI 补全, |
||||
* 本模块不依赖具体应用的账号模型。</p> |
||||
* |
||||
* @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<MyPageData<SysUserSecret>> 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<SysUserSecret> secretList = sysUserSecretService.getSysUserSecretList(filter); |
||||
long totalCount = 0L; |
||||
if (secretList instanceof Page) { |
||||
totalCount = ((Page<SysUserSecret>) 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<SysUserSecret> 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); |
||||
} |
||||
|
||||
/** |
||||
* 为当前登录人签发一把对外密钥。明文只在本响应中出现一次,之后无法再查。 |
||||
* <p><b>不能标注 {@code @NoAuthInterface}</b>:本方法依赖 {@code TokenData.takeFromRequest()} 取当前用户, |
||||
* 而该方法在没有请求属性时会回退去解析请求头 {@code tokenData} 的 JSON,客户端可以伪造整个身份。 |
||||
* 普通用户要能调本接口,正确做法是把本 URL 加入权限白名单(见建表脚本), |
||||
* 白名单只跳过 URL 权限校验、不跳过登录校验。</p> |
||||
* |
||||
* @param expireTime 过期时间字符串,留空表示永不过期。 |
||||
* @return 应答结果对象,包含 keyId、明文 secret 与完整凭证。 |
||||
*/ |
||||
@OperationLog(type = SysOperationLogType.ADD, saveResponse = false) |
||||
@PostMapping("/issue") |
||||
public ResponseResult<SysUserSecretIssueVo> 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 立即失效。明文只在本响应中出现一次。 |
||||
* <p>非管理员只能轮换自己的密钥——该接口会返回明文,归属校验不可省略。</p> |
||||
* |
||||
* @param userSecretId 密钥记录主键Id。 |
||||
* @return 应答结果对象,包含新的明文 secret 与完整凭证。 |
||||
*/ |
||||
@OperationLog(type = SysOperationLogType.UPDATE, saveResponse = false) |
||||
@PostMapping("/resetSecret") |
||||
public ResponseResult<SysUserSecretIssueVo> 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<Void> 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<SysUserSecret> secretList) { |
||||
if (secretList == null || secretList.isEmpty() || mcpAccountProvider == null) { |
||||
return; |
||||
} |
||||
Set<Long> userIdSet = secretList.stream().map(SysUserSecret::getUserId).filter(Objects::nonNull).collect(Collectors.toSet()); |
||||
if (userIdSet.isEmpty()) { |
||||
return; |
||||
} |
||||
Map<Long, McpAccountInfo> 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)); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,13 @@
@@ -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<SysUserSecret> { |
||||
} |
||||
@ -0,0 +1,98 @@
@@ -0,0 +1,98 @@
|
||||
package apelet.common.openapi.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; |
||||
|
||||
/** |
||||
* 用户对外密钥实体对象。 |
||||
* <p>一条记录绑定唯一的 (userId, appId):userId 决定"你是谁",appId 决定"你能调哪些开放接口"。 |
||||
* keyId 明文存储用于定位与审计,secret 只存 SHA-256 摘要,对外凭证形态为 "keyId.secret"。</p> |
||||
* |
||||
* @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; |
||||
} |
||||
@ -0,0 +1,76 @@
@@ -0,0 +1,76 @@
|
||||
package apelet.common.openapi.service; |
||||
|
||||
import apelet.common.core.base.service.IBaseService; |
||||
import apelet.common.core.object.ResponseResult; |
||||
import apelet.common.openapi.model.SysUserSecret; |
||||
import apelet.common.openapi.vo.SysUserSecretIssueVo; |
||||
|
||||
import java.util.Date; |
||||
import java.util.List; |
||||
|
||||
/** |
||||
* 用户对外密钥数据服务接口。 |
||||
* <p>对外凭证形态为 "keyId.secret":keyId 明文存储用于定位,secret 只存 SHA-256 摘要。 |
||||
* 校验时由 keyId 定位记录、再由手机号比对确认调用方身份,密钥持有者无法代表他人。</p> |
||||
* |
||||
* @author chenchuchuan |
||||
* @date 2026-10-14 |
||||
*/ |
||||
public interface SysUserSecretService extends IBaseService<SysUserSecret, Long> { |
||||
|
||||
/** |
||||
* 凭证中 keyId 与 secret 的分隔符。 |
||||
*/ |
||||
String CREDENTIAL_SEPARATOR = "."; |
||||
|
||||
/** |
||||
* 获取用户对外密钥列表。 |
||||
* <p>只按密钥表自身的字段过滤(用户Id/应用Id/状态),不关联账号表——账号信息由调用方按需补充。 |
||||
* 排序固定为创建时间倒序,不接受外部排序串,避免拼接SQL。</p> |
||||
* |
||||
* @param filter 过滤对象。 |
||||
* @return 过滤后的对外密钥列表。 |
||||
*/ |
||||
List<SysUserSecret> getSysUserSecretList(SysUserSecret filter); |
||||
|
||||
/** |
||||
* 为指定账号签发一把对外密钥,明文 secret 只在返回值中出现一次。 |
||||
* <p><b>全生命周期只允许签发一次</b>:该账号已有记录(无论启用/停用/过期)时直接拒绝, |
||||
* 更换密钥需由管理员重置。</p> |
||||
* <p>绑定的 OpenAPI 应用取自配置项 {@code apelet.mcp.app-code}:所有人员密钥共用一个承载应用, |
||||
* 由该应用在"开放接口配置"里的授权范围决定密钥能调哪些接口。</p> |
||||
* |
||||
* @param userId 绑定的用户Id。 |
||||
* @param expireTime 过期时间,null表示永不过期。 |
||||
* @return 签发结果,含明文 secret。 |
||||
*/ |
||||
SysUserSecretIssueVo issue(Long userId, 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); |
||||
|
||||
/** |
||||
* 校验"手机号 + 密钥"。 |
||||
* <p>校验顺序:keyId 定位 → 状态与有效期 → secret 摘要比对 → 手机号与绑定账号比对。 |
||||
* "密钥不存在"与"secret 不匹配"返回同一错误信息,不泄露凭证存在性。</p> |
||||
* |
||||
* @param mobile 调用方声明的手机号。 |
||||
* @param credential 完整凭证,格式为 keyId.secret。 |
||||
* @return 校验通过时 data 为对应的密钥记录,否则返回错误。 |
||||
*/ |
||||
ResponseResult<SysUserSecret> authenticate(String mobile, String credential); |
||||
} |
||||
@ -0,0 +1,322 @@
@@ -0,0 +1,322 @@
|
||||
package apelet.common.openapi.service.impl; |
||||
|
||||
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.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; |
||||
import java.security.MessageDigest; |
||||
import java.security.NoSuchAlgorithmException; |
||||
import java.security.SecureRandom; |
||||
import java.util.Date; |
||||
import java.util.List; |
||||
import java.util.Objects; |
||||
|
||||
/** |
||||
* 用户对外密钥数据服务类。 |
||||
* <p>对外凭证形态为 "keyId.secret":keyId 明文存储用于定位与审计,secret 只存 SHA-256 摘要。 |
||||
* 定位永远走 keyId(唯一索引),手机号只做比对、不参与定位——因为账号表的手机号不一定有唯一约束。</p> |
||||
* <p>账号信息(手机号/是否可用)依赖应用的账号体系,通过 {@link McpAccountProvider} SPI 获取; |
||||
* 承载应用信息则直接使用本模块的 {@link OpenApiAppService}。</p> |
||||
* |
||||
* @author chenchuchuan |
||||
* @date 2026-10-14 |
||||
*/ |
||||
@Slf4j |
||||
@Service("sysUserSecretService") |
||||
public class SysUserSecretServiceImpl extends BaseService<SysUserSecret, Long> implements SysUserSecretService { |
||||
|
||||
private static final int APP_STATUS_ENABLED = 1; |
||||
|
||||
/** |
||||
* 密钥属于长期凭证,必须使用密码学安全的随机源。 |
||||
*/ |
||||
private static final SecureRandom SECURE_RANDOM = new SecureRandom(); |
||||
|
||||
@Autowired |
||||
private SysUserSecretMapper sysUserSecretMapper; |
||||
@Autowired |
||||
private OpenApiAppService openApiAppService; |
||||
@Autowired(required = false) |
||||
private McpAccountProvider mcpAccountProvider; |
||||
|
||||
/** |
||||
* 人员密钥的承载应用编码。所有人员密钥共用一个 OpenAPI 应用,由它承载"能调哪些开放接口"的授权关系。 |
||||
*/ |
||||
@Value("${apelet.mcp.app-code:}") |
||||
private String mcpAppCode; |
||||
|
||||
/** |
||||
* 返回主对象的Mapper对象。 |
||||
* |
||||
* @return 主对象的Mapper对象。 |
||||
*/ |
||||
@Override |
||||
protected BaseDaoMapper<SysUserSecret> mapper() { |
||||
return sysUserSecretMapper; |
||||
} |
||||
|
||||
@Override |
||||
public List<SysUserSecret> getSysUserSecretList(SysUserSecret filter) { |
||||
SysUserSecret condition = new SysUserSecret(); |
||||
if (filter != null) { |
||||
condition.setUserId(filter.getUserId()); |
||||
condition.setAppId(filter.getAppId()); |
||||
condition.setStatus(filter.getStatus()); |
||||
} |
||||
QueryWrapper<SysUserSecret> wrapper = new QueryWrapper<>(condition); |
||||
wrapper.orderByDesc("create_time"); |
||||
return this.list(wrapper); |
||||
} |
||||
|
||||
@Override |
||||
public SysUserSecretIssueVo issue(Long userId, Date expireTime) { |
||||
McpAccountInfo account = this.getAccount(userId); |
||||
if (account == null) { |
||||
throw new MyRuntimeException("签发失败,指定的用户不存在!"); |
||||
} |
||||
if (StrUtil.isBlank(account.getMobile())) { |
||||
throw new MyRuntimeException("签发失败,该账号未维护手机号,无法用于外部调用身份比对!"); |
||||
} |
||||
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(IdUtil.getSnowflakeNextId()); |
||||
userSecret.setKeyId(keyId); |
||||
userSecret.setUserId(userId); |
||||
userSecret.setAppId(appId); |
||||
userSecret.setSecretHash(this.sha256Hex(secret)); |
||||
userSecret.setStatus(SysUserSecretStatus.STATUS_ENABLED); |
||||
userSecret.setExpireTime(expireTime); |
||||
this.fillCommonsWithCurrentUser(userSecret, true); |
||||
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(SysUserSecretSpec.SECRET_LENGTH); |
||||
SysUserSecret updating = new SysUserSecret(); |
||||
updating.setUserSecretId(userSecretId); |
||||
updating.setSecretHash(this.sha256Hex(secret)); |
||||
this.fillCommonsWithCurrentUser(updating, false); |
||||
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); |
||||
this.fillCommonsWithCurrentUser(updating, false); |
||||
return sysUserSecretMapper.updateById(updating) == 1; |
||||
} |
||||
|
||||
/** |
||||
* 校验该账号是否已经生成过对外密钥。 |
||||
* <p><b>全生命周期只允许生成一次</b>:不论已有记录是启用、停用还是已过期,都拒绝再次生成。 |
||||
* 需要更换密钥时由管理员执行重置(轮换 secret,keyId 不变),或由管理员停用后另行处理。</p> |
||||
* <p>这样"密钥 ⇄ 人"始终一一对应,吊销、轮换、审计都不会有歧义。</p> |
||||
* |
||||
* @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<SysUserSecret> 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, "凭证已过期,请联系管理员!"); |
||||
} |
||||
McpAccountInfo account = this.getAccount(userSecret.getUserId()); |
||||
if (account == null) { |
||||
return ResponseResult.error(ErrorCodeEnum.INVALID_ACCESS_TOKEN, "凭证无效!"); |
||||
} |
||||
if (Boolean.FALSE.equals(account.getEnabled())) { |
||||
return ResponseResult.error(ErrorCodeEnum.INVALID_USER_STATUS, "账号不可用,请联系管理员!"); |
||||
} |
||||
if (!Objects.equals(mobile, account.getMobile())) { |
||||
return ResponseResult.error(ErrorCodeEnum.INVALID_ACCESS_TOKEN, "凭证与用户不匹配!"); |
||||
} |
||||
userSecret.setLoginName(account.getLoginName()); |
||||
userSecret.setShowName(account.getShowName()); |
||||
this.touchLastUsedTime(userSecret.getUserSecretId()); |
||||
return ResponseResult.success(userSecret); |
||||
} |
||||
|
||||
/** |
||||
* 解析人员密钥的承载应用Id。 |
||||
* <p>所有人员密钥共用一个 OpenAPI 应用(配置项 {@code apelet.mcp.app-code}):该应用本身不对任何调用方分发 |
||||
* appSecret,只用于承载"这批密钥能调哪些开放接口"的授权关系。</p> |
||||
* |
||||
* @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。 |
||||
*/ |
||||
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; |
||||
} |
||||
|
||||
/** |
||||
* 生成指定长度的随机串,字符集见 {@link SysUserSecretSpec#RANDOM_ALPHABET}。 |
||||
* <p>使用 {@link SecureRandom} 而非普通随机数:密钥是长期凭证,可预测的随机源等于没有随机。 |
||||
* 长度等规格是安全参数,集中在 {@link SysUserSecretSpec},不接配置项。</p> |
||||
* |
||||
* @param length 目标长度。 |
||||
* @return 随机串。 |
||||
*/ |
||||
private String randomString(int length) { |
||||
StringBuilder sb = new StringBuilder(length); |
||||
for (int i = 0; i < length; i++) { |
||||
sb.append(SysUserSecretSpec.RANDOM_ALPHABET.charAt(SECURE_RANDOM.nextInt(SysUserSecretSpec.RANDOM_ALPHABET.length()))); |
||||
} |
||||
return sb.toString(); |
||||
} |
||||
|
||||
/** |
||||
* 计算 secret 的 SHA-256 十六进制摘要。 |
||||
* <p>刻意不用 BCrypt:该摘要每个请求都要参与校验,BCrypt 单次约百毫秒不可接受; |
||||
* 32 位随机串的熵足够,无需靠慢哈希抵抗爆破。</p> |
||||
* |
||||
* @param text 待摘要的明文。 |
||||
* @return 64 位十六进制摘要。 |
||||
*/ |
||||
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 摘要算法!"); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
|
||||
package apelet.common.openapi.vo; |
||||
|
||||
import lombok.Data; |
||||
|
||||
import java.util.Date; |
||||
|
||||
/** |
||||
* 用户对外密钥签发结果对象。 |
||||
* <p>明文 secret 只在签发/轮换的响应中出现一次,之后任何接口都查不到。</p> |
||||
* |
||||
* @author chenchuchuan |
||||
* @date 2026-10-14 |
||||
*/ |
||||
@Data |
||||
public class SysUserSecretIssueVo { |
||||
|
||||
/** |
||||
* 主键Id。 |
||||
*/ |
||||
private Long userSecretId; |
||||
|
||||
/** |
||||
* 密钥标识(明文,可用于定位与审计)。 |
||||
*/ |
||||
private String keyId; |
||||
|
||||
/** |
||||
* 密钥口令明文,仅本次响应可见。 |
||||
*/ |
||||
private String secret; |
||||
|
||||
/** |
||||
* 完整凭证(keyId + "." + secret),可直接交给调用方配置。 |
||||
*/ |
||||
private String credential; |
||||
|
||||
/** |
||||
* 过期时间(null表示永不过期)。 |
||||
*/ |
||||
private Date expireTime; |
||||
} |
||||
Loading…
Reference in new issue