Java SpringCloud SpringCloud Gateway CROS

场景

在 SpringCloud 项目中,前后端分离目前很常见,在调试时,会遇到两种情况的跨域:

| 前端页面通过不同域名或IP访问微服务的后台

例如前端人员会在本地起HttpServer 直连后台开发本地起的服务,此时,如果不加任何配置,前端页面的请求会被浏览器跨域限制拦截,所以,业务服务常常会添加如下代码设置全局跨域:

  1. @Bean
  2. public CorsFilter corsFilter() {
  3. logger.debug("CORS限制打开");
  4. CorsConfiguration config = new CorsConfiguration();
  5. # 仅在开发环境设置为*
  6. config.addAllowedOrigin("*");
  7. config.addAllowedHeader("*");
  8. config.addAllowedMethod("*");
  9. config.setAllowCredentials(true);
  10. UrlBasedCorsConfigurationSource configSource = new UrlBasedCorsConfigurationSource();
  11. configSource.registerCorsConfiguration("/**", config);
  12. return new CorsFilter(configSource);
  13. }

| 前端页面通过不同域名或IP访问SpringCloud Gateway

例如前端人员在本地起HttpServer直连服务器的Gateway进行调试。此时,同样会遇到跨域。需要在Gateway的配置文件中增加:

  1. spring:
  2. cloud:
  3. gateway:
  4. globalcors:
  5. cors-configurations:
  6. # 仅在开发环境设置为*
  7. '[/**]':
  8. allowedOrigins: "*"
  9. allowedHeaders: "*"
  10. allowedMethods: "*"

那么,此时直连微服务和网关的跨域问题都解决了,是不是很完美?
No~ 问题来了,前端仍然会报错:“不允许有多个’Access-Control-Allow-Origin’ CORS头”

  1. Access to XMLHttpRequest at 'http://192.168.2.137:8088/api/two' from origin 'http://localhost:3200' has been blocked by CORS policy:
  2. The 'Access-Control-Allow-Origin' header contains multiple values '*, http://localhost:3200', but only one is allowed.

仔细查看返回的响应头,里面包含了两份Access-Control-Allow-Origin头。
用客户端版的PostMan做一个模拟,在请求里设置头:Origin : * ,查看返回结果的头:
不能用Chrome插件版,由于浏览器的限制,插件版设置Origin的Header是无效的
SpringCloud Gateway CORS方案 - 图1
发现问题了:Vary 和 Access-Control-Allow-Origin 两个头重复了两次,其中浏览器对后者有唯一性限制!

分 析

Spring Cloud Gateway是基于SpringWebFlux的,所有web请求首先是交给DispatcherHandler进行处理的,将HTTP请求交给具体注册的handler去处理。
Spring Cloud Gateway进行请求转发,是在配置文件里配置路由信息,一般都是用url predicates模式,对应的就是RoutePredicateHandlerMapping 。所以,DispatcherHandler会把请求交给 RoutePredicateHandlerMapping
SpringCloud Gateway CORS方案 - 图2
**RoutePredicateHandlerMapping.getHandler(ServerWebExchange exchange)** 方法,默认提供者是其父类 **AbstractHandlerMapping**

  1. @Override
  2. public Mono<Object> getHandler(ServerWebExchange exchange) {
  3. return getHandlerInternal(exchange).map(handler -> {
  4. if (logger.isDebugEnabled()) {
  5. logger.debug(exchange.getLogPrefix() + "Mapped to " + handler);
  6. }
  7. ServerHttpRequest request = exchange.getRequest();
  8. // 可以看到是在这一行就进行CORS判断,两个条件:
  9. // 1. 是否配置了CORS,如果不配的话,默认是返回false的
  10. // 2. 或者当前请求是OPTIONS请求,且头里包含ORIGIN和ACCESS_CONTROL_REQUEST_METHOD
  11. if (hasCorsConfigurationSource(handler) || CorsUtils.isPreFlightRequest(request)) {
  12. CorsConfiguration config = (this.corsConfigurationSource != null ? this.corsConfigurationSource.getCorsConfiguration(exchange) : null);
  13. CorsConfiguration handlerConfig = getCorsConfiguration(handler, exchange);
  14. config = (config != null ? config.combine(handlerConfig) : handlerConfig);
  15. //此处交给DefaultCorsProcessor去处理了
  16. if (!this.corsProcessor.process(config, exchange) || CorsUtils.isPreFlightRequest(request)) {
  17. return REQUEST_HANDLED_HANDLER;
  18. }
  19. }
  20. return handler;
  21. });
  22. }

网上有些关于修改Gateway的CORS设定的方式,是跟前面SpringBoot一样,实现一个CorsWebFilter的Bean,靠写代码提供 CorsConfiguration ,而不是修改Gateway的配置文件。其实本质,都是将配置交给corsProcessor去处理,殊途同归。但靠配置解决永远比hard code来的优雅。

