詳解跨域問題出現及解決的原理

在本機使用瀏覽器訪問遠程服務器進行開發時,我們經常會碰到這種報錯信息:

XMLHttpRequest cannot load xxxxxxxx. Request header field Content-Type is not allowed by Access-Control-Allow-Headers in preflight response.

瞭解過的人一眼就看出了是出現了跨域問題。

那麼,爲什麼會出現這種情況呢?

出於對瀏覽器安全的考量,你在載入其他網站的資源時會觸發跨域資源共享CORS這一種機制,它使用額外的 HTTP 頭來告訴瀏覽器 讓運行在一個 origin (domain) 上的Web應用被准許訪問來自不同源服務器上的指定的資源。當一個資源從與該資源本身所在的服務器不同的域、協議或端口請求一個資源時,資源會發起一個跨域 HTTP 請求。它允許瀏覽器向跨源服務器,發出XMLHttpRequest請求,從而克服了AJAX只能同源使用的限制。

跨域資源共享(CORS)機制允許 Web 應用服務器進行跨域訪問控制,從而使跨域數據傳輸得以安全進行。現代瀏覽器支持在 API 容器中使用 CORS,以降低跨域 HTTP 請求所帶來的風險。

同源的含義是:A網頁設置的 Cookie等消息,B網頁不能打開,除非這兩個網頁"同源"。所謂"同源"指的是:同協議、同域名、同端口

如果非同源,以下行爲將會受到限制:

(1) Cookie、LocalStorage 和 IndexDB 無法讀取。
(2) DOM 無法獲得。
(3) AJAX 請求不能發送。

利用Postman進行開發或者後端獲取http數據的話,是不會發送OPTIONS進行請求的,即是不會出現跨域問題。跨域問題只有在瀏覽器纔會出現,javascript等腳本的主動http請求才會出現跨域問題。

一、跨域的兩種請求

而瀏覽器將CORS請求分成兩類:簡單請求(simple request)和非簡單請求(not-so-simple request)。

只要同時滿足以下兩大條件,就屬於簡單請求。

(1) 請求方法是以下三種方法之一:

  • HEAD
  • GET
  • POST

(2)HTTP的頭信息不超出以下幾種字段:

  • Accept
  • Accept-Language
  • Content-Language
  • Last-Event-ID
  • Content-Type:只限於三個值application/x-www-form-urlencodedmultipart/form-datatext/plain

凡是不同時滿足上面兩個條件,就屬於非簡單請求。

瀏覽器對這兩種請求的處理,是不一樣的。

二、簡單請求

對於簡單請求,在頭信息之中,增加一個Origin字段,直接發出CORS請求。

GET /cors HTTP/1.1
Origin: null
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

上面的頭信息中,Origin字段用來說明,本次請求來自哪個源(協議 + 域名 + 端口)。服務器根據這個值,決定是否同意這次請求。

如果Origin指定的源,不在許可範圍內,服務器會返回一個正常的HTTP迴應(只要有包含正確頭信息即可,此時從狀態碼中無法看出錯誤,需要觀察頭信息)。瀏覽器發現,這個迴應的頭信息沒有包含Access-Control-Allow-Origin字段(詳見下文),就知道出錯了,從而拋出一個錯誤,被XMLHttpRequestonerror回調函數捕獲。

如果Origin指定的域名在許可範圍內,服務器返回的響應,會多出以下幾個頭信息字段。

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: FooBar
Content-Type: text/html; charset=utf-8

上面的頭信息之中,有三個與CORS請求相關的字段,都以Access-Control-開頭。

(1)Access-Control-Allow-Origin

必選字段。它的值要麼是請求時Origin字段的值,要麼是一個*,表示接受任意域名的請求。

(2)Access-Control-Allow-Credentials

可選字段。它的值是一個布爾值,表示是否允許發送Cookie。默認情況下,Cookie不包括在CORS請求之中。設爲true,即表示服務器明確許可瀏覽器發送Cookie。

(3)Access-Control-Expose-Headers

該字段可選。CORS請求時,XMLHttpRequest對象的getResponseHeader()方法只能拿到6個基本字段:Cache-ControlContent-LanguageContent-TypeExpiresLast-ModifiedPragma。如果想拿到其他字段,就必須在Access-Control-Expose-Headers裏面指定。上面的例子指定,getResponseHeader('FooBar')可以返回FooBar字段的值。

三、非簡單請求

3.1 預檢請求

對於一些可能對服務器數據有特殊要求或有影響的請求,如 PUT,DELETE 和搭配某些 MIME 類型的 POST 方法(常見的application/json也是非簡單請求),瀏覽器必須先發送一個“預檢請求”——也就是preflight response,來確認服務器是否允許該請求,允許的話再真正發送相應的請求。

瀏覽器先詢問服務器,當前網頁所在的域名是否在服務器的許可名單之中,以及可以使用哪些HTTP動詞和頭信息字段。只有得到肯定答覆,瀏覽器纔會發出正式的請求,否則就報錯。

curl -X OPTIONS http://example.org -i

