SpringBoot解决CORS问题

1,592 阅读7分钟

写在前面的话

在做前后端分离的开发或者前端调用第三方平台的接口时经常会遇到跨域的问题,前端总是希望能够通过各种方法解决跨域的问题。但事实上跨域问题是安全问题。这篇文章将会讲解一些为什么会有跨域问题,并提供一个方便的解决方法。为了阅读的流畅,相关的参考链接均会在文章末尾给出。本文使用的springboot版本为2.1.6.RELEASE,相应的spring版本为5.1.8.RELEASE

跨域问题的产生

跨域问题的产生是因为浏览器的同源策略。同源策略将协议+域名+端口构成的三元作为一个整体,只有三者均相同的情况下才属于一个源。跨域问题也就是不同源之间访问导致的问题。

同源策略限制了从同一个源加载的文档或脚本如何与来自另一个源的资源进行交互。这是一个用于隔离潜在恶意文件的重要安全机制。

浏览器的同源策略 @developer.mozilla.org

下表给出了相对http://store.company.com/dir/page.html同源检测的示例:

URL结果原因
http://store.company.com/dir2/other.html成功只有路径不同
http://store.company.com/dir/inner/another.html成功只有路径不同
https://store.company.com/secure.html失败不同协议 ( https和http )
http://store.company.com:81/dir/etc.html失败不同端口 ( http:// 80是默认的)
http://news.company.com/dir/other.html失败不同域名 ( news和store )

对于跨域的请求,服务器可以接受到请求,但浏览器不会出来请求的返回结果。

在浏览器中打开本地的一个html文件,在客户端中输入一下的内容可以模拟跨域的请求。(为了防止因为https的限制而无法发送请求,可以自己启动一个前端服务,然后再进行试验。)

var xhttp = new XMLHttpRequest();
xhttp.open("GET", "http://192.168.20.185:8080/users/12345678", true);
xhttp.send();

跨域资源共享 CORS

CORS是一个W3C标准,全称是"跨域资源共享"(Cross-origin resource sharing)。

它允许浏览器向跨源服务器,发出XMLHttpRequest请求,从而克服了AJAX只能同源使用的限制。

-- 前端的辅助配置

Header值示例描述
Access-Control-Allow-Credentialstrue是否允许发送Cookie,默认false
Access-Control-Allow-HeadersAuthorization,Content-Type,X-Requested-With,accept,Origin,Access-Control-Request-Method,Access-Control-Request-Headers后端接受的请求头。除了Accept,Accept-Language,Content-Language,Last-Event-ID和Content-Type外的附加请求头
Access-Control-Allow-MethodsGET,POST,HEAD,OPTIONS,PUT,DELETE,PATCH后端接受的请求方法。除了HEAD,GET,POST外的请求方法
Access-Control-Allow-Originhttp://localhost:4000请求的来源,一般为当前页面所在的源。要么为准确值,要么为*.
Access-Control-Expose-HeadersAccess-Control-Allow-Origin,Access-Control-Allow-Credentials暴露给客户端的请求头。除了Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma之外的附加响应头
Access-Control-Max-Age86400预检请求有效时长,单位为秒
  • 当Access-Control-Allow-Credentials为true时,不可以设置Access-Control-Allow-Origin为*
  • 减少预检请求(Option) 通过延长预检请求的有效期,可以减少对同一个源的Option请求的数量。如设置为86400,则24小时内无需在对同一个源发送Option请求。

Spring Web解决方法

通过过滤器处理请求,对origin进行判断,并添加必要的Headers。

@CrossOrigin

范围:单个类 单个Path

@RestController
@RequestMapping("/account")
public class AccountController {

    @CrossOrigin
    @GetMapping("/{id}")
    public Account retrieve(@PathVariable Long id) {
        // ...
    }
}

可类级别配置,也可方法级别配置。默认:

  • 所有origins
  • 所有headers
  • 所有http方法