该方法把Gateway里定义的所有的 GlobalFilter 加载进来,作为handler返回,但在返回前,先进行CORS校验,获取配置后,交给corsProcessor去处理,即DefaultCorsProcessor类。
看下**DefaultCorsProcessor****process**方法

  1. @Override
  2. public boolean process(@Nullable CorsConfiguration config, ServerWebExchange exchange) {
  3. ServerHttpRequest request = exchange.getRequest();
  4. ServerHttpResponse response = exchange.getResponse();
  5. HttpHeaders responseHeaders = response.getHeaders();
  6. List<String> varyHeaders = responseHeaders.get(HttpHeaders.VARY);
  7. if (varyHeaders == null) {
  8. // 第一次进来时,肯定是空,所以加了一次VERY的头,包含ORIGIN, ACCESS_CONTROL_REQUEST_METHOD和ACCESS_CONTROL_REQUEST_HEADERS
  9. responseHeaders.addAll(HttpHeaders.VARY, VARY_HEADERS);
  10. }
  11. else {
  12. for (String header : VARY_HEADERS) {
  13. if (!varyHeaders.contains(header)) {
  14. responseHeaders.add(HttpHeaders.VARY, header);
  15. }
  16. }
  17. }
  18. if (!CorsUtils.isCorsRequest(request)) {
  19. return true;
  20. }
  21. if (responseHeaders.getFirst(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN) != null) {
  22. logger.trace("Skip: response already contains \"Access-Control-Allow-Origin\"");
  23. return true;
  24. }
  25. boolean preFlightRequest = CorsUtils.isPreFlightRequest(request);
  26. if (config == null) {
  27. if (preFlightRequest) {
  28. rejectRequest(response);
  29. return false;
  30. }
  31. else {
  32. return true;
  33. }
  34. }
  35. return handleInternal(exchange, config, preFlightRequest);
  36. }
  37. // 在这个类里进行实际的CORS校验和处理
  38. protected boolean handleInternal(ServerWebExchange exchange,
  39. CorsConfiguration config, boolean preFlightRequest) {
  40. ServerHttpRequest request = exchange.getRequest();
  41. ServerHttpResponse response = exchange.getResponse();
  42. HttpHeaders responseHeaders = response.getHeaders();
  43. String requestOrigin = request.getHeaders().getOrigin();
  44. String allowOrigin = checkOrigin(config, requestOrigin);
  45. if (allowOrigin == null) {
  46. logger.debug("Reject: '" + requestOrigin + "' origin is not allowed");
  47. rejectRequest(response);
  48. return false;
  49. }
  50. HttpMethod requestMethod = getMethodToUse(request, preFlightRequest);
  51. List<HttpMethod> allowMethods = checkMethods(config, requestMethod);
  52. if (allowMethods == null) {
  53. logger.debug("Reject: HTTP '" + requestMethod + "' is not allowed");
  54. rejectRequest(response);
  55. return false;
  56. }
  57. List<String> requestHeaders = getHeadersToUse(request, preFlightRequest);
  58. List<String> allowHeaders = checkHeaders(config, requestHeaders);
  59. if (preFlightRequest && allowHeaders == null) {
  60. logger.debug("Reject: headers '" + requestHeaders + "' are not allowed");
  61. rejectRequest(response);
  62. return false;
  63. }
  64. //此处添加了AccessControllAllowOrigin的头
  65. responseHeaders.setAccessControlAllowOrigin(allowOrigin);
  66. if (preFlightRequest) {
  67. responseHeaders.setAccessControlAllowMethods(allowMethods);
  68. }
  69. if (preFlightRequest && !allowHeaders.isEmpty()) {
  70. responseHeaders.setAccessControlAllowHeaders(allowHeaders);
  71. }
  72. if (!CollectionUtils.isEmpty(config.getExposedHeaders())) {
  73. responseHeaders.setAccessControlExposeHeaders(config.getExposedHeaders());
  74. }
  75. if (Boolean.TRUE.equals(config.getAllowCredentials())) {
  76. responseHeaders.setAccessControlAllowCredentials(true);
  77. }
  78. if (preFlightRequest && config.getMaxAge() != null) {
  79. responseHeaders.setAccessControlMaxAge(config.getMaxAge());
  80. }
  81. return true;
  82. }

可以看到,在DefaultCorsProcessor 中,根据在appliation.yml 中的配置,给Response添加了 Vary 和 Access-Control-Allow-Origin 的头。
SpringCloud Gateway CORS方案 - 图3
再接下来就是进入各个**GlobalFilter**进行处理了,其中**NettyRoutingFilter** 是负责实际将请求转发给后台微服务,并获取Response的,重点看下代码中filter的处理结果的部分:
SpringCloud Gateway CORS方案 - 图4其中以下几种header会被过滤掉的:
SpringCloud Gateway CORS方案 - 图5
很明显,在图里的第3步中,如果后台服务返回的header里有 VaryAccess-Control-Allow-Origin ,这时由于是putAll,没有做任何去重就加进去了,必然会重复,看看DEBUG结果验证一下:
SpringCloud Gateway CORS方案 - 图6验证了前面的发现。

