清理多租户

This commit is contained in:
2026-03-12 19:45:27 +08:00
parent f0649cb888
commit 8cef3227f3
40 changed files with 123 additions and 753 deletions

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -71,7 +71,6 @@
- Token 透传
- 用户信息透传
- TraceId / 请求链路标识透传
- 租户编号透传
常见做法是基于 `RequestInterceptor`,在所有 Feign 请求发出前统一补充请求头。

View File

@@ -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 透传
- 更细的权限表达式
- 更完整的审计日志字段
- 更严格的跨租户访问控制

View File

@@ -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) {

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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());
}
}

View File

@@ -224,7 +224,7 @@ private RestTemplate loadBalancedRestTemplate;
- `/admin-api/**`
- `/app-api/**`
同时会在文档中预置常见请求头,例如认证头、租户头,便于在 Swagger 页面直接调试接口。
同时会在文档中预置常见请求头,例如认证头,便于在 Swagger 页面直接调试接口。
Swagger 配置示例:

View File

@@ -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;
};
}
}

View File

@@ -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");
}

View File

@@ -75,7 +75,7 @@
- 需要前端通过 `?token={token}` 形式携带令牌
- `LoginUserHandshakeInterceptor` 会将 `LoginUser` 写入 Session
- `WebSocketFrameworkUtils` 可获取 `userId/userType/tenantId`
- `WebSocketFrameworkUtils` 可获取 `userId/userType`
## 4. 开发人员上手

View File

@@ -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;
}
}

View File

@@ -96,7 +96,6 @@ spring:
```
Authorization: Bearer <token>
tenant-id: <tenantId>
```
网关校验成功后,会透传 `login-user` 给后端服务。

View File

@@ -79,7 +79,7 @@ public class GrayLoadBalancer implements ReactorServiceInstanceLoadBalancer {
// 基于 tag 过滤实例列表
chooseInstances = filterTagServiceInstances(chooseInstances, headers);
// 随机 + 权重获取实例列表 TODO 芋艿:目前直接使用 Nacos 提供的方法,如果替换注册中心,需要重新失败该方法
// 随机 + 权重获取实例列表 TODO 目前直接使用 Nacos 提供的方法,如果替换注册中心,需要重新失败该方法
return new DefaultResponse(NacosBalancer.getHostByRandomWeight3(chooseInstances));
}

View File

@@ -50,7 +50,7 @@ import static cn.hutool.core.date.DatePattern.NORM_DATETIME_MS_FORMATTER;
*
* 从功能上,它类似 rdms-spring-boot-starter-web 的 ApiAccessLogFilter 过滤器
*
* TODO 芋艿:如果网关执行异常,不会记录访问日志,后续研究下 https://github.com/Silvmike/webflux-demo/blob/master/tests/src/test/java/ru/hardcoders/demo/webflux/web_handler/filters/logging
* TODO 如果网关执行异常,不会记录访问日志,后续研究下 https://github.com/Silvmike/webflux-demo/blob/master/tests/src/test/java/ru/hardcoders/demo/webflux/web_handler/filters/logging
*
* @author hongawen
*/
@@ -71,34 +71,34 @@ public class AccessLogFilter implements GlobalFilter, Ordered {
// log.info("[writeAccessLog][日志内容:{}]", JsonUtils.toJsonString(gatewayLog));
// 方式二:调用远程服务,记录到数据库中
// TODO 芋艿:暂未实现
// TODO 暂未实现
// 方式三:打印到控制台,方便排查错误
try {
Map<String, Object> values = MapUtil.newHashMap(15, true); // 手工拼接保证排序15 保证不用扩容
values.put("userId", gatewayLog.getUserId());
values.put("userType", gatewayLog.getUserType());
values.put("routeId", gatewayLog.getRoute() != null ? gatewayLog.getRoute().getId() : null);
values.put("schema", gatewayLog.getSchema());
values.put("requestUrl", gatewayLog.getRequestUrl());
values.put("queryParams", gatewayLog.getQueryParams().toSingleValueMap());
values.put("requestBody", JsonUtils.isJson(gatewayLog.getRequestBody()) ? // 保证 body 的展示好看
JSONUtil.parse(gatewayLog.getRequestBody()) : gatewayLog.getRequestBody());
values.put("requestHeaders", JsonUtils.toJsonString(gatewayLog.getRequestHeaders().toSingleValueMap()));
values.put("userIp", gatewayLog.getUserIp());
values.put("responseBody", JsonUtils.isJson(gatewayLog.getResponseBody()) ? // 保证 body 的展示好看
JSONUtil.parse(gatewayLog.getResponseBody()) : gatewayLog.getResponseBody());
values.put("responseHeaders", gatewayLog.getResponseHeaders() != null ?
JsonUtils.toJsonString(gatewayLog.getResponseHeaders().toSingleValueMap()) : null);
values.put("httpStatus", gatewayLog.getHttpStatus());
values.put("startTime", LocalDateTimeUtil.format(gatewayLog.getStartTime(), NORM_DATETIME_MS_FORMATTER));
values.put("endTime", LocalDateTimeUtil.format(gatewayLog.getEndTime(), NORM_DATETIME_MS_FORMATTER));
values.put("duration", gatewayLog.getDuration() != null ? gatewayLog.getDuration() + " ms" : null);
log.info("[writeAccessLog][网关日志:{}]", JsonUtils.toJsonPrettyString(values));
} catch (Exception e) {
// 兜底处理,参见 https://gitee.com/zhijiantianya/rdms-cloud/issues/IC9A70
log.error("[writeAccessLog][打印网关日志时,发生异常]", e);
}
// try {
// Map<String, Object> values = MapUtil.newHashMap(15, true); // 手工拼接保证排序15 保证不用扩容
// values.put("userId", gatewayLog.getUserId());
// values.put("userType", gatewayLog.getUserType());
// values.put("routeId", gatewayLog.getRoute() != null ? gatewayLog.getRoute().getId() : null);
// values.put("schema", gatewayLog.getSchema());
// values.put("requestUrl", gatewayLog.getRequestUrl());
// values.put("queryParams", gatewayLog.getQueryParams().toSingleValueMap());
// values.put("requestBody", JsonUtils.isJson(gatewayLog.getRequestBody()) ? // 保证 body 的展示好看
// JSONUtil.parse(gatewayLog.getRequestBody()) : gatewayLog.getRequestBody());
// values.put("requestHeaders", JsonUtils.toJsonString(gatewayLog.getRequestHeaders().toSingleValueMap()));
// values.put("userIp", gatewayLog.getUserIp());
// values.put("responseBody", JsonUtils.isJson(gatewayLog.getResponseBody()) ? // 保证 body 的展示好看
// JSONUtil.parse(gatewayLog.getResponseBody()) : gatewayLog.getResponseBody());
// values.put("responseHeaders", gatewayLog.getResponseHeaders() != null ?
// JsonUtils.toJsonString(gatewayLog.getResponseHeaders().toSingleValueMap()) : null);
// values.put("httpStatus", gatewayLog.getHttpStatus());
// values.put("startTime", LocalDateTimeUtil.format(gatewayLog.getStartTime(), NORM_DATETIME_MS_FORMATTER));
// values.put("endTime", LocalDateTimeUtil.format(gatewayLog.getEndTime(), NORM_DATETIME_MS_FORMATTER));
// values.put("duration", gatewayLog.getDuration() != null ? gatewayLog.getDuration() + " ms" : null);
// log.info("[writeAccessLog][网关日志:{}]", JsonUtils.toJsonPrettyString(values));
// } catch (Exception e) {
// // 兜底处理,参见 https://gitee.com/zhijiantianya/rdms-cloud/issues/IC9A70
// log.error("[writeAccessLog][打印网关日志时,发生异常]", e);
// }
}
@Override

