Spring Cloud 轻松解决跨域,别再乱用了!

Posted Java技术栈

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Spring Cloud 轻松解决跨域,别再乱用了!相关的知识,希望对你有一定的参考价值。

点击关注公众号,Java干货及时送达

今天是《Spring Cloud Alibaba 微服务实战课》 1024 程序员节活动最后一天,别再错过这次的特惠价了,点击这个链接可了解、参与报名学习。

问题

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

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

@Bean
public CorsFilter corsFilter() 
    logger.debug("CORS限制打开");
    CorsConfiguration config = new CorsConfiguration();
    # 仅在开发环境设置为*
    config.addAllowedOrigin("*");
    config.addAllowedHeader("*");
    config.addAllowedMethod("*");
    config.setAllowCredentials(true);
    UrlBasedCorsConfigurationSource configSource = new UrlBasedCorsConfigurationSource();
    configSource.registerCorsConfiguration("/**", config);
    return new CorsFilter(configSource);

前端页面通过不同域名或IP访问SpringCloud Gateway,例如前端人员在本地起HttpServer直连服务器的Gateway进行调试。此时,同样会遇到跨域。需要在Gateway的配置文件中增加:

spring:
  cloud:
    gateway:
      globalcors:
        cors-configurations:
        # 仅在开发环境设置为*
          '[/**]':
            allowedOrigins: "*"
            allowedHeaders: "*"
            allowedMethods: "*"

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

Access to XMLHttpRequest at 'http://192.168.2.137:8088/api/two' from origin 'http://localhost:3200' has been blocked by CORS policy:
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是无效的

发现问题了:

VaryAccess-Control-Allow-Origin 两个头重复了两次,其中浏览器对后者有唯一性限制!最新 Spring Cloud 面试题整理好了,大家可以在Java面试库小程序在线刷题。

分析

Spring Cloud Gateway是基于SpringWebFlux的,所有web请求首先是交给DispatcherHandler进行处理的,将HTTP请求交给具体注册的handler去处理。

我们知道Spring Cloud Gateway进行请求转发,是在配置文件里配置路由信息,一般都是用url predicates模式,对应的就是RoutePredicateHandlerMapping 。所以,DispatcherHandler会把请求交给 RoutePredicateHandlerMapping.

那么,接下来看下 RoutePredicateHandlerMapping.getHandler(ServerWebExchange exchange) 方法,默认提供者是其父类 AbstractHandlerMapping

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

注:

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

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

看下DefaultCorsProcessorprocess方法:

@Override
public boolean process(@Nullable CorsConfiguration config, ServerWebExchange exchange) 

    ServerHttpRequest request = exchange.getRequest();
    ServerHttpResponse response = exchange.getResponse();
    HttpHeaders responseHeaders = response.getHeaders();

    List<String> varyHeaders = responseHeaders.get(HttpHeaders.VARY);
    if (varyHeaders == null) 
        // 第一次进来时,肯定是空,所以加了一次VERY的头,包含ORIGIN, ACCESS_CONTROL_REQUEST_METHOD和ACCESS_CONTROL_REQUEST_HEADERS
        responseHeaders.addAll(HttpHeaders.VARY, VARY_HEADERS);
    
    else 
        for (String header : VARY_HEADERS) 
            if (!varyHeaders.contains(header)) 
                responseHeaders.add(HttpHeaders.VARY, header);
            
        
    

    if (!CorsUtils.isCorsRequest(request)) 
        return true;
    

    if (responseHeaders.getFirst(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN) != null) 
        logger.trace("Skip: response already contains \\"Access-Control-Allow-Origin\\"");
        return true;
    

    boolean preFlightRequest = CorsUtils.isPreFlightRequest(request);
    if (config == null) 
        if (preFlightRequest) 
            rejectRequest(response);
            return false;
        
        else 
            return true;
        
    

    return handleInternal(exchange, config, preFlightRequest);


// 在这个类里进行实际的CORS校验和处理
protected boolean handleInternal(ServerWebExchange exchange,
                                 CorsConfiguration config, boolean preFlightRequest) 

    ServerHttpRequest request = exchange.getRequest();
    ServerHttpResponse response = exchange.getResponse();
    HttpHeaders responseHeaders = response.getHeaders();

    String requestOrigin = request.getHeaders().getOrigin();
    String allowOrigin = checkOrigin(config, requestOrigin);
    if (allowOrigin == null) 
        logger.debug("Reject: '" + requestOrigin + "' origin is not allowed");
        rejectRequest(response);
        return false;
    

    HttpMethod requestMethod = getMethodToUse(request, preFlightRequest);
    List<HttpMethod> allowMethods = checkMethods(config, requestMethod);
    if (allowMethods == null) 
        logger.debug("Reject: HTTP '" + requestMethod + "' is not allowed");
        rejectRequest(response);
        return false;
    

    List<String> requestHeaders = getHeadersToUse(request, preFlightRequest);
    List<String> allowHeaders = checkHeaders(config, requestHeaders);
    if (preFlightRequest && allowHeaders == null) 
        logger.debug("Reject: headers '" + requestHeaders + "' are not allowed");
        rejectRequest(response);
        return false;
    
    //此处添加了AccessControllAllowOrigin的头
    responseHeaders.setAccessControlAllowOrigin(allowOrigin);

    if (preFlightRequest) 
        responseHeaders.setAccessControlAllowMethods(allowMethods);
    

    if (preFlightRequest && !allowHeaders.isEmpty()) 
        responseHeaders.setAccessControlAllowHeaders(allowHeaders);
    

    if (!CollectionUtils.isEmpty(config.getExposedHeaders())) 
        responseHeaders.setAccessControlExposeHeaders(config.getExposedHeaders());
    

    if (Boolean.TRUE.equals(config.getAllowCredentials())) 
        responseHeaders.setAccessControlAllowCredentials(true);
    

    if (preFlightRequest && config.getMaxAge() != null) 
        responseHeaders.setAccessControlMaxAge(config.getMaxAge());
    

    return true;

