diff --git a/yudao-gateway/src/main/java/cn/iocoder/yudao/gateway/GatewayServerApplication.java b/yudao-gateway/src/main/java/cn/iocoder/yudao/gateway/GatewayServerApplication.java index 99984699f..5da532a82 100644 --- a/yudao-gateway/src/main/java/cn/iocoder/yudao/gateway/GatewayServerApplication.java +++ b/yudao-gateway/src/main/java/cn/iocoder/yudao/gateway/GatewayServerApplication.java @@ -1,9 +1,12 @@ package cn.iocoder.yudao.gateway; +import cn.iocoder.yudao.gateway.filter.front.ParamterSecretProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; @SpringBootApplication +@EnableConfigurationProperties(ParamterSecretProperties.class) public class GatewayServerApplication { public static void main(String[] args) { diff --git a/yudao-gateway/src/main/java/cn/iocoder/yudao/gateway/filter/front/ResponseDto.java b/yudao-gateway/src/main/java/cn/iocoder/yudao/gateway/filter/front/BodyDto.java similarity index 74% rename from yudao-gateway/src/main/java/cn/iocoder/yudao/gateway/filter/front/ResponseDto.java rename to yudao-gateway/src/main/java/cn/iocoder/yudao/gateway/filter/front/BodyDto.java index 9e149e873..927e85571 100644 --- a/yudao-gateway/src/main/java/cn/iocoder/yudao/gateway/filter/front/ResponseDto.java +++ b/yudao-gateway/src/main/java/cn/iocoder/yudao/gateway/filter/front/BodyDto.java @@ -11,7 +11,7 @@ import java.io.Serializable; * @author feng */ @Data -public class ResponseDto implements Serializable { +public class BodyDto implements Serializable { @Serial private static final long serialVersionUID = -6354714160436354659L; @@ -20,4 +20,8 @@ public class ResponseDto implements Serializable { private String msg; private Object data; + + private String requestBody; + + private String responseBody; } \ No newline at end of file diff --git a/yudao-gateway/src/main/java/cn/iocoder/yudao/gateway/filter/front/FrontSecretFilter.java b/yudao-gateway/src/main/java/cn/iocoder/yudao/gateway/filter/front/FrontSecretFilter.java new file mode 100644 index 000000000..0b9a02c6b --- /dev/null +++ b/yudao-gateway/src/main/java/cn/iocoder/yudao/gateway/filter/front/FrontSecretFilter.java @@ -0,0 +1,239 @@ +package cn.iocoder.yudao.gateway.filter.front; + +import cn.hutool.core.codec.Base64Encoder; +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.gateway.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.gateway.util.secret.AESUtils; +import cn.iocoder.yudao.gateway.util.secret.RSAUtils; +import com.alibaba.nacos.common.utils.StringUtils; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.reactivestreams.Publisher; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.cloud.gateway.filter.factory.rewrite.CachedBodyOutputMessage; +import org.springframework.cloud.gateway.support.BodyInserterContext; +import org.springframework.cloud.gateway.support.NotFoundException; +import org.springframework.cloud.gateway.support.ServerWebExchangeUtils; +import org.springframework.core.Ordered; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ReactiveHttpOutputMessage; +import org.springframework.http.codec.CodecConfigurer; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpRequestDecorator; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpResponseDecorator; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.BodyInserter; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +@Slf4j +@Component +public class FrontSecretFilter implements GlobalFilter, Ordered { + @Resource + private ParamterSecretProperties secretProperties; + + @Resource + private CodecConfigurer codecConfigurer; + + private String aesKey; + private BodyDto bodyDto=new BodyDto(); + @Override + public int getOrder() { + return Ordered.HIGHEST_PRECEDENCE+98; //在日志之前,以免日志无法打印 + } + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + // 将 Request 中可以直接获取到的参数,设置到网关日志 + ServerHttpRequest request = exchange.getRequest(); + + //筛选方法,只对get put post处理 + String method = request.getMethod().name(); + if (request.getMethod() != HttpMethod.POST && request.getMethod() != HttpMethod.PUT && request.getMethod() != HttpMethod.PATCH && request.getMethod() != HttpMethod.GET) { + // 如果不是post(新增)、put(全量修改)、patch(部分字段修改)GET(加密返回体) 操作,则直接放行 + return chain.filter(exchange); + } + + String secretHeader = request.getHeaders().getFirst(secretProperties.getHeader()); + if(StrUtil.isEmpty(secretHeader)){ //无头部指示 + return chain.filter(exchange); + } + if(!secretHeader.equals("true")) //头部指示不为true + return chain.filter(exchange); + if(!secretProperties.getEnable()) //系统配置为前后端不加密 + return chain.filter(exchange); + for(String url : secretProperties.getIgnoreUrls()){ //白名单剔除 + if(request.getURI().getPath().contains(url)) + return chain.filter(exchange); + } + + aesKey = exchange.getRequest().getHeaders().getFirst("frontSecKEY");//前端通过header 传递的aes key + try { + //使用rsa解密 key + aesKey = RSAUtils.decrypt(aesKey, secretProperties.getPrivateKey()); + }catch (Exception e){ + log.error("解密前端密钥失败:"+e.getMessage()); + throw NotFoundException.create(true, "Unable to find instance for " + request.getURI().getHost()); + } + // 继续 filter 过滤 + if(method.equals(HttpMethod.POST.name()) || method.equals(HttpMethod.PUT.name())) { + return filterWithRequestBody(exchange, chain); + } + return filterWithoutRequestBody(exchange, chain); //get method 不拦截request + } + + private Mono filterWithRequestBody(ServerWebExchange exchange, GatewayFilterChain chain) { + // 设置 Request Body 读取时,设置到网关日志 + // 此处 codecConfigurer.getReaders() 的目的,是解决 spring.codec.max-in-memory-size 不生效 + ServerRequest serverRequest = ServerRequest.create(exchange, codecConfigurer.getReaders()); + Mono modifiedBody = serverRequest.bodyToMono(String.class).flatMap(body -> { + //**解码 + try { + String _body = body.substring(1, body.length() - 1); + String originalQuery = AESUtils.decrypt(_body, aesKey); + + bodyDto.setRequestBody(originalQuery); + return Mono.just(originalQuery); + }catch (Exception e){ + + } + return Mono.just(body); + }); + + + // 创建 BodyInserter 对象 + BodyInserter, ReactiveHttpOutputMessage> bodyInserter = BodyInserters.fromPublisher(modifiedBody, String.class); + // 创建 CachedBodyOutputMessage 对象 + HttpHeaders headers = new HttpHeaders(); + headers.putAll(exchange.getRequest().getHeaders()); + // the new content type will be computed by bodyInserter + // and then set in the request decorator + headers.remove(HttpHeaders.CONTENT_LENGTH); // 移除 + CachedBodyOutputMessage outputMessage = new CachedBodyOutputMessage(exchange, headers); + // 通过 BodyInserter 将 Request Body 写入到 CachedBodyOutputMessage 中 + return bodyInserter.insert(outputMessage, new BodyInserterContext()).then(Mono.defer(() -> { + // 包装 Request,用于缓存 Request Body + ServerHttpRequest decoratedRequest = requestDecorate(exchange, headers, outputMessage); + // 包装 Response,用于记录 Response Body + ServerHttpResponseDecorator decoratedResponse = encodeResponse(exchange, bodyDto); + return chain.filter(exchange.mutate().request(decoratedRequest).response(decoratedResponse).build()); + })); + } + + /** + * 加密响应包 + * 通过 DataBufferFactory 解决响应体分段传输问题。 + * @param exchange + * @param bodyDto + * @return + */ + private ServerHttpResponseDecorator encodeResponse(ServerWebExchange exchange, BodyDto bodyDto) { + ServerHttpResponse response = exchange.getResponse(); + return new ServerHttpResponseDecorator(response) { + + @Override + public Mono writeWith(Publisher body) { + if (body instanceof Flux) { + DataBufferFactory bufferFactory = response.bufferFactory(); + // 获取响应类型,如果是 json 就打印 + String originalResponseContentType = exchange.getAttribute(ServerWebExchangeUtils.ORIGINAL_RESPONSE_CONTENT_TYPE_ATTR); + if (StringUtils.isNotBlank(originalResponseContentType) + && originalResponseContentType.contains("application/json")) { + Flux fluxBody = Flux.from(body); + return super.writeWith(fluxBody.buffer().map(dataBuffers -> { + // 设置 response body 到网关日志 + byte[] content = readContent(dataBuffers); + String responseResult = new String(content, StandardCharsets.UTF_8); + //加密 + bodyDto.setResponseBody(responseResult); + log.info("aesKey:"+aesKey); + String _content = AESUtils.encrypt(responseResult, (aesKey)); + log.info("响应整体加密:"+_content); + // 响应 + return bufferFactory.wrap(_content.getBytes()); + })); + } + } + // if body is not a flux. never got there. + return super.writeWith(body); + } + + @Override + public HttpHeaders getHeaders() { + HttpHeaders httpHeaders = getDelegate().getHeaders(); +// if(!httpHeaders.containsKey(secretProperties.getHeader())){ //此处不写也是可以的,在前端可以通过response.requst得到 +// httpHeaders.add(secretProperties.getHeader(), "true"); +// } + return httpHeaders; + } + }; + } + + private Mono filterWithoutRequestBody(ServerWebExchange exchange, GatewayFilterChain chain) { + // 包装 Response,用于记录 Response Body + ServerHttpResponseDecorator decoratedResponse = encodeResponse(exchange, bodyDto); + return chain.filter(exchange.mutate().response(decoratedResponse).build()); + } + + // ========== 参考 ModifyResponseBodyGatewayFilterFactory 中的方法 ========== + + private byte[] readContent(List dataBuffers) { + // 合并多个流集合,解决返回体分段传输 + DataBufferFactory dataBufferFactory = new DefaultDataBufferFactory(); + DataBuffer join = dataBufferFactory.join(dataBuffers); + byte[] content = new byte[join.readableByteCount()]; + join.read(content); + // 释放掉内存 + DataBufferUtils.release(join); + return content; + } + + /** + * 请求装饰器,支持重新计算 headers、body 缓存 + * + * @param exchange 请求 + * @param headers 请求头 + * @param outputMessage body 缓存 + * @return 请求装饰器 + */ + private ServerHttpRequestDecorator requestDecorate(ServerWebExchange exchange, HttpHeaders headers, CachedBodyOutputMessage outputMessage) { + return new ServerHttpRequestDecorator(exchange.getRequest()) { + + @Override + public HttpHeaders getHeaders() { + long contentLength = headers.getContentLength(); + HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.putAll(super.getHeaders()); + if (contentLength > 0) { + httpHeaders.setContentLength(contentLength); + } else { + // TODO: this causes a 'HTTP/1.1 411 Length Required' // on + // httpbin.org + httpHeaders.set(HttpHeaders.TRANSFER_ENCODING, "chunked"); + } + return httpHeaders; + } + + @Override + public Flux getBody() { + return outputMessage.getBody(); + } + }; + } +} diff --git a/yudao-gateway/src/main/java/cn/iocoder/yudao/gateway/filter/front/ParamterSecretProperties.java b/yudao-gateway/src/main/java/cn/iocoder/yudao/gateway/filter/front/ParamterSecretProperties.java new file mode 100755 index 000000000..ff3f02322 --- /dev/null +++ b/yudao-gateway/src/main/java/cn/iocoder/yudao/gateway/filter/front/ParamterSecretProperties.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.gateway.filter.front; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.Set; + +/** + * 参数加密配置属性类 gareway globalfilter没起作用 + * 请求需要带上 ENCPARAMTER=true 的请求头,才会进行参数加密 + * @author atuchina + */ +@ConfigurationProperties(prefix = "yudao.front.secret") +@Data +public class ParamterSecretProperties { + /** + * 参数加是否开启 + */ + private static final Boolean ENABLE_DEFAULT = true; + + /** + * 是否开启 + */ + private Boolean enable = ENABLE_DEFAULT; + + /** + * 需要忽略参数加密的请求 + * + * 这里可配置部分请求是不需要加密的 + */ + private Set ignoreUrls = Collections.emptySet(); + + private String header = "ENCPARAMTER"; // 请求头名称 + + @NotNull(message = "前后端参数加密 密钥 不能为空") + private String privateKey; // 加密秘钥 +} diff --git a/yudao-gateway/src/main/java/cn/iocoder/yudao/gateway/filter/logging/AccessLogFilter.java b/yudao-gateway/src/main/java/cn/iocoder/yudao/gateway/filter/logging/AccessLogFilter.java index b01343f8d..e4eea5aa4 100644 --- a/yudao-gateway/src/main/java/cn/iocoder/yudao/gateway/filter/logging/AccessLogFilter.java +++ b/yudao-gateway/src/main/java/cn/iocoder/yudao/gateway/filter/logging/AccessLogFilter.java @@ -98,7 +98,7 @@ public class AccessLogFilter implements GlobalFilter, Ordered { @Override public int getOrder() { - return Ordered.HIGHEST_PRECEDENCE; + return Ordered.HIGHEST_PRECEDENCE+99; } @Override diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/user/vo/user/UserSaveReqVO.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/user/vo/user/UserSaveReqVO.java index 7cadd243f..651370187 100644 --- a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/user/vo/user/UserSaveReqVO.java +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/user/vo/user/UserSaveReqVO.java @@ -23,7 +23,7 @@ public class UserSaveReqVO { @Schema(description = "用户账号", requiredMode = Schema.RequiredMode.REQUIRED, example = "yudao") @NotBlank(message = "用户账号不能为空") - @Pattern(regexp = "^[a-zA-Z0-9]$", message = "用户账号由 数字、字母 组成") + @Pattern(regexp = "^[A-Za-z0-9]+$", message = "用户账号由 数字、字母 组成") @Size(min = 4, max = 30, message = "用户账号长度为 4-30 个字符") @DiffLogField(name = "用户账号") private String username;