Browse Source
- 新增McpAuthInterceptor拦截器,支持JWT和人员密钥两种鉴权方式 - 新增SysUserSecret实体及对应的Mapper、Service、Controller完整CRUD - 实现密钥签发、轮换、启用/停用管理接口 - 密钥口令仅存SHA-256摘要,明文只在签发/轮换响应中出现一次 - 引入会话复用机制,避免密钥分支每次请求重建会话上下文 - 响应中自动脱敏secretHash和手机号字段feature/2026-10/1031-ccc-dev
11 changed files with 1197 additions and 0 deletions
@ -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})的鉴权拦截器。 |
||||||
|
* <p>支持两种调用方身份,按顺序判定:</p> |
||||||
|
* <ol> |
||||||
|
* <li>{@code Authorization: Bearer <jwt>} —— 登录态,兼容现有调用方;</li> |
||||||
|
* <li>{@code X-Mcp-User} + {@code X-Mcp-Secret} —— 人员级密钥,格式为 "keyId.secret",锁定到具体账号。</li> |
||||||
|
* </ol> |
||||||
|
* <p>两者都带时以 Bearer 优先,避免调用方主动降级到密钥分支。</p> |
||||||
|
* <p>校验通过后统一注入 {@link TokenData},下游(在线表单、数据权限、流程)对来源无感知。</p> |
||||||
|
* <p><b>注意</b>:本拦截器注册后,{@code /tenantadmin/mcp} 已从 AuthenticationInterceptor 的路径中排除, |
||||||
|
* 因此 MCP 路径不做"菜单×URL"越权校验;数据权限(DATA_PERM)仍照常生效。</p> |
||||||
|
* |
||||||
|
* @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<SysUserSecret> 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<String> 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<String> sessionIdBucket = redissonClient.getBucket(MCP_SESSION_KEY_PREFIX + user.getUserId()); |
||||||
|
String sessionId = sessionIdBucket.get(); |
||||||
|
if (StrUtil.isNotBlank(sessionId)) { |
||||||
|
RBucket<String> 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 请求头。 |
||||||
|
* <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); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 日志中只保留 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(); |
||||||
|
} |
||||||
|
} |
||||||
@ -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; |
||||||
|
|
||||||
|
/** |
||||||
|
* 用户对外密钥管理接口。 |
||||||
|
* <p>密钥是"一人一把、锁定到具体账号"的长期凭证,签发与停用属于提权操作, |
||||||
|
* 接口本身依赖登录态 + URL 权限控制,仅管理员角色应被授予。</p> |
||||||
|
* <p>响应中永远不返回 secretHash;明文 secret 只在签发/轮换的响应里出现一次, |
||||||
|
* 且对应操作日志显式关闭了响应体记录(saveResponse=false),避免明文密钥落到日志。</p> |
||||||
|
* |
||||||
|
* @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<MyPageData<SysUserSecret>> 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<SysUserSecret> secretList = sysUserSecretService.getSysUserSecretList(sysUserSecretFilter, orderBy); |
||||||
|
long totalCount = 0L; |
||||||
|
if (secretList instanceof Page) { |
||||||
|
totalCount = ((Page<SysUserSecret>) secretList).getTotal(); |
||||||
|
} |
||||||
|
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, "对外密钥不存在,请刷新后重试!"); |
||||||
|
} |
||||||
|
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<SysUserSecretIssueVo> 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<SysUserSecretIssueVo> 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<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); |
||||||
|
} |
||||||
|
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)); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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<SysUserSecret> { |
||||||
|
|
||||||
|
/** |
||||||
|
* 获取用户对外密钥列表(带账号信息)。 |
||||||
|
* |
||||||
|
* @param sysUserSecretFilter 过滤对象。 |
||||||
|
* @param orderBy 排序字符串。 |
||||||
|
* @return 过滤后的对外密钥列表。 |
||||||
|
*/ |
||||||
|
List<SysUserSecret> getSysUserSecretList(@Param("sysUserSecretFilter") SysUserSecret sysUserSecretFilter, @Param("orderBy") String orderBy); |
||||||
|
} |
||||||
@ -0,0 +1,66 @@ |
|||||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> |
||||||
|
<mapper namespace="apelet.tenantadmin.upms.dao.SysUserSecretMapper"> |
||||||
|
<resultMap id="BaseResultMap" type="apelet.tenantadmin.upms.model.SysUserSecret"> |
||||||
|
<id column="user_secret_id" jdbcType="BIGINT" property="userSecretId"/> |
||||||
|
<result column="key_id" jdbcType="VARCHAR" property="keyId"/> |
||||||
|
<result column="user_id" jdbcType="BIGINT" property="userId"/> |
||||||
|
<result column="app_id" jdbcType="BIGINT" property="appId"/> |
||||||
|
<result column="secret_hash" jdbcType="VARCHAR" property="secretHash"/> |
||||||
|
<result column="status" jdbcType="INTEGER" property="status"/> |
||||||
|
<result column="expire_time" jdbcType="TIMESTAMP" property="expireTime"/> |
||||||
|
<result column="last_used_time" jdbcType="TIMESTAMP" property="lastUsedTime"/> |
||||||
|
<result column="create_user_id" jdbcType="BIGINT" property="createUserId"/> |
||||||
|
<result column="create_time" jdbcType="TIMESTAMP" property="createTime"/> |
||||||
|
<result column="update_user_id" jdbcType="BIGINT" property="updateUserId"/> |
||||||
|
<result column="update_time" jdbcType="TIMESTAMP" property="updateTime"/> |
||||||
|
<result column="deleted_flag" jdbcType="INTEGER" property="deletedFlag"/> |
||||||
|
</resultMap> |
||||||
|
|
||||||
|
<resultMap id="BaseResultMapEx" type="apelet.tenantadmin.upms.model.SysUserSecret" extends="BaseResultMap"> |
||||||
|
<result column="login_name" jdbcType="VARCHAR" property="loginName"/> |
||||||
|
<result column="show_name" jdbcType="VARCHAR" property="showName"/> |
||||||
|
<result column="mobile" jdbcType="VARCHAR" property="mobile"/> |
||||||
|
</resultMap> |
||||||
|
|
||||||
|
<sql id="filterRef"> |
||||||
|
<if test="sysUserSecretFilter != null"> |
||||||
|
<if test="sysUserSecretFilter.userId != null"> |
||||||
|
AND xy_sys_user_secret.user_id = #{sysUserSecretFilter.userId} |
||||||
|
</if> |
||||||
|
<if test="sysUserSecretFilter.appId != null"> |
||||||
|
AND xy_sys_user_secret.app_id = #{sysUserSecretFilter.appId} |
||||||
|
</if> |
||||||
|
<if test="sysUserSecretFilter.status != null"> |
||||||
|
AND xy_sys_user_secret.status = #{sysUserSecretFilter.status} |
||||||
|
</if> |
||||||
|
<if test="sysUserSecretFilter.loginName != null and sysUserSecretFilter.loginName != ''"> |
||||||
|
<bind name="safeLoginName" value="'%' + sysUserSecretFilter.loginName + '%'"/> |
||||||
|
AND IFNULL(xy_sys_user.login_name, '') LIKE #{safeLoginName} |
||||||
|
</if> |
||||||
|
<if test="sysUserSecretFilter.mobile != null and sysUserSecretFilter.mobile != ''"> |
||||||
|
<bind name="safeMobile" value="'%' + sysUserSecretFilter.mobile + '%'"/> |
||||||
|
AND IFNULL(xy_sys_user.mobile, '') LIKE #{safeMobile} |
||||||
|
</if> |
||||||
|
</if> |
||||||
|
AND xy_sys_user_secret.deleted_flag = ${@apelet.common.core.constant.GlobalDeletedFlag@NORMAL} |
||||||
|
</sql> |
||||||
|
|
||||||
|
<select id="getSysUserSecretList" resultMap="BaseResultMapEx" parameterType="apelet.tenantadmin.upms.model.SysUserSecret"> |
||||||
|
SELECT |
||||||
|
xy_sys_user_secret.*, |
||||||
|
xy_sys_user.login_name, |
||||||
|
xy_sys_user.show_name, |
||||||
|
xy_sys_user.mobile |
||||||
|
FROM |
||||||
|
xy_sys_user_secret |
||||||
|
LEFT JOIN |
||||||
|
xy_sys_user ON xy_sys_user_secret.user_id = xy_sys_user.user_id |
||||||
|
<where> |
||||||
|
<include refid="filterRef"/> |
||||||
|
</where> |
||||||
|
<if test="orderBy != null and orderBy != ''"> |
||||||
|
ORDER BY ${orderBy} |
||||||
|
</if> |
||||||
|
</select> |
||||||
|
</mapper> |
||||||
@ -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; |
||||||
|
|
||||||
|
/** |
||||||
|
* 用户对外密钥实体对象。 |
||||||
|
* <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,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<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,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; |
||||||
|
|
||||||
|
/** |
||||||
|
* 用户对外密钥数据服务接口。 |
||||||
|
* <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 = "."; |
||||||
|
|
||||||
|
/** |
||||||
|
* 获取用户对外密钥列表(带账号信息)。 |
||||||
|
* |
||||||
|
* @param filter 过滤对象。 |
||||||
|
* @param orderBy 排序字符串。 |
||||||
|
* @return 过滤后的对外密钥列表。 |
||||||
|
*/ |
||||||
|
List<SysUserSecret> 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); |
||||||
|
|
||||||
|
/** |
||||||
|
* 校验"手机号 + 密钥"。 |
||||||
|
* <p>校验顺序:keyId 定位 → 状态与有效期 → secret 摘要比对 → 手机号与绑定账号比对。 |
||||||
|
* "密钥不存在"与"secret 不匹配"返回同一错误信息,不泄露凭证存在性。</p> |
||||||
|
* |
||||||
|
* @param mobile 调用方声明的手机号。 |
||||||
|
* @param credential 完整凭证,格式为 keyId.secret。 |
||||||
|
* @return 校验通过时 data 为对应的密钥记录,否则返回错误。 |
||||||
|
*/ |
||||||
|
ResponseResult<SysUserSecret> authenticate(String mobile, String credential); |
||||||
|
} |
||||||
@ -0,0 +1,53 @@ |
|||||||
|
package apelet.tenantadmin.upms.service; |
||||||
|
|
||||||
|
import apelet.common.core.object.TokenData; |
||||||
|
import apelet.tenantadmin.upms.model.SysUser; |
||||||
|
|
||||||
|
/** |
||||||
|
* 用户会话上下文构建服务接口。 |
||||||
|
* <p>把"登录成功后需要落到 Redis 的那几份缓存"收敛到一处,供登录链路与 MCP 密钥鉴权链路共用, |
||||||
|
* 避免各自实现导致口径不一致。</p> |
||||||
|
* |
||||||
|
* @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); |
||||||
|
|
||||||
|
/** |
||||||
|
* 写入会话上下文缓存。 |
||||||
|
* <p>包含:SESSIONID(会话)、DATA_PERM(数据权限)、WHITELISTPERM(白名单URL)。</p> |
||||||
|
* <p><b>不写 PERM(URL权限集合)</b>:本方法当前服务于 MCP 路径,该路径已从登录态拦截器排除、 |
||||||
|
* 不做"菜单×URL"越权校验;登录链路若复用本方法需另行写入 PERM。</p> |
||||||
|
* |
||||||
|
* @param tokenData 已组装好的 TokenData。 |
||||||
|
* @param user 账号对象。 |
||||||
|
*/ |
||||||
|
void putSessionContext(TokenData tokenData, SysUser user); |
||||||
|
|
||||||
|
/** |
||||||
|
* 清理指定会话的全部上下文缓存。 |
||||||
|
* |
||||||
|
* @param sessionId 会话Id。 |
||||||
|
*/ |
||||||
|
void removeSessionContext(String sessionId); |
||||||
|
} |
||||||
@ -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; |
||||||
|
|
||||||
|
/** |
||||||
|
* 用户对外密钥数据服务类。 |
||||||
|
* <p>对外凭证形态为 "keyId.secret":keyId 明文存储用于定位与审计,secret 只存 SHA-256 摘要。 |
||||||
|
* 定位永远走 keyId(唯一索引),手机号只做比对,不参与定位——因为 xy_sys_user.mobile 无唯一索引。</p> |
||||||
|
* |
||||||
|
* @author chenchuchuan |
||||||
|
* @date 2026-10-14 |
||||||
|
*/ |
||||||
|
@Slf4j |
||||||
|
@MyDataSource(DataSourceType.TENANT_ADMIN) |
||||||
|
@Service("sysUserSecretService") |
||||||
|
public class SysUserSecretServiceImpl extends BaseService<SysUserSecret, Long> 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<SysUserSecret> mapper() { |
||||||
|
return sysUserSecretMapper; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public List<SysUserSecret> 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<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, "凭证已过期,请联系管理员!"); |
||||||
|
} |
||||||
|
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 摘要算法!"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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; |
||||||
|
|
||||||
|
/** |
||||||
|
* 用户会话上下文构建服务实现类。 |
||||||
|
* <p>TokenData 的字段来源、会话Id 生成规则、会话缓存写入方式均对齐登录链路的既有做法, |
||||||
|
* 保证"用密钥进来的身份"和"用密码登录的身份"在下游完全等价。</p> |
||||||
|
* |
||||||
|
* @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<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; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public 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); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@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); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,47 @@ |
|||||||
|
package apelet.tenantadmin.upms.vo; |
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema; |
||||||
|
import lombok.Data; |
||||||
|
|
||||||
|
import java.util.Date; |
||||||
|
|
||||||
|
/** |
||||||
|
* 用户对外密钥签发结果对象。 |
||||||
|
* <p>明文 secret 只在签发/轮换的响应中出现一次,之后任何接口都查不到。</p> |
||||||
|
* |
||||||
|
* @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; |
||||||
|
} |
||||||
Loading…
Reference in new issue