可以看到,在DefaultCorsProcessor 中,根据我们在appliation.yml 中的配置,给Response添加了 VaryAccess-Control-Allow-Origin 的头。

再接下来就是进入各个GlobalFilter进行处理了,其中NettyRoutingFilter 是负责实际将请求转发给后台微服务,并获取Response的,重点看下代码中filter的处理结果的部分:

其中以下几种header会被过滤掉的:

很明显,在图里的第3步中,如果后台服务返回的header里有 VaryAccess-Control-Allow-Origin ,这时由于是putAll,没有做任何去重就加进去了,必然会重复,看看DEBUG结果验证一下:

验证了前面的发现。

点击关注公众号,Java干货及时送达

解决

解决的方案有两种:

1. 利用 DedupeResponseHeader 配置:

spring:
    cloud:
        gateway:
          globalcors:
            cors-configurations:
              '[/**]':
                allowedOrigins: "*"
                allowedHeaders: "*"
                allowedMethods: "*"
          default-filters:
          - DedupeResponseHeader=Vary Access-Control-Allow-Origin Access-Control-Allow-Credentials, RETAIN_FIRST

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

Spring Boot 基础就不介绍了,推荐下这个实战教程:https://github.com/javastacks/spring-boot-best-practice

private void dedupe(HttpHeaders headers, String name, Strategy strategy) 
  List<String> values = headers.get(name);
  if (values == null || values.size() <= 1) 
   return;
  
  switch (strategy) 
  // 只保留第一个
  case RETAIN_FIRST:
   headers.set(name, values.get(0));
   break;
  // 保留最后一个
  case RETAIN_LAST:
   headers.set(name, values.get(values.size() - 1));
   break;
  // 去除值相同的
  case RETAIN_UNIQUE:
   headers.put(name, values.stream().distinct().collect(Collectors.toList()));
   break;
  default:
   break;
  
 
  • 如果请求中设置的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 可以针对所有头,做重复的处理。

2. 手动写一个 CorsResponseHeaderFilterGlobalFilter 去修改Response中的头。

Spring Boot 基础就不介绍了,推荐下这个实战教程:https://github.com/javastacks/spring-boot-best-practice

@Component
public class CorsResponseHeaderFilter implements GlobalFilter, Ordered 

    private static final Logger logger = LoggerFactory.getLogger(CorsResponseHeaderFilter.class);

    private static final String ANY = "*";

    @Override
    public int getOrder() 
        // 指定此过滤器位于NettyWriteResponseFilter之后
        // 即待处理完响应体后接着处理响应头
        return NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER + 1;
    

    @Override
    @SuppressWarnings("serial")
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) 
        return chain.filter(exchange).then(Mono.fromRunnable(() -> 
            exchange.getResponse().getHeaders().entrySet().stream()
                    .filter(kv -> (kv.getValue() != null && kv.getValue().size() > 1))
                    .filter(kv -> (kv.getKey().equals(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)
                            || kv.getKey().equals(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS)
                            || kv.getKey().equals(HttpHeaders.VARY)))
                    .forEach(kv ->
                    
                        // Vary只需要去重即可
                        if(kv.getKey().equals(HttpHeaders.VARY))
                            kv.setValue(kv.getValue().stream().distinct().collect(Collectors.toList()));
                        else
                            List<String> value = new ArrayList<>();
                            if(kv.getValue().contains(ANY))  //如果包含*,则取*
                                value.add(ANY);
                                kv.setValue(value);
                            else
                                value.add(kv.getValue().get(0)); // 否则默认取第一个
                                kv.setValue(value);
                            
                        
                    );
        ));
    

此处有两个地方要注意:

1)根据下图可以看到,在取得返回值后,Filter的Order 值越大,越先处理Response,而真正将Response返回到前端的,是 NettyWriteResponseFilter, 我们要想在它之前修改Response,则Order 的值必须比NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER 大。

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

作者:EdisonXu - 徐焱飞 

来源:http://edisonxu.com/2020/10/14/spring-cloud-gateway-cors.html

End

Spring Boot 学习笔记,这个太全了!

23 种设计模式实战(很全)

Nacos 2.1.1 正式发布,真心强!

Spring Cloud Alibaba 最新重磅发布!

Stream 中的 map、peek、foreach 方法的区别?

Spring Cloud 微服务最新课程!

以上是关于Spring Cloud 轻松解决跨域,别再乱用了!的主要内容,如果未能解决你的问题,请参考以下文章

别再乱用了,这才是 @Validated 和 @Valid 的真正区别!!!

别再乱用了,这才是 @Validated 和 @Valid 的真正区别!!!

别再乱用了,这才是 @Validated 和 @Valid 的真正区别

MyBatis 别再乱用 foreach 批量插入了,5000 数据用了 14 分钟,实力劝退。。

MyBatis 别再乱用 foreach 批量插入了,5000 数据用了 14 分钟,实力劝退。。

劲爆:Java 18 正式发布,默认 UTF-8,finalize 被弃用。。别再乱用了