@CrossOrigin 支持各个值的配置。@CrossOrigin 虽然提供了简单的配置,但需要重复为不同的类和方法进行配置,重复麻烦。如果对于某些请求有特定的配置需要可以使用。

注解@CrossOrigin会成为CorsConfiguration的一部分,可与WebMvcConfigurer#addCorsMappings(CorsRegistry)一起使用,为并列关系。

WebMvcConfigurer#addCorsMappings(CorsRegistry)

范围:全局 单个Path

@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {

        registry.addMapping("/api/**")
            .allowedOrigins("https://domain1.com, https://domain2.com")
            .allowedMethods("PUT", "DELETE")
            .allowedHeaders("header1", "header2", "header3")
            .exposedHeaders("header1", "header2")
            .allowCredentials(true).maxAge(3600);

        // Add more mappings...
    }
}

效果相同的XML配置:

<mvc:cors>

    <mvc:mapping path="/api/**"
        allowed-origins="https://domain1.com, https://domain2.com"
        allowed-methods="PUT,DELETE"
        allowed-headers="header1, header2, header3"
        exposed-headers="header1, header2" allow-credentials="true"
        max-age="3600" />

</mvc:cors>

真的很喜欢用JavaConfig进行配置,灵活方便。在WebConfig.java的实现中,可以设置一个CorsPropertiesList.java类来做将配置移到.properties配置文件中,可以得到如下的实现:

@EnableConfigurationProperties
@Configuration
public class CorsConfig {

    /**
     * 可与 @CrossOrigin 联用
     */
    @Configuration
    @EnableWebMvc
    @ConditionalOnProperty(prefix = "web.config", name = "cors", havingValue = "webMvc")
    public class WebConfig implements WebMvcConfigurer {

        @Autowired
        private CorsPropertiesList corsPropertiesList;

        @Override
        public void addCorsMappings(CorsRegistry registry) {
            System.out.println("config cors with " + corsPropertiesList.toString());
            for(CorsProperties corsProperties: corsPropertiesList.getList()) {
                addCorsMappings(registry, corsProperties);
            }
        }

        private void addCorsMappings(CorsRegistry registry, CorsProperties corsProperties) {
            for(String pathPattern: corsProperties.getPathPatterns()) {
                CorsRegistration registration = registry.addMapping(pathPattern);
                registration.allowedOrigins(corsProperties.getAllowedOrigins());
                registration.allowedMethods(corsProperties.getAllowedMethods());
                registration.allowedHeaders(corsProperties.getAllowedHeaders());
                registration.allowCredentials(corsProperties.getAllowedCredentials());
                registration.exposedHeaders(corsProperties.getExposedHeaders());
                registration.maxAge(corsProperties.getMaxAge());
            }
        }

        ...
    }
}
@Data
@NoArgsConstructor
@Component
@ConfigurationProperties("corses")
public class CorsPropertiesList {

    private List<CorsProperties> list;

}
@Data
public class CorsProperties {
    // Ant-style path patterns
    private String[] pathPatterns;
    private String[] allowedOrigins;
    private String[] allowedMethods;
    private String[] allowedHeaders;
    private Boolean allowedCredentials;
    private String[] exposedHeaders;
    private Long maxAge;

    public void setPathPatterns(String[] pathPatterns) {
        this.pathPatterns = pathPatterns;
    }

    public void setPathPatterns(String pathPatterns) {
        this.pathPatterns = StringUtils.split(pathPatterns, ",");
    }

    public void setAllowedOrigins(String[] allowedOrigins) {
        this.allowedOrigins = allowedOrigins;
    }

    public void setAllowedOrigins(String allowedOrigins) {
        this.allowedOrigins = StringUtils.split(allowedOrigins, ",");
    }

    public void setAllowedMethods(String[] allowedMethods) {
        this.allowedMethods = allowedMethods;
    }

    public void setAllowedMethods(String allowedMethods) {
        this.allowedMethods = StringUtils.split(allowedMethods, ",");
    }

