SpringBoot解決CORS問題

寫在前面的話

在作先後端分離的開發或者前端調用第三方平臺的接口時常常會遇到跨域的問題,前端老是但願可以經過各類方法解決跨域的問題。但事實上跨域問題是安全問題。這篇文章將會講解一些爲何會有跨域問題,並提供一個方便的解決方法。爲了閱讀的流暢,相關的參考連接均會在文章末尾給出。本文使用的springboot版本爲2.1.6.RELEASE,相應的spring版本爲5.1.8.RELEASEhtml

跨域問題的產生

跨域問題的產生是由於瀏覽器的同源策略。同源策略將協議+域名+端口構成的三元做爲一個總體,只有三者均相同的狀況下才屬於一個源。跨域問題也就是不一樣源之間訪問致使的問題。前端

同源策略限制了從同一個源加載的文檔或腳本如何與來自另外一個源的資源進行交互。這是一個用於隔離潛在惡意文件的重要安全機制。

瀏覽器的同源策略 @developer.mozilla.orgjava

下表給出了相對http://store.company.com/dir/page.html同源檢測的示例:git

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 )

對於跨域的請求,服務器能夠接受到請求,但瀏覽器不會出來請求的返回結果。web

在瀏覽器中打開本地的一個html文件,在客戶端中輸入一下的內容能夠模擬跨域的請求。(爲了防止由於https的限制而沒法發送請求,能夠本身啓動一個前端服務,而後再進行試驗。)spring

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)。segmentfault

它容許瀏覽器向跨源服務器,發出XMLHttpRequest請求,從而克服了AJAX只能同源使用的限制。後端

-- 前端的輔助配置api

Header 值示例 描述
Access-Control-Allow-Credentials true 是否容許發送Cookie,默認false
Access-Control-Allow-Headers Authorization,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-Methods GET,POST,HEAD,OPTIONS,PUT,DELETE,PATCH 後端接受的請求方法。除了HEAD,GET,POST外的請求方法
Access-Control-Allow-Origin http://localhost:4000 請求的來源,通常爲當前頁面所在的源。要麼爲準確值,要麼爲*.
Access-Control-Expose-Headers Access-Control-Allow-Origin,Access-Control-Allow-Credentials 暴露給客戶端的請求頭。除了Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma以外的附加響應頭
Access-Control-Max-Age 86400 預檢請求有效時長,單位爲秒
  • 當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
@Configuration
public 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;

...

@Slf4j
public 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

相關文章
相關標籤/搜索