響應報文包含一個首部字段,該字段的值表明了服務器支持的所有 HTTP 方法:

爲了檢測後端服務器是否會出現跨域問題,我們可以使用以下命令模擬瀏覽器的OPTIONS預檢請求:

HTTP/1.1 200 
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: POST, GET, DELETE, PUT, OPTIONS
Access-Control-Allow-Headers: Content-Type, x-requested-with, Token
Access-Control-Max-Age: 30
...

我們也可以使用JS腳本來判斷服務器允許跨域:

var url = 'http://api.alice.com/cors';
var xhr = new XMLHttpRequest();
xhr.open('PUT', url, true);
xhr.setRequestHeader('X-Custom-Header', 'value');
xhr.send();

上面代碼中,HTTP請求的方法是PUT,並且發送一個自定義頭信息X-Custom-Header。下面是"預檢"請求的HTTP頭信息:

OPTIONS /example.org HTTP/1.1
Origin: http://zeng.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: X-Custom-Header
...

"預檢"請求用的請求方法是OPTIONS,表示這個請求是用來詢問的。頭信息裏面,關鍵字段是Origin,表示請求來自哪個源。

除了Origin字段,"預檢"請求的頭信息包括兩個特殊字段。

(1)Access-Control-Request-Method

必須字段,列出瀏覽器的CORS請求所用到的HTTP方法。

(2)Access-Control-Request-Headers

非必須字段,該字段是一個逗號分隔的字符串,指定瀏覽器CORS請求會額外發送的頭信息字段。

3.2 預檢請求的迴應

服務器收到"預檢"請求以後,檢查了OriginAccess-Control-Request-MethodAccess-Control-Request-Headers字段以後,確認允許跨源請求,就可以做出迴應。

以下是服務器同意了預檢請求的返回頭信息:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://zeng.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Access-Control-Max-Age: 1728000
....

(1)Access-Control-Allow-Origin

必選字段。它的值要麼是請求時Origin字段的值,要麼是一個*,表示接受任意域名的請求。

(2)Access-Control-Allow-Methods

必選字段,它的值是逗號分隔的一個字符串,表明服務器支持的所有跨域請求的方法。

(3)Access-Control-Allow-Headers

可選字段。如果瀏覽器請求包括Access-Control-Request-Headers字段,則Access-Control-Allow-Headers字段是必需的。它也是一個逗號分隔的字符串,表明服務器支持的所有頭信息字段。

(4)Access-Control-Allow-Credentials

可選字段。它的值是一個布爾值,表示是否允許發送Cookie。默認情況下,Cookie不包括在CORS請求之中。設爲true,即表示服務器明確許可瀏覽器發送Cookie。

(5)Access-Control-Max-Age

可選字段。用來指定本次預檢請求的有效期,單位爲秒。上面結果中,有效期是20天(1728000秒),即允許緩存該條迴應1728000秒(即20天),在此期間,不用發出另一條預檢請求。

3.3 瀏覽器的正常請求和迴應

從上面可以看出,在服務器中可以使用過濾器進行判斷來源的有效性判斷。

如果服務器通過了"預檢"請求,則會返回正常的狀態碼以及包含CORS相關的頭信息字段,需要的最少信息如下:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://zeng.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
....

如果服務器不通過"預檢"請求,同樣是返回正常的狀態碼,但是不設置任何CORS相關的頭信息字段,這樣瀏覽器便不會發送相應請求,避免了用戶通過其他非法來源訪問服務器,降低用戶出現損失的可能。

HTTP/1.1 200 OK
....

服務器通過了"預檢"請求之後,在預檢請求後的一段有效期內,每次瀏覽器正常的CORS請求,就都跟簡單請求一樣,會有一個Origin頭信息字段。服務器的迴應,也都會有一個Access-Control-Allow-Origin頭信息字段。

四、跨域問題的解決

既然瞭解了跨域問題出現的原因,那麼解決方法的原理也都千篇一律了,都是通過定義響應中的CORS頭信息進行實現的,瀏覽器檢查到OPTIONS的響應頭信息上包含:Access-Control-Allow-OriginAccess-Control-Allow-Methods,即代表服務器允許跨域。

那麼有以下幾種跨域實現方式:

過濾器允許跨域使用原生代碼,使用Servlet便可以使用。

CorFilter、WebMvConfigurer、@CrossOrigin 需要 SpringMVC 4.2以上版本才支持,對應於springBoot 1.3版本以上

過濾器跨域、CorFilter、WebMvConfigurer屬於全局 CORS 配置,@CrossOrigin屬於局部 CORS配置。如果使用了局部跨域是會覆蓋全局跨域的規則,所以可以通過 @CrossOrigin 註解來進行細粒度更高的跨域資源控制

注意:

4.1 使用過濾器允許跨域

通過上文我們可以知道,如果要允許跨域,只需要在瀏覽器發送的預檢請求OPTIONS上設置必須的CORS響應頭信息即可允許跨域。