    public void setAllowedHeaders(String[] allowedHeaders) {
        this.allowedHeaders = allowedHeaders;
    }

    public void setAllowedHeaders(String allowedHeaders) {
        this.allowedHeaders = StringUtils.split(allowedHeaders, ",");
    }

    public void setExposedHeaders(String[] exposedHeaders) {
        this.exposedHeaders = exposedHeaders;
    }

    public void setExposedHeaders(String exposedHeaders) {
        this.exposedHeaders = StringUtils.split(exposedHeaders, ",");
    }
}

application.yml

# web.config.cors: sourceConfig
# web.config.cors: customFilter
# web.config.cors: corsFilterRegistration
# web.config.cors: corsFilter
web.config.cors: webMvc

corses.list:
  -
    path-patterns:
      - /**
    allowed-origins:
      - http://localhost:*
    allowed-methods: GET,POST,HEAD,OPTIONS,PUT,DELETE,PATCH
    allowed-headers:
      - Authorization
      - Content-Type
      - X-Requested-With
      - accept,Origin
      - Access-Control-Request-Method
      - Access-Control-Request-Headers
    allowed-credentials: true
    exposed-headers: Access-Control-Allow-Origin,Access-Control-Allow-Credentials
    max-age: 86400

通过该方法的配置,可以实现全局的跨域设置,但无法修改到对origin的判断规则,比如无法实现实现对一个域名的子域名或者一个ip的任意端口的检验。

使用Spring的CrosFilter

@Bean
public CorsFilter corsFilter() {
    CorsConfiguration config = new CorsConfiguration();

    // Possibly...
    // config.applyPermitDefaultValues()

    config.setAllowCredentials(true);
    config.addAllowedOrigin("https://domain1.com");
    config.addAllowedHeader("*");
    config.addAllowedMethod("*");

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", config);

    CorsFilter filter = new CorsFilter(source);
}

到这里已经可以完成全局CORS的配置了。为了能够使用AntPathMatcher匹配origin,可以重写CorsConfiguration#checkOrigin(String)方法。

package io.gitlab.donespeak.tutorial.cors.config.support;

import org.springframework.lang.Nullable;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.ObjectUtils;
import org.springframework.util.PathMatcher;
import org.springframework.util.StringUtils;
import org.springframework.web.cors.CorsConfiguration;

/**
 * @date 2019/12/03 00:04
 */
public class AntPathMatcherCorsConfiguration extends CorsConfiguration {

    private PathMatcher pathMatcher = new AntPathMatcher();

    @Nullable
    @Override
    public String checkOrigin(@Nullable String requestOrigin) {
        System.out.println(requestOrigin);
        if (!StringUtils.hasText(requestOrigin)) {
            return null;
        }
        if (ObjectUtils.isEmpty(this.getAllowedOrigins())) {
            return null;
        }

        if (this.getAllowedOrigins().contains(ALL)) {
            if (!Boolean.TRUE.equals(this.getAllowCredentials())) {
                // ALL 和 TRUE不是不能同时出现吗?
                return ALL;
            }
            else {
                return requestOrigin;
            }
        }

        String lowcaseRequestOrigin = requestOrigin.toLowerCase();
        for (String allowedOrigin : this.getAllowedOrigins()) {
            System.out.println(allowedOrigin + ": " + pathMatcher.match(allowedOrigin.toLowerCase(), lowcaseRequestOrigin));
            if (pathMatcher.match(allowedOrigin.toLowerCase(), lowcaseRequestOrigin)) {
                return requestOrigin;
            }
        }
        return null;
    }
}

相应的可配置CorsFilter如下:

@EnableConfigurationProperties@Configurationpublic class CorsConfig {    /**     * 不可与 @CrossOrigin 联用     */    @Configuration    @ConditionalOnProperty(prefix = "web.config", name = "cors", havingValue = "corsFilterRegistration")    public static class CorsFilterRegistrationConfig {        @Bean        public FilterRegistrationBean corsFilterRegistration(CorsPropertiesList corsPropertiesList) {            System.out.println("create bean FilterRegistrationBean with " + corsPropertiesList);            FilterRegistrationBean bean = new FilterRegistrationBean(createCorsFilter(corsPropertiesList));            bean.setOrder(0);            return bean;        }    }    /**     * 不可与 @CrossOrigin 联用     */    @Configuration    @ConditionalOnProperty(prefix = "web.config", name = "cors", havingValue = "corsFilter")    public static class CorsFilterConfig {        @Bean(name = "corsFilter")        public CorsFilter corsFilter(CorsPropertiesList corsPropertiesList) {            System.out.println("init bean CorsFilter with " + corsPropertiesList);            return createCorsFilter(corsPropertiesList);        }    }    private static CorsFilter createCorsFilter(CorsPropertiesList corsPropertiesList) {        return new CorsFilter(createCorsConfigurationSource(corsPropertiesList));    }    private static CorsConfigurationSource createCorsConfigurationSource(CorsPropertiesList corsPropertiesList) {        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();        for(CorsProperties corsProperties: corsPropertiesList.getList()) {            // 路径也是 AntPathMarcher            for(String pathPattern: corsProperties.getPathPatterns()) {                source.registerCorsConfiguration(pathPattern, toCorsConfiguration(corsProperties));            }        }        return source;    }    private static CorsConfiguration toCorsConfiguration(CorsProperties corsProperties) {        CorsConfiguration corsConfig = new AntPathMatcherCorsConfiguration();        corsConfig.setAllowedOrigins(Arrays.asList(corsProperties.getAllowedOrigins()));        corsConfig.setAllowedMethods(Arrays.asList(corsProperties.getAllowedMethods()));        corsConfig.setAllowedHeaders(Arrays.asList(corsProperties.getAllowedHeaders()));        corsConfig.setAllowCredentials(corsProperties.getAllowedCredentials());        corsConfig.setMaxAge(corsProperties.getMaxAge());        corsConfig.setExposedHeaders(Arrays.asList(corsProperties.getExposedHeaders()));        return corsConfig;    }    ...}

通过@Configuration注解的配置类,添加的CorsFilter实例无法和@CrossOrigin一起使用,一旦CorsFilter校验不通过,请求就会被Rejected。

直接使用 CorsConfigurationSource

public class CorsConfig {    @Configuration    @ConditionalOnProperty(prefix = "web.config", name = "cors", havingValue = "sourceConfig")    public static class CorsConfigurationSourceConfig {                @Bean        public CorsConfigurationSource corsConfigurationSource(CorsPropertiesList corsPropertiesList) {            System.out.println("init bean CorsConfigurationSource with " + corsPropertiesList);            return createCorsConfigurationSource(corsPropertiesList);        }    }    ...}

自定义 Filter

当然,你也可以自定义一个Filter来处理CORS,但既然有CorsFilter了,除非有什么特别的情况,否则无需自己实现一个Filter来处理CORS问题。如下给出一个大概的思路,可自行完善拓展。

package io.gitlab.donespeak.tutorial.cors.filter;import io.gitlab.donespeak.tutorial.cors.config.properties.CorsProperties;import io.gitlab.donespeak.tutorial.cors.config.properties.CorsPropertiesList;...@Slf4jpublic class CustomCorsFilter implements Filter {    private CorsPropertiesList corsPropertiesList;    private AntPathMatcher antPathMatcher = new AntPathMatcher();    private static final String ALL = "*";    private Map<String, CorsProperties> corsPropertiesMap = new LinkedHashMap<>();    public CustomCorsFilter(CorsPropertiesList corsPropertiesList) {        this.corsPropertiesList = corsPropertiesList;        for(CorsProperties corsProperties: corsPropertiesList.getList()) {            for(String pathPattern: corsProperties.getPathPatterns()) {                corsPropertiesMap.put(pathPattern, corsProperties);            }        }    }    @Override    public void init(FilterConfig filterConfig) throws ServletException {    }    @Override    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)        throws IOException, ServletException {        HttpServletRequest servletRequest = (HttpServletRequest)request;        HttpServletResponse servletResponse = (HttpServletResponse)response;        String origin = servletRequest.getHeader("Origin");        List<CorsProperties> corsPropertiesList = getCorsPropertiesMatch(servletRequest.getServletPath());        if(log.isDebugEnabled()) {            log.debug("Try to check origin: " + origin);        }        CorsProperties originPassCorsProperties = null;        for(CorsProperties corsProperties: corsPropertiesList) {            if (corsProperties != null && isOriginAllowed(origin, corsProperties.getAllowedOrigins())) {                originPassCorsProperties = corsProperties;                break;            }        }        if (originPassCorsProperties != null) {            servletResponse.setHeader("Access-Control-Allow-Origin", origin);            servletResponse.setHeader("Access-Control-Allow-Methods",                StringUtils.arrayToCommaDelimitedString(originPassCorsProperties.getAllowedMethods()));            servletResponse.setHeader("Access-Control-Allow-Headers",                StringUtils.arrayToCommaDelimitedString(originPassCorsProperties.getAllowedHeaders()));            servletResponse.addHeader("Access-Control-Expose-Headers",                StringUtils.arrayToCommaDelimitedString(originPassCorsProperties.getExposedHeaders()));            servletResponse.addHeader("Access-Control-Allow-Credentials",                String.valueOf(originPassCorsProperties.getAllowedCredentials()));            servletResponse.setHeader("Access-Control-Max-Age", String.valueOf(originPassCorsProperties.getMaxAge()));        } else {            servletResponse.setHeader("Access-Control-Allow-Origin", null);        }        if ("OPTIONS".equals(servletRequest.getMethod())) {            servletResponse.setStatus(HttpServletResponse.SC_OK);        } else {            chain.doFilter(servletRequest, servletResponse);        }    }    private List<CorsProperties> getCorsPropertiesMatch(String path) {        List<CorsProperties> corsPropertiesList = new ArrayList<>();       for(Map.Entry<String, CorsProperties> entry: corsPropertiesMap.entrySet()) {           if(antPathMatcher.match(entry.getKey(), path)) {               corsPropertiesList.add(entry.getValue());           }       }       return corsPropertiesList;    }    private boolean isOriginAllowed(String origin, String[] allowedOrigins) {        if (StringUtils.isEmpty(origin) || (allowedOrigins == null || allowedOrigins.length == 0)) {            return false;        }        for (String allowedOrigin : allowedOrigins) {            if (ALL.equals(allowedOrigin) || isOriginMatch(origin, allowedOrigin)) {                return true;            }        }        return false;    }    private boolean isOriginMatch(String origin, String originPattern) {        return antPathMatcher.match(originPattern, origin);    }}

相关的配置如下:

@EnableConfigurationProperties
@Configuration
public class CorsConfig {

    /**
     * 可与 @CrossOrigin 联用
     */
    @Configuration
    @ConditionalOnProperty(prefix = "web.config", name = "cors", havingValue = "customFilter")
    public static class CustomCorsFilterConfig {

        @Bean
        public CustomCorsFilter customCorsFilter(CorsPropertiesList corsPropertiesList) {
            System.out.println("init bean CustomCorsFilter with " + corsPropertiesList);
            return new CustomCorsFilter(corsPropertiesList);
        }
    }
}

因为@CrossOrigin并非通过Filter进行的处理,这里的CustomCorsFilter仅仅做添加Header的操作,如果没有校验成功,不回结束FilterChain,因而可以和@CrossOrigin一起使用。

拓展

获取第三方平台数据

如果想要获取第三方平台的数据,可以采用服务器代理的方式进行处理。因为直接干涉第三平台的服务器的配置,而且同源策略也只有在浏览器中有效。因而可以将自己的服务器访问第三方平台的数据再返回给自己的客户端。

参考和其他

源码见:tutorial/cors