解决方案

解决的方案有两种:

| 利用DedupeResponseHeader配置

  1. spring:
  2. cloud:
  3. gateway:
  4. globalcors:
  5. cors-configurations:
  6. '[/**]':
  7. allowedOrigins: "*"
  8. allowedHeaders: "*"
  9. allowedMethods: "*"
  10. default-filters:
  11. - DedupeResponseHeader=Vary Access-Control-Allow-Origin Access-Control-Allow-Credentials, RETAIN_FIRST

DedupeResponseHeader加上以后会启用DedupeResponseHeaderGatewayFilterFactory 在其中,dedupe方法可以按照给定策略处理值。

  1. private void dedupe(HttpHeaders headers, String name, Strategy strategy) {
  2. List<String> values = headers.get(name);
  3. if (values == null || values.size() <= 1) {
  4. return;
  5. }
  6. switch (strategy) {
  7. // 只保留第一个
  8. case RETAIN_FIRST:
  9. headers.set(name, values.get(0));
  10. break;
  11. // 保留最后一个
  12. case RETAIN_LAST:
  13. headers.set(name, values.get(values.size() - 1));
  14. break;
  15. // 去除值相同的
  16. case RETAIN_UNIQUE:
  17. headers.put(name, values.stream().distinct().collect(Collectors.toList()));
  18. break;
  19. default:
  20. break;
  21. }
  22. }

如果请求中设置的Origin的值与自己设置的是同一个,例如生产环境设置的都是自己的域名xxx.com或者开发测试环境设置的都是*(浏览器中是无法设置Origin的值,设置了也不起作用,浏览器默认是当前访问地址),那么可以选用RETAIN_UNIQUE策略,去重后返回到前端。
如果请求中设置的Oringin的值与自己设置的不是同一个,RETAIN_UNIQUE策略就无法生效,比如“*”和 “xxx.com”是两个不一样的Origin,最终还是会返回两个Access-Control-Allow-Origin 的头。此时,看代码里,response的header里,先加入的是自己配置的Access-Control-Allow-Origin的值,所以,可以将策略设置为RETAIN_FIRST,只保留自己设置的。
大多数情况下,想要返回的是自己设置的规则,所以直接使用RETAIN_FIRST 即可。实际上,DedupeResponseHeader 可以针对所有头,做重复的处理。

| 手动写一个 CorsResponseHeaderFilterGlobalFilter 去修改Response中的头

此处有两个地方要注意:

  1. @Component
  2. public class CorsResponseHeaderFilter implements GlobalFilter, Ordered {
  3. private static final Logger logger = LoggerFactory.getLogger(CorsResponseHeaderFilter.class);
  4. private static final String ANY = "*";
  5. @Override
  6. public int getOrder() {
  7. // 指定此过滤器位于NettyWriteResponseFilter之后
  8. // 即待处理完响应体后接着处理响应头
  9. return NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER + 1;
  10. }
  11. @Override
  12. @SuppressWarnings("serial")
  13. public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
  14. return chain.filter(exchange).then(Mono.fromRunnable(() -> {
  15. exchange.getResponse().getHeaders().entrySet().stream()
  16. .filter(kv -> (kv.getValue() != null && kv.getValue().size() > 1))
  17. .filter(kv -> (kv.getKey().equals(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)
  18. || kv.getKey().equals(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS)
  19. || kv.getKey().equals(HttpHeaders.VARY)))
  20. .forEach(kv ->
  21. {
  22. // Vary只需要去重即可
  23. if(kv.getKey().equals(HttpHeaders.VARY))
  24. kv.setValue(kv.getValue().stream().distinct().collect(Collectors.toList()));
  25. else{
  26. List<String> value = new ArrayList<>();
  27. if(kv.getValue().contains(ANY)){ //如果包含*,则取*
  28. value.add(ANY);
  29. kv.setValue(value);
  30. }else{
  31. value.add(kv.getValue().get(0)); // 否则默认取第一个
  32. kv.setValue(value);
  33. }
  34. }
  35. });
  36. }));
  37. }
  38. }
  1. 根据下图可以看到,在取得返回值后,Filter的Order 值越大,越先处理Response,而真正将Response返回到前端的,是 NettyWriteResponseFilter,要想在它之前修改Response,则Order 的值必须比NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER 大。

spring-cloud-gateway-fliter-order

  1. 修改后置filter时,网上有些博客使用的是 Mono.defer去做的,这种做法,会从此filter开始,重新执行一遍它后面的其他filter,一般会添加一些认证或鉴权的 GlobalFilter,就需要在这些filter里用ServerWebExchangeUtils.isAlreadyRouted(exchange) 方法去判断是否重复执行,否则可能会执行二次重复操作,所以建议使用fromRunnable 避免这种情况。