清理多租户

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

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