View File

@@ -6,39 +6,13 @@ import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 登录用户信息
*
* copy from rdms-spring-boot-starter-security 的 LoginUser 类
*
* @author hongawen
*/
@Data
public class LoginUser {
/**
* 用户编号
*/
private Long id;
/**
* 用户类型
*/
private Integer userType;
/**
* 额外的用户信息
*/
private Map<String, String> info;
/**
* 租户编号
*/
private Long tenantId;
/**
* 授权范围
*/
private List<String> scopes;
/**
* 过期时间
*/
private LocalDateTime expiresTime;
}

View File

@@ -6,12 +6,10 @@ import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.njcn.rdms.framework.common.biz.system.oauth2.OAuth2TokenCommonApi;
import com.njcn.rdms.framework.common.biz.system.oauth2.dto.OAuth2AccessTokenCheckRespDTO;
import com.njcn.rdms.framework.common.core.KeyValue;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.framework.common.util.date.LocalDateTimeUtils;
import com.njcn.rdms.framework.common.util.json.JsonUtils;
import com.njcn.rdms.gateway.util.SecurityFrameworkUtils;
import com.njcn.rdms.gateway.util.WebFrameworkUtils;
import org.springframework.cloud.client.loadbalancer.reactive.ReactorLoadBalancerExchangeFilterFunction;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
@@ -28,141 +26,102 @@ import java.util.function.Function;
import static com.njcn.rdms.framework.common.util.cache.CacheUtils.buildAsyncReloadingCache;
/**
* Token 过滤器,验证 token 的有效性
* 1. 验证通过时,将 userId、userType、tenantId 通过 Header 转发给服务
* 2. 验证不通过,还是会转发给服务。因为,接口是否需要登录的校验,还是交给服务自身处理
*
* @author hongawen
*/
@Component
public class TokenAuthenticationFilter implements GlobalFilter, Ordered {
/**
* CommonResult<OAuth2AccessTokenCheckRespDTO> 对应的 TypeReference 结果,用于解析 checkToken 的结果
*/
private static final TypeReference<CommonResult<OAuth2AccessTokenCheckRespDTO>> CHECK_RESULT_TYPE_REFERENCE
= new TypeReference<CommonResult<OAuth2AccessTokenCheckRespDTO>>() {};
private static final TypeReference<CommonResult<OAuth2AccessTokenCheckRespDTO>> CHECK_RESULT_TYPE_REFERENCE =
new TypeReference<CommonResult<OAuth2AccessTokenCheckRespDTO>>() {};
/**
* 空的 LoginUser 的结果
*
* 用于解决如下问题:
* 1. {@link #getLoginUser(ServerWebExchange, String)} 返回 Mono.empty() 时,会导致后续的 flatMap 无法进行处理的问题。
* 2. {@link #buildUser(String)} 时,如果 Token 已经过期,返回 LOGIN_USER_EMPTY 对象,避免缓存无法刷新
*/
private static final LoginUser LOGIN_USER_EMPTY = new LoginUser();
private final WebClient webClient;
/**
* 登录用户的本地缓存
*
* key1多租户的编号
* key2访问令牌
*/
private final LoadingCache<KeyValue<Long, String>, LoginUser> loginUserCache = buildAsyncReloadingCache(Duration.ofMinutes(1),
new CacheLoader<KeyValue<Long, String>, LoginUser>() {
private final LoadingCache<String, LoginUser> loginUserCache = buildAsyncReloadingCache(Duration.ofMinutes(1),
new CacheLoader<String, LoginUser>() {
@Override
public LoginUser load(KeyValue<Long, String> token) {
String body = checkAccessToken(token.getKey(), token.getValue()).block();
public LoginUser load(String token) {
String body = checkAccessToken(token).block();
return buildUser(body);
}
});
public TokenAuthenticationFilter(ReactorLoadBalancerExchangeFilterFunction lbFunction) {
// Q为什么不使用 OAuth2TokenApi 进行调用?
// A1Spring Cloud OpenFeign 官方未内置 Reactive 的支持 https://docs.spring.io/spring-cloud-openfeign/docs/current/reference/html/#reactive-support
// A2校验 Token 的 API 需要使用到 header[tenant-id] 传递租户编号,暂时不想编写 RequestInterceptor 实现
// 因此,这里采用 WebClient通过 lbFunction 实现负载均衡
this.webClient = WebClient.builder().filter(lbFunction).build();
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 移除 login-user 的请求头,避免伪造模拟
exchange = SecurityFrameworkUtils.removeLoginUser(exchange);
// 情况一,如果没有 Token 令牌,则直接继续 filter
String token = SecurityFrameworkUtils.obtainAuthorization(exchange);
if (StrUtil.isEmpty(token)) {
return chain.filter(exchange);
}
// 情况二,如果有 Token 令牌,则解析对应 userId、userType、tenantId 等字段,并通过 通过 Header 转发给服务
// 重要说明defaultIfEmpty 作用,保证 Mono.empty() 情况,可以继续执行 `flatMap 的 chain.filter(exchange)` 逻辑,避免返回给前端空的 Response
ServerWebExchange finalExchange = exchange;
return getLoginUser(exchange, token).defaultIfEmpty(LOGIN_USER_EMPTY).flatMap(user -> {
// 1. 无用户,直接 filter 继续请求
if (user == LOGIN_USER_EMPTY || // 下面 expiresTime 的判断,为了解决 token 实际已经过期的情况
user.getExpiresTime() == null || LocalDateTimeUtils.beforeNow(user.getExpiresTime())) {
return getLoginUser(token).defaultIfEmpty(LOGIN_USER_EMPTY).flatMap(user -> {
if (user == LOGIN_USER_EMPTY
|| user.getExpiresTime() == null
|| LocalDateTimeUtils.beforeNow(user.getExpiresTime())) {
return chain.filter(finalExchange);
}
// 2.1 有用户,则设置登录用户
SecurityFrameworkUtils.setLoginUser(finalExchange, user);
// 2.2 将 user 并设置到 login-user 的请求头,使用 json 存储值
ServerWebExchange newExchange = finalExchange.mutate()
.request(builder -> SecurityFrameworkUtils.setLoginUserHeader(builder, user)).build();
.request(builder -> SecurityFrameworkUtils.setLoginUserHeader(builder, user))
.build();
return chain.filter(newExchange);
});
}
private Mono<LoginUser> getLoginUser(ServerWebExchange exchange, String token) {
// 从缓存中,获取 LoginUser
Long tenantId = WebFrameworkUtils.getTenantId(exchange);
KeyValue<Long, String> cacheKey = new KeyValue<Long, String>().setKey(tenantId).setValue(token);
LoginUser localUser = loginUserCache.getIfPresent(cacheKey);
private Mono<LoginUser> getLoginUser(String token) {
LoginUser localUser = loginUserCache.getIfPresent(token);
if (localUser != null) {
return Mono.just(localUser);
}
// 缓存不存在,则请求远程服务
return checkAccessToken(tenantId, token).flatMap((Function<String, Mono<LoginUser>>) body -> {
return checkAccessToken(token).flatMap((Function<String, Mono<LoginUser>>) body -> {
LoginUser remoteUser = buildUser(body);
if (remoteUser != null) {
// 非空,则进行缓存
loginUserCache.put(cacheKey, remoteUser);
loginUserCache.put(token, remoteUser);
return Mono.just(remoteUser);
}
return Mono.empty();
});
}
private Mono<String> checkAccessToken(Long tenantId, String token) {
private Mono<String> checkAccessToken(String token) {
return webClient.get()
.uri(OAuth2TokenCommonApi.URL_CHECK, uriBuilder -> uriBuilder.queryParam("accessToken", token).build())
.headers(httpHeaders -> WebFrameworkUtils.setTenantIdHeader(tenantId, httpHeaders)) // 设置租户的 Header
.retrieve().bodyToMono(String.class);
.retrieve()
.bodyToMono(String.class);
}
private LoginUser buildUser(String body) {
// 处理结果,结果不正确
CommonResult<OAuth2AccessTokenCheckRespDTO> result = JsonUtils.parseObject(body, CHECK_RESULT_TYPE_REFERENCE);
if (result == null) {
return null;
}
if (result.isError()) {
// 特殊情况令牌已经过期code = 401需要返回 LOGIN_USER_EMPTY避免 Token 一直因为缓存,被误判为有效
if (Objects.equals(result.getCode(), HttpStatus.UNAUTHORIZED.value())) {
return LOGIN_USER_EMPTY;
}
return null;
}
// 创建登录用户
OAuth2AccessTokenCheckRespDTO tokenInfo = result.getData();
return new LoginUser().setId(tokenInfo.getUserId()).setUserType(tokenInfo.getUserType())
.setInfo(tokenInfo.getUserInfo()) // 额外的用户信息
.setTenantId(tokenInfo.getTenantId()).setScopes(tokenInfo.getScopes())
return new LoginUser().setId(tokenInfo.getUserId())
.setUserType(tokenInfo.getUserType())
.setInfo(tokenInfo.getUserInfo())
.setScopes(tokenInfo.getScopes())
.setExpiresTime(tokenInfo.getExpiresTime());
}
@Override
public int getOrder() {
return -100; // 和 Spring Security Filter 的顺序对齐
return -100;
}
}

View File

@@ -52,7 +52,7 @@ public class GlobalExceptionHandler implements ErrorWebExceptionHandler {
*/
private CommonResult<?> responseStatusExceptionHandler(ServerWebExchange exchange,
ResponseStatusException ex) {
// TODO 芋艿:这里要精细化翻译,默认返回用户是看不懂的
// TODO 这里要精细化翻译,默认返回用户是看不懂的
ServerHttpRequest request = exchange.getRequest();
log.error("[responseStatusExceptionHandler][uri({}/{}) 发生异常]", request.getURI(), request.getMethod(), ex);
return CommonResult.error(ex.getStatusCode().value(), ex.getReason());
@@ -66,7 +66,7 @@ public class GlobalExceptionHandler implements ErrorWebExceptionHandler {
Throwable ex) {
ServerHttpRequest request = exchange.getRequest();
log.error("[defaultExceptionHandler][uri({}/{}) 发生异常]", request.getURI(), request.getMethod(), ex);
// TODO 芋艿:是否要插入异常日志呢?
// TODO 是否要插入异常日志呢?
// 返回 ERROR CommonResult
return CommonResult.error(INTERNAL_SERVER_ERROR.getCode(), INTERNAL_SERVER_ERROR.getMsg());
}

View File

@@ -2,91 +2,45 @@ package com.njcn.rdms.gateway.util;
import cn.hutool.core.net.NetUtil;
import cn.hutool.core.util.ArrayUtil;
import cn.hutool.core.util.NumberUtil;
import cn.hutool.extra.servlet.ServletUtil;
import com.njcn.rdms.framework.common.util.json.JsonUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.route.Route;
import org.springframework.cloud.gateway.support.ServerWebExchangeUtils;
import org.springframework.core.io.buffer.DataBufferFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
/**
* Web 工具类
*
* copy from rdms-spring-boot-starter-web 的 WebFrameworkUtils 类
*
* @author hongawen
*/
@Slf4j
public class WebFrameworkUtils {
private static final String HEADER_TENANT_ID = "tenant-id";
private WebFrameworkUtils() {}
/**
* 将 Gateway 请求中的 header设置到 HttpHeaders 中
*
* @param tenantId 租户编号
* @param httpHeaders WebClient 的请求
*/
public static void setTenantIdHeader(Long tenantId, HttpHeaders httpHeaders) {
if (tenantId == null) {
return;
}
httpHeaders.set(HEADER_TENANT_ID, String.valueOf(tenantId));
}
public static Long getTenantId(ServerWebExchange exchange) {
String tenantId = exchange.getRequest().getHeaders().getFirst(HEADER_TENANT_ID);
return NumberUtil.isNumber(tenantId) ? Long.valueOf(tenantId) : null;
}
/**
* 返回 JSON 字符串
*
* @param exchange 响应
* @param object 对象,会序列化成 JSON 字符串
*/
@SuppressWarnings("deprecation") // 必须使用 APPLICATION_JSON_UTF8_VALUE否则会乱码
@SuppressWarnings("deprecation")
public static Mono<Void> writeJSON(ServerWebExchange exchange, Object object) {
// 设置 header
ServerHttpResponse response = exchange.getResponse();
response.getHeaders().setContentType(MediaType.APPLICATION_JSON_UTF8);
// 设置 body
return response.writeWith(Mono.fromSupplier(() -> {
DataBufferFactory bufferFactory = response.bufferFactory();
try {
return bufferFactory.wrap(JsonUtils.toJsonByte(object));
} catch (Exception ex) {
ServerHttpRequest request = exchange.getRequest();
log.error("[writeJSON][uri({}/{}) 发生异常]", request.getURI(), request.getMethod(), ex);
log.error("[writeJSON][uri({}/{}) error]", request.getURI(), request.getMethod(), ex);
return bufferFactory.wrap(new byte[0]);
}
}));
}
/**
* 获得客户端 IP
*
* 参考 {@link ServletUtil} 的 getClientIP 方法
*
* @param exchange 请求
* @param otherHeaderNames 其它 header 名字的数组
* @return 客户端 IP
*/
public static String getClientIP(ServerWebExchange exchange, String... otherHeaderNames) {
String[] headers = { "X-Forwarded-For", "X-Real-IP", "Proxy-Client-IP", "WL-Proxy-Client-IP", "HTTP_CLIENT_IP", "HTTP_X_FORWARDED_FOR" };
String[] headers = {"X-Forwarded-For", "X-Real-IP", "Proxy-Client-IP", "WL-Proxy-Client-IP",
"HTTP_CLIENT_IP", "HTTP_X_FORWARDED_FOR"};
if (ArrayUtil.isNotEmpty(otherHeaderNames)) {
headers = ArrayUtil.addAll(headers, otherHeaderNames);
}
// 方式一,通过 header 获取
String ip;
for (String header : headers) {
ip = exchange.getRequest().getHeaders().getFirst(header);
@@ -94,8 +48,6 @@ public class WebFrameworkUtils {
return NetUtil.getMultistageReverseProxyIp(ip);
}
}
// 方式二,通过 remoteAddress 获取
if (exchange.getRequest().getRemoteAddress() == null) {
return null;
}
@@ -103,12 +55,6 @@ public class WebFrameworkUtils {
return NetUtil.getMultistageReverseProxyIp(ip);
}
/**
* 获得请求匹配的 Route 路由
*
* @param exchange 请求
* @return 路由
*/
public static Route getGatewayRoute(ServerWebExchange exchange) {
return exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR);
}

View File

@@ -100,20 +100,6 @@ public interface ErrorCodeConstants {
ErrorCode SMS_CODE_EXCEED_SEND_MAXIMUM_QUANTITY_PER_DAY = new ErrorCode(1_002_014_004, "超过每日短信发送数量");
ErrorCode SMS_CODE_SEND_TOO_FAST = new ErrorCode(1_002_014_005, "短信发送过于频繁");
// ========== 租户信息 1-002-015-000 ==========
ErrorCode TENANT_NOT_EXISTS = new ErrorCode(1_002_015_000, "租户不存在");
ErrorCode TENANT_DISABLE = new ErrorCode(1_002_015_001, "名字为【{}】的租户已被禁用");
ErrorCode TENANT_EXPIRE = new ErrorCode(1_002_015_002, "名字为【{}】的租户已过期");
ErrorCode TENANT_CAN_NOT_UPDATE_SYSTEM = new ErrorCode(1_002_015_003, "系统租户不能进行修改、删除等操作!");
ErrorCode TENANT_NAME_DUPLICATE = new ErrorCode(1_002_015_004, "名字为【{}】的租户已存在");
ErrorCode TENANT_WEBSITE_DUPLICATE = new ErrorCode(1_002_015_005, "域名为【{}】的租户已存在");
// ========== 租户套餐 1-002-016-000 ==========
ErrorCode TENANT_PACKAGE_NOT_EXISTS = new ErrorCode(1_002_016_000, "租户套餐不存在");
ErrorCode TENANT_PACKAGE_USED = new ErrorCode(1_002_016_001, "租户正在使用该套餐,请给租户重新设置套餐后再尝试删除");
ErrorCode TENANT_PACKAGE_DISABLE = new ErrorCode(1_002_016_002, "名字为【{}】的租户套餐已被禁用");
ErrorCode TENANT_PACKAGE_NAME_DUPLICATE = new ErrorCode(1_002_016_003, "已经存在该名字的租户套餐");
// ========== OAuth2 客户端 1-002-020-000 =========
ErrorCode OAUTH2_CLIENT_NOT_EXISTS = new ErrorCode(1_002_020_000, "OAuth2 客户端不存在");
ErrorCode OAUTH2_CLIENT_EXISTS = new ErrorCode(1_002_020_001, "OAuth2 客户端编号已存在");

View File

@@ -4,25 +4,14 @@ import com.njcn.rdms.framework.common.util.object.ObjectUtils;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 角色标识枚举
*/
@Getter
@AllArgsConstructor
public enum RoleCodeEnum {
SUPER_ADMIN("super_admin", "超级管理员"),
TENANT_ADMIN("tenant_admin", "租户管理员"),
CRM_ADMIN("crm_admin", "CRM 管理员"); // CRM 系统专用
;
CRM_ADMIN("crm_admin", "CRM 管理员");
/**
* 角色编码
*/
private final String code;
/**
* 名字
*/
private final String name;
public static boolean isSuperAdmin(String code) {

View File

@@ -1,12 +1,11 @@
package com.njcn.rdms.module.system.api.oauth2;
import com.njcn.rdms.framework.common.biz.system.oauth2.OAuth2TokenCommonApi;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.framework.common.util.object.BeanUtils;
import com.njcn.rdms.framework.common.biz.system.oauth2.dto.OAuth2AccessTokenCheckRespDTO;
import com.njcn.rdms.framework.common.biz.system.oauth2.dto.OAuth2AccessTokenCreateReqDTO;
import com.njcn.rdms.framework.common.biz.system.oauth2.dto.OAuth2AccessTokenRespDTO;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.framework.common.util.object.BeanUtils;
import com.njcn.rdms.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
import com.njcn.rdms.module.system.service.oauth2.OAuth2TokenService;
import jakarta.annotation.Resource;
@@ -15,7 +14,7 @@ import org.springframework.web.bind.annotation.RestController;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
@RestController // 提供 RESTful API 接口,给 Feign 调用
@RestController
@Validated
public class OAuth2TokenApiImpl implements OAuth2TokenCommonApi {
@@ -30,7 +29,6 @@ public class OAuth2TokenApiImpl implements OAuth2TokenCommonApi {
}
@Override
// 访问令牌校验时,无需传递租户编号;主要解决上传文件的场景,前端不会传递 tenant-id
public CommonResult<OAuth2AccessTokenCheckRespDTO> checkAccessToken(String accessToken) {
OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.checkAccessToken(accessToken);
return success(BeanUtils.toBean(accessTokenDO, OAuth2AccessTokenCheckRespDTO.class));

View File

@@ -1,7 +1,6 @@
### 请求 /login 接口 => 成功
POST {{baseUrl}}/system/auth/login
Content-Type: application/json
tenant-id: {{adminTenantId}}
tag: Yunai.local
{
@@ -14,7 +13,6 @@ tag: Yunai.local
### 请求 /login 接口【加密 AES】 => 成功
POST {{baseUrl}}/system/auth/login
Content-Type: application/json
tenant-id: {{adminTenantId}}
tag: Yunai.local
X-API-ENCRYPT: true
@@ -23,7 +21,6 @@ WvSX9MOrenyGfBhEM0g1/hHgq8ocktMZ9OwAJ6MOG5FUrzYF/rG5JF1eMptQM1wT73VgDS05l/37WeRt
### 请求 /login 接口【加密 RSA】 => 成功
POST {{baseUrl}}/system/auth/login
Content-Type: application/json
tenant-id: {{adminTenantId}}
tag: Yunai.local
X-API-ENCRYPT: true
@@ -32,7 +29,6 @@ e7QZTork9ZV5CmgZvSd+cHZk3xdUxKtowLM02kOha+gxHK2H/daU8nVBYS3+bwuDRy5abf+Pz1QJJGVA
### 请求 /login 接口 => 成功(无验证码)
POST {{baseUrl}}/system/auth/login
Content-Type: application/json
tenant-id: {{adminTenantId}}
{
"username": "admin",
@@ -42,10 +38,8 @@ tenant-id: {{adminTenantId}}
### 请求 /get-permission-info 接口 => 成功
GET {{baseUrl}}/system/auth/get-permission-info
Authorization: Bearer {{token}}
tenant-id: {{adminTenantId}}
### 请求 /list-menus 接口 => 成功
GET {{baseUrl}}/system/list-menus
Authorization: Bearer {{token}}
#Authorization: Bearer a6aa7714a2e44c95aaa8a2c5adc2a67a
tenant-id: {{adminTenantId}}

View File

@@ -1,4 +1,3 @@
### 请求 /menu/list 接口 => 成功
GET {{baseUrl}}/system/dict-data/list-all-simple
Authorization: Bearer {{token}}
tenant-id: {{adminTenantId}}

View File

@@ -1,7 +1,6 @@
### 请求 /system/file-config/create 接口 => 成功
### 请求 /system/file-config/create 接口 => 成功
POST {{baseUrl}}/system/file-config/create
Content-Type: application/json
tenant-id: {{adminTenantId}}
Authorization: Bearer {{token}}
{
@@ -21,7 +20,6 @@ Authorization: Bearer {{token}}
### 请求 /system/file-config/update 接口 => 成功
PUT {{baseUrl}}/system/file-config/update
Content-Type: application/json
tenant-id: {{adminTenantId}}
Authorization: Bearer {{token}}
{
@@ -41,6 +39,4 @@ Authorization: Bearer {{token}}
### 请求 /system/file-config/test 接口 => 成功
GET {{baseUrl}}/system/file-config/test?id=2
Content-Type: application/json
tenant-id: {{adminTenantId}}
Authorization: Bearer {{token}}

View File

@@ -1,5 +1,3 @@
### 获得地区树
GET {{baseUrl}}/system/area/tree
Authorization: Bearer {{token}}
tenant-id: {{adminTenantId}}

View File

@@ -1,4 +1,3 @@
### 请求 /system/operate-log/page 接口 => 成功
GET {{baseUrl}}/system/operate-log/page
Authorization: Bearer {{token}}
tenant-id: {{adminTenantId}}

View File

@@ -2,7 +2,6 @@
POST {{baseUrl}}/system/oauth2-client/create
Content-Type: application/json
Authorization: Bearer {{token}}
tenant-id: {{adminTenantId}}
{
"id": "1",

View File

@@ -1,13 +1,11 @@
### 请求 /system/oauth2/authorize 接口 => 成功
GET {{baseUrl}}/system/oauth2/authorize?clientId=default
Authorization: Bearer {{token}}
tenant-id: {{adminTenantId}}
### 请求 /system/oauth2/authorize + token 接口 => 成功
POST {{baseUrl}}/system/oauth2/authorize
Content-Type: application/x-www-form-urlencoded
Authorization: Bearer {{token}}
tenant-id: {{adminTenantId}}
response_type=token&client_id=default&scope={"user.read": true}&redirect_uri=https://www.iocoder.cn&auto_approve=true
@@ -15,7 +13,6 @@ response_type=token&client_id=default&scope={"user.read": true}&redirect_uri=htt
POST {{baseUrl}}/system/oauth2/authorize
Content-Type: application/x-www-form-urlencoded
Authorization: Bearer {{token}}
tenant-id: {{adminTenantId}}
response_type=code&client_id=default&scope={"user.read": true}&redirect_uri=https://www.iocoder.cn&auto_approve=false
@@ -23,7 +20,6 @@ response_type=code&client_id=default&scope={"user.read": true}&redirect_uri=http
POST {{baseUrl}}/system/oauth2/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic ZGVmYXVsdDphZG1pbjEyMw==
tenant-id: {{adminTenantId}}
grant_type=authorization_code&redirect_uri=https://www.iocoder.cn&code=189956c07a174588a97157eabef2f93a
@@ -31,7 +27,6 @@ grant_type=authorization_code&redirect_uri=https://www.iocoder.cn&code=189956c07
POST {{baseUrl}}/system/oauth2/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic ZGVmYXVsdDphZG1pbjEyMw==
tenant-id: {{adminTenantId}}
grant_type=password&username=admin&password=admin123&scope=user.read
@@ -39,7 +34,6 @@ grant_type=password&username=admin&password=admin123&scope=user.read
POST {{baseUrl}}/system/oauth2/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic ZGVmYXVsdDphZG1pbjEyMw==
tenant-id: {{adminTenantId}}
grant_type=client_credentials&scope=user.read
@@ -47,16 +41,13 @@ grant_type=client_credentials&scope=user.read
POST {{baseUrl}}/system/oauth2/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic ZGVmYXVsdDphZG1pbjEyMw==
tenant-id: {{adminTenantId}}
grant_type=refresh_token&refresh_token=00895465d6994f72a9d926ceeed0f588
### 请求 /system/oauth2/token + DELETE 接口 => 成功
DELETE {{baseUrl}}/system/oauth2/token?token=ca8a188f464441d6949c51493a2b7596
Authorization: Basic ZGVmYXVsdDphZG1pbjEyMw==
tenant-id: {{adminTenantId}}
### 请求 /system/oauth2/check-token 接口 => 成功
POST {{baseUrl}}/system/oauth2/check-token?token=620d307c5b4148df8a98dd6c6c547106
Authorization: Basic ZGVmYXVsdDphZG1pbjEyMw==
tenant-id: {{adminTenantId}}

View File

@@ -1,13 +1,11 @@
### 请求 /system/oauth2/user/get 接口 => 成功
GET {{baseUrl}}/system/oauth2/user/get
Authorization: Bearer 47f9c74ec11041f193b777ebb95c3b0d
tenant-id: {{adminTenantId}}
### 请求 /system/oauth2/user/update 接口 => 成功
PUT {{baseUrl}}/system/oauth2/user/update
Content-Type: application/json
Authorization: Bearer 47f9c74ec11041f193b777ebb95c3b0d
tenant-id: {{adminTenantId}}
{
"nickname": "灿能源码"

View File

@@ -8,33 +8,32 @@ import lombok.NoArgsConstructor;
import java.util.List;
@Schema(description = "管理后台 - 【开放接口】校验令牌 Response VO")
@Schema(description = "Admin API - open check token response")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class OAuth2OpenCheckTokenRespVO {
@Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "666")
@Schema(description = "User id", requiredMode = Schema.RequiredMode.REQUIRED, example = "666")
@JsonProperty("user_id")
private Long userId;
@Schema(description = "用户类型,参见 UserTypeEnum 枚举", requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
@Schema(description = "User type", requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
@JsonProperty("user_type")
private Integer userType;
@Schema(description = "租户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@JsonProperty("tenant_id")
private Long tenantId;
@Schema(description = "客户端编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "car")
@Schema(description = "Client id", requiredMode = Schema.RequiredMode.REQUIRED, example = "car")
@JsonProperty("client_id")
private String clientId;
@Schema(description = "授权范围", requiredMode = Schema.RequiredMode.REQUIRED, example = "user_info")
@Schema(description = "Scopes", requiredMode = Schema.RequiredMode.REQUIRED, example = "user_info")
private List<String> scopes;
@Schema(description = "访问令牌", requiredMode = Schema.RequiredMode.REQUIRED, example = "tudou")
@Schema(description = "Access token", requiredMode = Schema.RequiredMode.REQUIRED, example = "tudou")
@JsonProperty("access_token")
private String accessToken;
@Schema(description = "过期时间,时间戳 / 1000即单位", requiredMode = Schema.RequiredMode.REQUIRED, example = "1593092157")
@Schema(description = "Expire timestamp in seconds", requiredMode = Schema.RequiredMode.REQUIRED, example = "1593092157")
private Long exp;
}

View File

@@ -1,4 +1,3 @@
### 请求 /menu/list 接口 => 成功
GET {{baseUrl}}/system/menu/list
Authorization: Bearer {{token}}
tenant-id: {{adminTenantId}}

View File

@@ -16,7 +16,14 @@ import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Comparator;
import java.util.List;
@@ -76,11 +83,9 @@ public class MenuController {
}
@GetMapping({"/list-all-simple", "simple-list"})
@Operation(summary = "获取菜单精简信息列表",
description = "只包含被开启的菜单,用于【角色分配菜单】功能的选项。在多租户的场景下,会只返回租户所在套餐有的菜单")
@Operation(summary = "获取菜单精简信息列表", description = "只包含已启用的菜单,用于【角色分配菜单】功能的选项")
public CommonResult<List<MenuSimpleRespVO>> getSimpleMenuList() {
List<MenuDO> list = menuService.getMenuListByTenant(
new MenuListReqVO().setStatus(CommonStatusEnum.ENABLE.getStatus()));
List<MenuDO> list = menuService.getMenuList(new MenuListReqVO().setStatus(CommonStatusEnum.ENABLE.getStatus()));
list = menuService.filterDisableMenus(list);
list.sort(Comparator.comparing(MenuDO::getSort));
return success(BeanUtils.toBean(list, MenuSimpleRespVO.class));

View File

@@ -2,7 +2,6 @@
POST {{baseUrl}}/system/role/create
Authorization: Bearer {{token}}
Content-Type: application/json
tenant-id: {{adminTenantId}}
{
"name": "测试角色",
@@ -14,7 +13,6 @@ tenant-id: {{adminTenantId}}
POST {{baseUrl}}/system/role/update
Authorization: Bearer {{token}}
Content-Type: application/json
tenant-id: {{adminTenantId}}
{
"id": 100,
@@ -26,7 +24,6 @@ tenant-id: {{adminTenantId}}
POST {{baseUrl}}/system/role/delete
Content-Type: application/x-www-form-urlencoded
Authorization: Bearer {{token}}
tenant-id: {{adminTenantId}}
roleId=14
@@ -34,9 +31,7 @@ roleId=14
GET {{baseUrl}}/system/role/get?id=100
Content-Type: application/x-www-form-urlencoded
Authorization: Bearer {{token}}
tenant-id: {{adminTenantId}}
### /role/page 成功
GET {{baseUrl}}/system/role/page?pageNo=1&pageSize=10
Authorization: Bearer {{token}}
tenant-id: {{adminTenantId}}

View File

@@ -1,5 +1,4 @@
### 请求 /system/redis/get-monitor-info 接口 => 成功
GET {{baseUrl}}/system/redis/get-monitor-info
Authorization: Bearer {{token}}
tenant-id: {{adminTenantId}}

View File

@@ -2,10 +2,7 @@
GET {{baseUrl}}/system/user/page?pageNo=1&pageSize=10
Authorization: Bearer {{token}}
#Authorization: Bearer test100
tenant-id: {{adminTenantId}}
### 请求 /system/user/page 接口(测试访问别的租户)
GET {{baseUrl}}/system/user/page?pageNo=1&pageSize=10
Authorization: Bearer {{token}}
tenant-id: {{adminTenantId}}
visit-tenant-id: 122

View File

@@ -1,4 +1,3 @@
### 请求 /system/user/profile/get 接口 => 没有权限
GET {{baseUrl}}/system/user/profile/get
Authorization: Bearer {{token}}
tenant-id: {{adminTenantId}}

View File

@@ -7,96 +7,26 @@ import com.njcn.rdms.module.system.dal.dataobject.permission.MenuDO;
import java.util.Collection;
import java.util.List;
/**
* 菜单 Service 接口
*
* @author hongawen
*/
public interface MenuService {
/**
* 创建菜单
*
* @param createReqVO 菜单信息
* @return 创建出来的菜单编号
*/
Long createMenu(MenuSaveVO createReqVO);
/**
* 更新菜单
*
* @param updateReqVO 菜单信息
*/
void updateMenu(MenuSaveVO updateReqVO);
/**
* 删除菜单
*
* @param id 菜单编号
*/
void deleteMenu(Long id);
/**
* 批量删除菜单
*
* @param ids 菜单编号数组
*/
void deleteMenuList(List<Long> ids);
/**
* 获得所有菜单列表
*
* @return 菜单列表
*/
List<MenuDO> getMenuList();
/**
* 基于租户,筛选菜单列表
* 注意,如果是系统租户,返回的还是全菜单
*
* @param reqVO 筛选条件请求 VO
* @return 菜单列表
*/
List<MenuDO> getMenuListByTenant(MenuListReqVO reqVO);
/**
* 过滤掉关闭的菜单及其子菜单
*
* @param list 菜单列表
* @return 过滤后的菜单列表
*/
List<MenuDO> filterDisableMenus(List<MenuDO> list);
/**
* 筛选菜单列表
*
* @param reqVO 筛选条件请求 VO
* @return 菜单列表
*/
List<MenuDO> getMenuList(MenuListReqVO reqVO);
/**
* 获得权限对应的菜单编号数组
*
* @param permission 权限标识
* @return 数组
*/
List<Long> getMenuIdListByPermissionFromCache(String permission);
/**
* 获得菜单
*
* @param id 菜单编号
* @return 菜单
*/
MenuDO getMenu(Long id);
/**
* 获得菜单数组
*
* @param ids 菜单编号数组
* @return 菜单数组
*/
List<MenuDO> getMenuList(Collection<Long> ids);
}

View File

@@ -125,13 +125,6 @@ public class MenuServiceImpl implements MenuService {
return menuMapper.selectList();
}
@Override
public List<MenuDO> getMenuListByTenant(MenuListReqVO reqVO) {
// 查询所有菜单,并过滤掉关闭的节点
List<MenuDO> menus = getMenuList(reqVO);
return menus;
}
@Override
public List<MenuDO> filterDisableMenus(List<MenuDO> menuList) {
if (CollUtil.isEmpty(menuList)){