@WebFilter(filterName = "cors", urlPatterns = {"/*"})
public class CorsFilter implements Filter {
    public void init(FilterConfig filterConfig) {
    }
    
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletResponse response = (HttpServletResponse) servletResponse;
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        // 設置允許源,若出於安全,可以設置允許訪問的用戶源
        response.setHeader("Access-Control-Allow-Origin", "*");
        // 這裏通過判斷請求的方法,判斷此次是否是預檢請求,如果是,則進行跨域的設置
        if ("OPTIONS".equals(request.getMethod())){
            // 設置狀態碼爲204,允許跨域
            response.setStatus(HttpStatus.SC_NO_CONTENT); 
            // 必選,表明服務器支持的所有跨域請求的方法。
            response.setHeader("Access-Control-Allow-Methods", "POST, GET, DELETE, PUT, OPTIONS");
            // 可選, 設定允許請求的頭部類型
            response.setHeader("Access-Control-Allow-Headers", "Content-Type, x-requested-with, Token"); 
            // 可選,預檢有效保持時間
            response.addHeader("Access-Control-Max-Age", "1800");
        }
        filterChain.doFilter(servletRequest, servletResponse);
    }

    @Override
    public void destroy() {
    }
}

4.2 CORS全局配置

這類似於使用過濾器,但可以在Spring MVC中聲明。

@Configuration
public class CorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                // 是否發送Cookie
                .allowCredentials(true)
                // 放行哪些原始域
                .allowedOrigins("*")
                // 允許的請求類型
                .allowedMethods(new String[]{"GET", "POST", "PUT", "DELETE"})
                // 允許的請求頭
                .allowedHeaders("*")
                // 允許OPTION請求攜帶的字段,相當於Access-Control-Expose-Headers
                .exposedHeaders("*");
    }
}

4.3 CorsFilter(全局跨域)

在任意配置類,返回一個 新的 CorsFIlter Bean ,並添加映射路徑和具體的CORS配置路徑。

@Configuration
public class GlobalCorsConfig {
    @Bean
    public CorsFilter corsFilter() {
        // 添加 CORS配置信息
        CorsConfiguration config = new CorsConfiguration();
        // 放行哪些原始域
        config.addAllowedOrigin("*");
        // 是否發送 Cookie
        config.setAllowCredentials(true);
        // 放行哪些請求方式
        config.addAllowedMethod("*");
        // 放行哪些原始請求頭部信息
        config.addAllowedHeader("*");
        // 暴露哪些頭部信息
        config.addExposedHeader("*");
        // 添加映射路徑
        UrlBasedCorsConfigurationSource corsConfigurationSource = new UrlBasedCorsConfigurationSource();
        corsConfigurationSource.registerCorsConfiguration("/**",config);
        // 返回新的CorsFilter
        return new CorsFilter(corsConfigurationSource);
    }
}

4.4 使用註解 (局部跨域)

@CrossOrigin:

@Target({ ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface CrossOrigin {

    String[] DEFAULT_ORIGINS = { "*" };

    String[] DEFAULT_ALLOWED_HEADERS = { "*" };

    boolean DEFAULT_ALLOW_CREDENTIALS = true;

    long DEFAULT_MAX_AGE = 1800;

    /**
     * 同origins屬性
     */
    @AliasFor("origins")
    String[] value() default {};

    /**
     * 所有支持域的集合,例如"http://zeng.com"。
     * 這些值都顯示在請求頭中的Access-Control-Allow-Origin
     * "*"代表所有域的請求都支持
     * 默認支持所有請求域
     * @see #value
     */
    @AliasFor("value")
    String[] origins() default {};

    /**
     * 允許請求頭重的header,默認都支持
     */
    String[] allowedHeaders() default {};

    /**
     * 響應頭中允許訪問的header,默認爲空
     */
    String[] exposedHeaders() default {};

    /**
     * 請求支持的方法,例如"{RequestMethod.GET, RequestMethod.POST}"}。
     * 默認支持RequestMapping中設置的方法
     */
    RequestMethod[] methods() default {};

    /**
     * 是否允許cookie隨請求發送,使用時必須指定具體的域
     */
    String allowCredentials() default "";

    /**
     * 預請求的結果的有效期,默認30分鐘
     */
    long maxAge() default -1;

}

在控制器上使用註解 @CrossOrigin:

@RestController
@CrossOrigin
public class HelloController {
    @PostMapping("/hello")
    public String hello() {
        return "hello world";
    }
}

複製代碼

在方法上使用註解 @CrossOrigin:

	@RequestMapping("/hello")
    @CrossOrigin(origins = "*")
    public String hello() {
        return "hello world";
    }

4.5 手動設置響應頭(局部跨域)

使用 HttpServletResponse 對象添加響應頭(Access-Control-Allow-Origin)來授權原始域,這裏 Origin的值也可以設置爲 “*”,表示全部放行。該方法使用不方便,使用較少。

    @RequestMapping("/index")
    public String index(HttpServletResponse response) {
        response.addHeader("Access-Allow-Control-Origin","*");
        return "index";
    }

五、參考網站

瀏覽器同源政策及其規避方法

跨域資源共享 CORS 詳解

HTTP訪問控制(CORS)

Spring MVC 4.2 增加 CORS 支持

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章