清理多租户
This commit is contained in:
@@ -8,26 +8,23 @@ import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Schema(description = "RPC 服务 - OAuth2 访问令牌的校验 Response DTO")
|
||||
@Schema(description = "RPC service - OAuth2 access token check response")
|
||||
@Data
|
||||
public class OAuth2AccessTokenCheckRespDTO implements Serializable {
|
||||
|
||||
@Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "10")
|
||||
@Schema(description = "User id", requiredMode = Schema.RequiredMode.REQUIRED, example = "10")
|
||||
private Long userId;
|
||||
|
||||
@Schema(description = "用户类型,参见 UserTypeEnum 枚举", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
@Schema(description = "User type", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
private Integer userType;
|
||||
|
||||
@Schema(description = "用户信息", example = "{\"nickname\": \"灿能\"}")
|
||||
@Schema(description = "User info", example = "{\"nickname\": \"rdms\"}")
|
||||
private Map<String, String> userInfo;
|
||||
|
||||
@Schema(description = "租户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
|
||||
private Long tenantId;
|
||||
|
||||
@Schema(description = "授权范围的数组", example = "user_info")
|
||||
@Schema(description = "Scopes", example = "user_info")
|
||||
private List<String> scopes;
|
||||
|
||||
@Schema(description = "过期时间", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Schema(description = "Expire time", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime expiresTime;
|
||||
|
||||
}
|
||||
|
||||
@@ -3,17 +3,11 @@ package com.njcn.rdms.framework.common.enums;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 文档地址
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum DocumentEnum {
|
||||
|
||||
REDIS_INSTALL("https://gitee.com/zhijiantianya/ruoyi-vue-pro/issues/I4VCSJ", "Redis 安装文档"),
|
||||
TENANT("https://doc.iocoder.cn", "SaaS 多租户文档");
|
||||
REDIS_INSTALL("https://gitee.com/zhijiantianya/ruoyi-vue-pro/issues/I4VCSJ", "Redis 安装文档");
|
||||
|
||||
private final String url;
|
||||
private final String memo;
|
||||
|
||||
@@ -71,7 +71,6 @@
|
||||
- Token 透传
|
||||
- 用户信息透传
|
||||
- TraceId / 请求链路标识透传
|
||||
- 租户编号透传
|
||||
|
||||
常见做法是基于 `RequestInterceptor`,在所有 Feign 请求发出前统一补充请求头。
|
||||
|
||||
|
||||
@@ -106,10 +106,8 @@
|
||||
- `id`
|
||||
- `userType`
|
||||
- `info`
|
||||
- `tenantId`
|
||||
- `scopes`
|
||||
- `expiresTime`
|
||||
- `visitTenantId`
|
||||
|
||||
认证成功后,`LoginUser` 会进入 Spring Security 上下文,后续可以通过 `SecurityFrameworkUtils` 获取,例如:
|
||||
|
||||
@@ -646,4 +644,3 @@ public Long createUser(UserSaveReqVO reqVO) {
|
||||
- 原始 token 透传
|
||||
- 更细的权限表达式
|
||||
- 更完整的审计日志字段
|
||||
- 更严格的跨租户访问控制
|
||||
|
||||
@@ -2,65 +2,27 @@ package com.njcn.rdms.framework.security.core;
|
||||
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.njcn.rdms.framework.common.enums.UserTypeEnum;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 登录用户信息
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
@Data
|
||||
public class LoginUser {
|
||||
|
||||
public static final String INFO_KEY_NICKNAME = "nickname";
|
||||
public static final String INFO_KEY_DEPT_ID = "deptId";
|
||||
|
||||
/**
|
||||
* 用户编号
|
||||
*/
|
||||
private Long id;
|
||||
/**
|
||||
* 用户类型
|
||||
*
|
||||
* 关联 {@link UserTypeEnum}
|
||||
*/
|
||||
private Integer userType;
|
||||
/**
|
||||
* 额外的用户信息
|
||||
*/
|
||||
private Map<String, String> info;
|
||||
/**
|
||||
* 租户编号
|
||||
*/
|
||||
private Long tenantId;
|
||||
/**
|
||||
* 授权范围
|
||||
*/
|
||||
private List<String> scopes;
|
||||
/**
|
||||
* 过期时间
|
||||
*/
|
||||
private LocalDateTime expiresTime;
|
||||
|
||||
// ========== 上下文 ==========
|
||||
/**
|
||||
* 上下文字段,不进行持久化
|
||||
*
|
||||
* 1. 用于基于 LoginUser 维度的临时缓存
|
||||
*/
|
||||
@JsonIgnore
|
||||
private Map<String, Object> context;
|
||||
/**
|
||||
* 访问的租户编号
|
||||
*/
|
||||
private Long visitTenantId;
|
||||
|
||||
public void setContext(String key, Object value) {
|
||||
if (context == null) {
|
||||
|
||||
@@ -26,42 +26,29 @@ import java.io.IOException;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* Token 过滤器,验证 token 的有效性
|
||||
* 验证通过后,获得 {@link LoginUser} 信息,并加入到 Spring Security 上下文
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class TokenAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final SecurityProperties securityProperties;
|
||||
|
||||
private final GlobalExceptionHandler globalExceptionHandler;
|
||||
|
||||
private final OAuth2TokenCommonApi oauth2TokenApi;
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("NullableProblems")
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
||||
throws ServletException, IOException {
|
||||
// 情况一,基于 header[login-user] 获得用户,例如说来自 Gateway 或者其它服务透传
|
||||
LoginUser loginUser = buildLoginUserByHeader(request);
|
||||
|
||||
// 情况二,基于 Token 获得用户
|
||||
// 注意,这里主要满足直接使用 Nginx 直接转发到 Spring Cloud 服务的场景。
|
||||
if (loginUser == null) {
|
||||
String token = SecurityFrameworkUtils.obtainAuthorization(request,
|
||||
securityProperties.getTokenHeader(), securityProperties.getTokenParameter());
|
||||
if (StrUtil.isNotEmpty(token)) {
|
||||
Integer userType = WebFrameworkUtils.getLoginUserType(request);
|
||||
try {
|
||||
// 1.1 基于 token 构建登录用户
|
||||
loginUser = buildLoginUserByToken(token, userType);
|
||||
// 1.2 模拟 Login 功能,方便日常开发调试
|
||||
if (loginUser == null) {
|
||||
loginUser = mockLoginUser(request, token, userType);
|
||||
loginUser = mockLoginUser(token, userType);
|
||||
}
|
||||
} catch (Throwable ex) {
|
||||
CommonResult<?> result = globalExceptionHandler.allExceptionHandler(request, ex);
|
||||
@@ -71,61 +58,40 @@ public class TokenAuthenticationFilter extends OncePerRequestFilter {
|
||||
}
|
||||
}
|
||||
|
||||
// 设置当前用户
|
||||
if (loginUser != null) {
|
||||
SecurityFrameworkUtils.setLoginUser(loginUser, request);
|
||||
}
|
||||
// 继续过滤链
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private LoginUser buildLoginUserByToken(String token, Integer userType) {
|
||||
try {
|
||||
// 校验访问令牌
|
||||
OAuth2AccessTokenCheckRespDTO accessToken = oauth2TokenApi.checkAccessToken(token).getCheckedData();
|
||||
if (accessToken == null) {
|
||||
return null;
|
||||
}
|
||||
// 用户类型不匹配,无权限
|
||||
// 注意:只有 /admin-api/* 和 /app-api/* 有 userType,才需要比对用户类型
|
||||
// 类似 WebSocket 的 /ws/* 连接地址,是不需要比对用户类型的
|
||||
if (userType != null
|
||||
&& ObjectUtil.notEqual(accessToken.getUserType(), userType)) {
|
||||
if (userType != null && ObjectUtil.notEqual(accessToken.getUserType(), userType)) {
|
||||
throw new AccessDeniedException("错误的用户类型");
|
||||
}
|
||||
// 构建登录用户
|
||||
return new LoginUser().setId(accessToken.getUserId()).setUserType(accessToken.getUserType())
|
||||
.setInfo(accessToken.getUserInfo()) // 额外的用户信息
|
||||
.setTenantId(accessToken.getTenantId()).setScopes(accessToken.getScopes())
|
||||
return new LoginUser().setId(accessToken.getUserId())
|
||||
.setUserType(accessToken.getUserType())
|
||||
.setInfo(accessToken.getUserInfo())
|
||||
.setScopes(accessToken.getScopes())
|
||||
.setExpiresTime(accessToken.getExpiresTime());
|
||||
} catch (ServiceException serviceException) {
|
||||
// 校验 Token 不通过时,考虑到一些接口是无需登录的,所以直接返回 null 即可
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟登录用户,方便日常开发调试
|
||||
*
|
||||
* 注意,在线上环境下,一定要关闭该功能!!!
|
||||
*
|
||||
* @param request 请求
|
||||
* @param token 模拟的 token,格式为 {@link SecurityProperties#getMockSecret()} + 用户编号
|
||||
* @param userType 用户类型
|
||||
* @return 模拟的 LoginUser
|
||||
*/
|
||||
private LoginUser mockLoginUser(HttpServletRequest request, String token, Integer userType) {
|
||||
private LoginUser mockLoginUser(String token, Integer userType) {
|
||||
if (!securityProperties.getMockEnable()) {
|
||||
return null;
|
||||
}
|
||||
// 必须以 mockSecret 开头
|
||||
if (!token.startsWith(securityProperties.getMockSecret())) {
|
||||
return null;
|
||||
}
|
||||
// 构建模拟用户
|
||||
Long userId = Long.valueOf(token.substring(securityProperties.getMockSecret().length()));
|
||||
return new LoginUser().setId(userId).setUserType(userType)
|
||||
.setTenantId(WebFrameworkUtils.getTenantId(request));
|
||||
return new LoginUser().setId(userId).setUserType(userType);
|
||||
}
|
||||
|
||||
private LoginUser buildLoginUserByHeader(HttpServletRequest request) {
|
||||
@@ -134,20 +100,15 @@ public class TokenAuthenticationFilter extends OncePerRequestFilter {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
loginUserStr = URLDecoder.decode(loginUserStr, StandardCharsets.UTF_8); // 解码,解决中文乱码问题
|
||||
loginUserStr = URLDecoder.decode(loginUserStr, StandardCharsets.UTF_8);
|
||||
LoginUser loginUser = JsonUtils.parseObject(loginUserStr, LoginUser.class);
|
||||
// 用户类型不匹配,无权限
|
||||
// 注意:只有 /admin-api/* 和 /app-api/* 有 userType,才需要比对用户类型
|
||||
// 类似 WebSocket 的 /ws/* 连接地址,是不需要比对用户类型的
|
||||
Integer userType = WebFrameworkUtils.getLoginUserType(request);
|
||||
if (userType != null
|
||||
&& loginUser != null
|
||||
&& ObjectUtil.notEqual(loginUser.getUserType(), userType)) {
|
||||
if (userType != null && loginUser != null && ObjectUtil.notEqual(loginUser.getUserType(), userType)) {
|
||||
throw new AccessDeniedException("错误的用户类型");
|
||||
}
|
||||
return loginUser;
|
||||
} catch (Exception ex) {
|
||||
log.error("[buildLoginUserByHeader][解析 LoginUser({}) 发生异常]", loginUserStr, ex); ;
|
||||
log.error("[buildLoginUserByHeader][parse LoginUser({}) error]", loginUserStr, ex);
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,44 +16,28 @@ import java.util.List;
|
||||
|
||||
import static com.njcn.rdms.framework.common.util.cache.CacheUtils.buildCache;
|
||||
import static com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
|
||||
import static com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils.skipPermissionCheck;
|
||||
|
||||
/**
|
||||
* 默认的 {@link SecurityFrameworkService} 实现类
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
@AllArgsConstructor
|
||||
public class SecurityFrameworkServiceImpl implements SecurityFrameworkService {
|
||||
|
||||
private final PermissionCommonApi permissionApi;
|
||||
|
||||
/**
|
||||
* 针对 {@link #hasAnyRoles(String...)} 的缓存
|
||||
*/
|
||||
private final LoadingCache<KeyValue<Long, List<String>>, Boolean> hasAnyRolesCache = buildCache(
|
||||
Duration.ofMinutes(1L), // 过期时间 1 分钟
|
||||
Duration.ofMinutes(1L),
|
||||
new CacheLoader<KeyValue<Long, List<String>>, Boolean>() {
|
||||
|
||||
@Override
|
||||
public Boolean load(KeyValue<Long, List<String>> key) {
|
||||
return permissionApi.hasAnyRoles(key.getKey(), key.getValue().toArray(new String[0])).getCheckedData();
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
/**
|
||||
* 针对 {@link #hasAnyPermissions(String...)} 的缓存
|
||||
*/
|
||||
private final LoadingCache<KeyValue<Long, List<String>>, Boolean> hasAnyPermissionsCache = buildCache(
|
||||
Duration.ofMinutes(1L), // 过期时间 1 分钟
|
||||
Duration.ofMinutes(1L),
|
||||
new CacheLoader<KeyValue<Long, List<String>>, Boolean>() {
|
||||
|
||||
@Override
|
||||
public Boolean load(KeyValue<Long, List<String>> key) {
|
||||
return permissionApi.hasAnyPermissions(key.getKey(), key.getValue().toArray(new String[0])).getCheckedData();
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
@Override
|
||||
@@ -64,12 +48,6 @@ public class SecurityFrameworkServiceImpl implements SecurityFrameworkService {
|
||||
@Override
|
||||
@SneakyThrows
|
||||
public boolean hasAnyPermissions(String... permissions) {
|
||||
// 特殊:跨租户访问
|
||||
if (skipPermissionCheck()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 权限校验
|
||||
Long userId = getLoginUserId();
|
||||
if (userId == null) {
|
||||
return false;
|
||||
@@ -85,12 +63,6 @@ public class SecurityFrameworkServiceImpl implements SecurityFrameworkService {
|
||||
@Override
|
||||
@SneakyThrows
|
||||
public boolean hasAnyRoles(String... roles) {
|
||||
// 特殊:跨租户访问
|
||||
if (skipPermissionCheck()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 权限校验
|
||||
Long userId = getLoginUserId();
|
||||
if (userId == null) {
|
||||
return false;
|
||||
@@ -105,12 +77,6 @@ public class SecurityFrameworkServiceImpl implements SecurityFrameworkService {
|
||||
|
||||
@Override
|
||||
public boolean hasAnyScopes(String... scope) {
|
||||
// 特殊:跨租户访问
|
||||
if (skipPermissionCheck()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 权限校验
|
||||
LoginUser user = SecurityFrameworkUtils.getLoginUser();
|
||||
if (user == null) {
|
||||
return false;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.njcn.rdms.framework.security.core.util;
|
||||
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.ObjUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.njcn.rdms.framework.security.core.LoginUser;
|
||||
import com.njcn.rdms.framework.web.core.util.WebFrameworkUtils;
|
||||
@@ -16,33 +15,14 @@ import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.Collections;
|
||||
|
||||
/**
|
||||
* 安全服务工具类
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
public class SecurityFrameworkUtils {
|
||||
|
||||
/**
|
||||
* HEADER 认证头 value 的前缀
|
||||
*/
|
||||
public static final String AUTHORIZATION_BEARER = "Bearer";
|
||||
|
||||
public static final String LOGIN_USER_HEADER = "login-user";
|
||||
|
||||
private SecurityFrameworkUtils() {}
|
||||
|
||||
/**
|
||||
* 从请求中,获得认证 Token
|
||||
*
|
||||
* @param request 请求
|
||||
* @param headerName 认证 Token 对应的 Header 名字
|
||||
* @param parameterName 认证 Token 对应的 Parameter 名字
|
||||
* @return 认证 Token
|
||||
*/
|
||||
public static String obtainAuthorization(HttpServletRequest request,
|
||||
String headerName, String parameterName) {
|
||||
// 1. 获得 Token。优先级:Header > Parameter
|
||||
public static String obtainAuthorization(HttpServletRequest request, String headerName, String parameterName) {
|
||||
String token = request.getHeader(headerName);
|
||||
if (StrUtil.isEmpty(token)) {
|
||||
token = request.getParameter(parameterName);
|
||||
@@ -50,16 +30,10 @@ public class SecurityFrameworkUtils {
|
||||
if (!StringUtils.hasText(token)) {
|
||||
return null;
|
||||
}
|
||||
// 2. 去除 Token 中带的 Bearer
|
||||
int index = token.indexOf(AUTHORIZATION_BEARER + " ");
|
||||
return index >= 0 ? token.substring(index + 7).trim() : token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获得当前认证信息
|
||||
*
|
||||
* @return 认证信息
|
||||
*/
|
||||
public static Authentication getAuthentication() {
|
||||
SecurityContext context = SecurityContextHolder.getContext();
|
||||
if (context == null) {
|
||||
@@ -68,11 +42,6 @@ public class SecurityFrameworkUtils {
|
||||
return context.getAuthentication();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户
|
||||
*
|
||||
* @return 当前用户
|
||||
*/
|
||||
@Nullable
|
||||
public static LoginUser getLoginUser() {
|
||||
Authentication authentication = getAuthentication();
|
||||
@@ -82,52 +51,28 @@ public class SecurityFrameworkUtils {
|
||||
return authentication.getPrincipal() instanceof LoginUser ? (LoginUser) authentication.getPrincipal() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获得当前用户的编号,从上下文中
|
||||
*
|
||||
* @return 用户编号
|
||||
*/
|
||||
@Nullable
|
||||
public static Long getLoginUserId() {
|
||||
LoginUser loginUser = getLoginUser();
|
||||
return loginUser != null ? loginUser.getId() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获得当前用户的昵称,从上下文中
|
||||
*
|
||||
* @return 昵称
|
||||
*/
|
||||
@Nullable
|
||||
public static String getLoginUserNickname() {
|
||||
LoginUser loginUser = getLoginUser();
|
||||
return loginUser != null ? MapUtil.getStr(loginUser.getInfo(), LoginUser.INFO_KEY_NICKNAME) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获得当前用户的部门编号,从上下文中
|
||||
*
|
||||
* @return 部门编号
|
||||
*/
|
||||
@Nullable
|
||||
public static Long getLoginUserDeptId() {
|
||||
LoginUser loginUser = getLoginUser();
|
||||
return loginUser != null ? MapUtil.getLong(loginUser.getInfo(), LoginUser.INFO_KEY_DEPT_ID) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前用户
|
||||
*
|
||||
* @param loginUser 登录用户
|
||||
* @param request 请求
|
||||
*/
|
||||
public static void setLoginUser(LoginUser loginUser, HttpServletRequest request) {
|
||||
// 创建 Authentication,并设置到上下文
|
||||
Authentication authentication = buildAuthentication(loginUser, request);
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
|
||||
// 额外设置到 request 中,用于 ApiAccessLogFilter 可以获取到用户编号;
|
||||
// 原因是,Spring Security 的 Filter 在 ApiAccessLogFilter 后面,在它记录访问日志时,线上上下文已经没有用户编号等信息
|
||||
if (request != null) {
|
||||
WebFrameworkUtils.setLoginUserId(request, loginUser.getId());
|
||||
WebFrameworkUtils.setLoginUserType(request, loginUser.getUserType());
|
||||
@@ -135,28 +80,10 @@ public class SecurityFrameworkUtils {
|
||||
}
|
||||
|
||||
private static Authentication buildAuthentication(LoginUser loginUser, HttpServletRequest request) {
|
||||
// 创建 UsernamePasswordAuthenticationToken 对象
|
||||
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
|
||||
loginUser, null, Collections.emptyList());
|
||||
UsernamePasswordAuthenticationToken authenticationToken =
|
||||
new UsernamePasswordAuthenticationToken(loginUser, null, Collections.emptyList());
|
||||
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
return authenticationToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否条件跳过权限校验,包括数据权限、功能权限
|
||||
*
|
||||
* @return 是否跳过
|
||||
*/
|
||||
public static boolean skipPermissionCheck() {
|
||||
LoginUser loginUser = getLoginUser();
|
||||
if (loginUser == null) {
|
||||
return false;
|
||||
}
|
||||
if (loginUser.getVisitTenantId() == null) {
|
||||
return false;
|
||||
}
|
||||
// 重点:跨租户访问时,无法进行权限校验
|
||||
return ObjUtil.notEqual(loginUser.getVisitTenantId(), loginUser.getTenantId());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -224,7 +224,7 @@ private RestTemplate loadBalancedRestTemplate;
|
||||
- `/admin-api/**`
|
||||
- `/app-api/**`
|
||||
|
||||
同时会在文档中预置常见请求头,例如认证头、租户头,便于在 Swagger 页面直接调试接口。
|
||||
同时会在文档中预置常见请求头,例如认证头,便于在 Swagger 页面直接调试接口。
|
||||
|
||||
Swagger 配置示例:
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Contact;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
import io.swagger.v3.oas.models.info.License;
|
||||
import io.swagger.v3.oas.models.media.IntegerSchema;
|
||||
import io.swagger.v3.oas.models.media.StringSchema;
|
||||
import io.swagger.v3.oas.models.parameters.Parameter;
|
||||
import io.swagger.v3.oas.models.security.SecurityRequirement;
|
||||
@@ -34,42 +33,24 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static com.njcn.rdms.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID;
|
||||
|
||||
/**
|
||||
* Swagger 自动配置类,基于 OpenAPI + Springdoc 实现。
|
||||
*
|
||||
* 友情提示:
|
||||
* 1. Springdoc 文档地址:<a href="https://github.com/springdoc/springdoc-openapi">仓库</a>
|
||||
* 2. Swagger 规范,于 2015 更名为 OpenAPI 规范,本质是一个东西
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
@AutoConfiguration(before = Knife4jAutoConfiguration.class) // before 原因,保证覆写的 Knife4jOpenApiCustomizer 先生效!相关 https://github.com/YunaiV/ruoyi-vue-pro/issues/954 讨论
|
||||
@AutoConfiguration(before = Knife4jAutoConfiguration.class)
|
||||
@ConditionalOnClass({OpenAPI.class})
|
||||
@EnableConfigurationProperties(SwaggerProperties.class)
|
||||
@ConditionalOnProperty(prefix = "springdoc.api-docs", name = "enabled", havingValue = "true", matchIfMissing = true) // 设置为 false 时,禁用
|
||||
@ConditionalOnProperty(prefix = "springdoc.api-docs", name = "enabled", havingValue = "true", matchIfMissing = true)
|
||||
@Import(Knife4jOpenApiCustomizer.class)
|
||||
public class RdmsSwaggerAutoConfiguration {
|
||||
|
||||
// ========== 全局 OpenAPI 配置 ==========
|
||||
|
||||
@Bean
|
||||
public OpenAPI createApi(SwaggerProperties properties) {
|
||||
Map<String, SecurityScheme> securitySchemas = buildSecuritySchemes();
|
||||
OpenAPI openAPI = new OpenAPI()
|
||||
// 接口信息
|
||||
.info(buildInfo(properties))
|
||||
// 接口安全配置
|
||||
.components(new Components().securitySchemes(securitySchemas))
|
||||
.addSecurityItem(new SecurityRequirement().addList(HttpHeaders.AUTHORIZATION));
|
||||
securitySchemas.keySet().forEach(key -> openAPI.addSecurityItem(new SecurityRequirement().addList(key)));
|
||||
return openAPI;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 摘要信息
|
||||
*/
|
||||
private Info buildInfo(SwaggerProperties properties) {
|
||||
return new Info()
|
||||
.title(properties.getTitle())
|
||||
@@ -79,24 +60,18 @@ public class RdmsSwaggerAutoConfiguration {
|
||||
.license(new License().name(properties.getLicense()).url(properties.getLicenseUrl()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全模式,这里配置通过请求头 Authorization 传递 token 参数
|
||||
*/
|
||||
private Map<String, SecurityScheme> buildSecuritySchemes() {
|
||||
Map<String, SecurityScheme> securitySchemes = new HashMap<>();
|
||||
SecurityScheme securityScheme = new SecurityScheme()
|
||||
.type(SecurityScheme.Type.APIKEY) // 类型
|
||||
.name(HttpHeaders.AUTHORIZATION) // 请求头的 name
|
||||
.in(SecurityScheme.In.HEADER); // token 所在位置
|
||||
.type(SecurityScheme.Type.APIKEY)
|
||||
.name(HttpHeaders.AUTHORIZATION)
|
||||
.in(SecurityScheme.In.HEADER);
|
||||
securitySchemes.put(HttpHeaders.AUTHORIZATION, securityScheme);
|
||||
return securitySchemes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义 OpenAPI 处理器
|
||||
*/
|
||||
@Bean
|
||||
@Primary // 目的:以我们创建的 OpenAPIService Bean 为主,避免一键改包后,启动报错!
|
||||
@Primary
|
||||
public OpenAPIService openApiBuilder(Optional<OpenAPI> openAPI,
|
||||
SecurityService securityParser,
|
||||
SpringDocConfigProperties springDocConfigProperties,
|
||||
@@ -108,11 +83,6 @@ public class RdmsSwaggerAutoConfiguration {
|
||||
propertyResolverUtils, openApiBuilderCustomizers, serverBaseUrlCustomizers, javadocProvider);
|
||||
}
|
||||
|
||||
// ========== 分组 OpenAPI 配置 ==========
|
||||
|
||||
/**
|
||||
* 所有模块的 API 分组
|
||||
*/
|
||||
@Bean
|
||||
public GroupedOpenApi allGroupedOpenApi() {
|
||||
return buildGroupedOpenApi("all", "");
|
||||
@@ -127,60 +97,29 @@ public class RdmsSwaggerAutoConfiguration {
|
||||
.group(group)
|
||||
.pathsToMatch("/admin-api/" + path + "/**", "/app-api/" + path + "/**")
|
||||
.addOperationCustomizer((operation, handlerMethod) -> operation
|
||||
.addParametersItem(buildTenantHeaderParameter())
|
||||
.addParametersItem(buildSecurityHeaderParameter()))
|
||||
.addOperationCustomizer(buildOperationIdCustomizer())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 Tenant 租户编号请求头参数
|
||||
*
|
||||
* @return 多租户参数
|
||||
*/
|
||||
private static Parameter buildTenantHeaderParameter() {
|
||||
return new Parameter()
|
||||
.name(HEADER_TENANT_ID) // header 名
|
||||
.description("租户编号") // 描述
|
||||
.in(String.valueOf(SecurityScheme.In.HEADER)) // 请求 header
|
||||
.schema(new IntegerSchema()._default(1L).name(HEADER_TENANT_ID).description("租户编号")); // 默认:使用租户编号为 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 Authorization 认证请求头参数
|
||||
*
|
||||
* 解决 Knife4j <a href="https://gitee.com/xiaoym/knife4j/issues/I69QBU">Authorize 未生效,请求header里未包含参数</a>
|
||||
*
|
||||
* @return 认证参数
|
||||
*/
|
||||
private static Parameter buildSecurityHeaderParameter() {
|
||||
return new Parameter()
|
||||
.name(HttpHeaders.AUTHORIZATION) // header 名
|
||||
.description("认证 Token") // 描述
|
||||
.in(String.valueOf(SecurityScheme.In.HEADER)) // 请求 header
|
||||
.schema(new StringSchema()._default("Bearer test1").name(HEADER_TENANT_ID).description("认证 Token")); // 默认:使用用户编号为 1
|
||||
.name(HttpHeaders.AUTHORIZATION)
|
||||
.description("认证 Token")
|
||||
.in(String.valueOf(SecurityScheme.In.HEADER))
|
||||
.schema(new StringSchema()._default("Bearer test1")
|
||||
.name(HttpHeaders.AUTHORIZATION)
|
||||
.description("认证 Token"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心:自定义OperationId生成规则,组合「类名前缀 + 方法名」
|
||||
*
|
||||
* @see <a href="https://github.com/YunaiV/ruoyi-vue-pro/issues/957">app-api 前缀不生效,都是使用 admin-api</a>
|
||||
*/
|
||||
private static OperationCustomizer buildOperationIdCustomizer() {
|
||||
return (operation, handlerMethod) -> {
|
||||
// 1. 获取控制器类名(如 UserController)
|
||||
String className = handlerMethod.getBeanType().getSimpleName();
|
||||
// 2. 提取类名前缀(去除 Controller 后缀,如 UserController -> User)
|
||||
String classPrefix = className.replaceAll("Controller$", "");
|
||||
// 3. 获取方法名(如 list)
|
||||
String methodName = handlerMethod.getMethod().getName();
|
||||
// 4. 组合生成 operationId(如 User_list)
|
||||
String operationId = classPrefix + "_" + methodName;
|
||||
// 5. 设置自定义 operationId
|
||||
operation.setOperationId(operationId);
|
||||
operation.setOperationId(classPrefix + "_" + methodName);
|
||||
return operation;
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -12,26 +12,12 @@ import org.springframework.web.context.request.RequestAttributes;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
/**
|
||||
* 专属于 web 包的工具类
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
public class WebFrameworkUtils {
|
||||
|
||||
private static final String REQUEST_ATTRIBUTE_LOGIN_USER_ID = "login_user_id";
|
||||
private static final String REQUEST_ATTRIBUTE_LOGIN_USER_TYPE = "login_user_type";
|
||||
|
||||
private static final String REQUEST_ATTRIBUTE_COMMON_RESULT = "common_result";
|
||||
|
||||
public static final String HEADER_TENANT_ID = "tenant-id";
|
||||
public static final String HEADER_VISIT_TENANT_ID = "visit-tenant-id";
|
||||
|
||||
/**
|
||||
* 终端的 Header
|
||||
*
|
||||
* @see TerminalEnum
|
||||
*/
|
||||
public static final String HEADER_TERMINAL = "terminal";
|
||||
|
||||
private static WebProperties properties;
|
||||
@@ -40,51 +26,14 @@ public class WebFrameworkUtils {
|
||||
WebFrameworkUtils.properties = webProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获得租户编号,从 header 中
|
||||
* 考虑到其它 framework 组件也会使用到租户编号,所以不得不放在 WebFrameworkUtils 统一提供
|
||||
*
|
||||
* @param request 请求
|
||||
* @return 租户编号
|
||||
*/
|
||||
public static Long getTenantId(HttpServletRequest request) {
|
||||
String tenantId = request.getHeader(HEADER_TENANT_ID);
|
||||
return NumberUtil.isNumber(tenantId) ? Long.valueOf(tenantId) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获得访问的租户编号,从 header 中
|
||||
* 考虑到其它 framework 组件也会使用到租户编号,所以不得不放在 WebFrameworkUtils 统一提供
|
||||
*
|
||||
* @param request 请求
|
||||
* @return 租户编号
|
||||
*/
|
||||
public static Long getVisitTenantId(HttpServletRequest request) {
|
||||
String tenantId = request.getHeader(HEADER_VISIT_TENANT_ID);
|
||||
return NumberUtil.isNumber(tenantId)? Long.valueOf(tenantId) : null;
|
||||
}
|
||||
|
||||
public static void setLoginUserId(ServletRequest request, Long userId) {
|
||||
request.setAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_ID, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置用户类型
|
||||
*
|
||||
* @param request 请求
|
||||
* @param userType 用户类型
|
||||
*/
|
||||
public static void setLoginUserType(ServletRequest request, Integer userType) {
|
||||
request.setAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_TYPE, userType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获得当前用户的编号,从请求中
|
||||
* 注意:该方法仅限于 framework 框架使用!!!
|
||||
*
|
||||
* @param request 请求
|
||||
* @return 用户编号
|
||||
*/
|
||||
public static Long getLoginUserId(HttpServletRequest request) {
|
||||
if (request == null) {
|
||||
return null;
|
||||
@@ -92,23 +41,14 @@ public class WebFrameworkUtils {
|
||||
return (Long) request.getAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获得当前用户的类型
|
||||
* 注意:该方法仅限于 web 相关的 framework 组件使用!!!
|
||||
*
|
||||
* @param request 请求
|
||||
* @return 用户编号
|
||||
*/
|
||||
public static Integer getLoginUserType(HttpServletRequest request) {
|
||||
if (request == null) {
|
||||
return null;
|
||||
}
|
||||
// 1. 优先,从 Attribute 中获取
|
||||
Integer userType = (Integer) request.getAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_TYPE);
|
||||
if (userType != null) {
|
||||
return userType;
|
||||
}
|
||||
// 2. 其次,基于 URL 前缀的约定
|
||||
if (request.getServletPath().startsWith(properties.getAdminApi().getPrefix())) {
|
||||
return UserTypeEnum.ADMIN.getValue();
|
||||
}
|
||||
@@ -119,13 +59,11 @@ public class WebFrameworkUtils {
|
||||
}
|
||||
|
||||
public static Integer getLoginUserType() {
|
||||
HttpServletRequest request = getRequest();
|
||||
return getLoginUserType(request);
|
||||
return getLoginUserType(getRequest());
|
||||
}
|
||||
|
||||
public static Long getLoginUserId() {
|
||||
HttpServletRequest request = getRequest();
|
||||
return getLoginUserId(request);
|
||||
return getLoginUserId(getRequest());
|
||||
}
|
||||
|
||||
public static Integer getTerminal() {
|
||||
@@ -155,24 +93,10 @@ public class WebFrameworkUtils {
|
||||
return servletRequestAttributes.getRequest();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 RPC 请求
|
||||
*
|
||||
* @param request 请求
|
||||
* @return 是否为 RPC 请求
|
||||
*/
|
||||
public static boolean isRpcRequest(HttpServletRequest request) {
|
||||
return request.getRequestURI().startsWith(RpcConstants.RPC_API_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 RPC 请求
|
||||
*
|
||||
* 约定大于配置,只要以 Api 结尾,都认为是 RPC 接口
|
||||
*
|
||||
* @param className 类名
|
||||
* @return 是否为 RPC 请求
|
||||
*/
|
||||
public static boolean isRpcRequest(String className) {
|
||||
return className.endsWith("Api");
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
|
||||
- 需要前端通过 `?token={token}` 形式携带令牌
|
||||
- `LoginUserHandshakeInterceptor` 会将 `LoginUser` 写入 Session
|
||||
- `WebSocketFrameworkUtils` 可获取 `userId/userType/tenantId`
|
||||
- `WebSocketFrameworkUtils` 可获取 `userId/userType`
|
||||
|
||||
## 4. 开发人员上手
|
||||
|
||||
|
||||
@@ -5,63 +5,26 @@ import org.springframework.web.socket.WebSocketSession;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 专属于 web 包的工具类
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
public class WebSocketFrameworkUtils {
|
||||
|
||||
public static final String ATTRIBUTE_LOGIN_USER = "LOGIN_USER";
|
||||
|
||||
/**
|
||||
* 设置当前用户
|
||||
*
|
||||
* @param loginUser 登录用户
|
||||
* @param attributes Session
|
||||
*/
|
||||
public static void setLoginUser(LoginUser loginUser, Map<String, Object> attributes) {
|
||||
attributes.put(ATTRIBUTE_LOGIN_USER, loginUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户
|
||||
*
|
||||
* @return 当前用户
|
||||
*/
|
||||
public static LoginUser getLoginUser(WebSocketSession session) {
|
||||
return (LoginUser) session.getAttributes().get(ATTRIBUTE_LOGIN_USER);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获得当前用户的编号
|
||||
*
|
||||
* @return 用户编号
|
||||
*/
|
||||
public static Long getLoginUserId(WebSocketSession session) {
|
||||
LoginUser loginUser = getLoginUser(session);
|
||||
return loginUser != null ? loginUser.getId() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获得当前用户的类型
|
||||
*
|
||||
* @return 用户编号
|
||||
*/
|
||||
public static Integer getLoginUserType(WebSocketSession session) {
|
||||
LoginUser loginUser = getLoginUser(session);
|
||||
return loginUser != null ? loginUser.getUserType() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获得当前用户的租户编号
|
||||
*
|
||||
* @param session Session
|
||||
* @return 租户编号
|
||||
*/
|
||||
public static Long getTenantId(WebSocketSession session) {
|
||||
LoginUser loginUser = getLoginUser(session);
|
||||
return loginUser != null ? loginUser.getTenantId() : null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user