天天看點

跨域、同源政策、CORS以及如何跨域請求資源

一、什麼是跨域?

要想知道什麼是跨域,那就得知道域的結構:

域 = 協定 + 主機位址 + 端口号

看幾個對比網站:

http://baidu.com

https://baidu.com

是不同的域,因為請求協定不同;

http://www.baidu.com

http://zzz.baidu.com

是不同的域,因為主機位址不同;

http://www.baidu.com:8080

http://www.baidu.com:8081

是不同的域,因為端口号不同。

不寫端口号通常為80端口。

跨域就是在一個域裡請求另一個域,在A網站的頁面,有個按鈕請求了B網站的某個資源,那就是跨域。

二、什麼是同源政策?

預設浏覽器對跨域的JavaScript請求響應結果做出限制,如果你用JS進行了跨域請求,伺服器會正常接收請求并進行處理以及響應,但是浏覽器會報錯,響應回來的資料不會讓你使用。

三、什麼是CORS?

cors就是一個w3c的标準,是一個跨域請求資源的解決方案,各家浏覽器都實作了這個規範。

同源政策太暴力了吧?我發了個JS請求,你直接就把響應給我的資料給處理掉了,太霸道了,導緻出現了很多稀奇古怪的解決方案,比如說麻煩并且隻能處理get請求的JSONP,什麼在《Script》标簽裡邊請求,那都不正宗。

是以說w3c制訂了一套規範,每個浏覽器,你别直接就把人家js的跨域請求回來的資源給幹掉,你每次你這麼辦,你讓伺服器做決定,到底讓不讓它跨域拿到資源,這多好啊。

四、浏覽器對CORS的實作原理

javascript發送跨域請求的時候,浏覽器會在請求頭上加一個字段,叫Origin,意思就是來源,是目前域的值,也就是從哪個域向伺服器發送的請求,然後浏覽器不管你伺服器進行了什馬樣的操作,反正在你給我的響應,浏覽器會去響應頭找它想看到的字段,并看看這個字段伺服器給設定了個什麼值,然後浏覽器就決定這個響應回來的資源怎麼處置。

第一種情況:伺服器響應頭沒有那個字段或者值不符合預期,那浏覽器就直接報錯,請求失敗

第二種情況:伺服器響應頭有那個字段,值也符合了規定,那這次請求就成功了。

進入正題

我們知道了大概的原理,那就看看請求頭裡的字段具體是啥?響應頭裡的字段又具體是個啥?

CORS規範中,Javascript的跨域請求還被分成了簡單請求和不簡單請求:

簡單請求:常見的get/post/head請求,前提沒有添加自定義的請求頭字段,Content-Type為text/plain、multipart/form-data、application/x-www-form-urlencoded這幾種。

非簡單請求:get/post/head以外的,如put、delete方法等,或者你雖然用的是get/post/head,但是有自定義的頭,或者你雖然用的是get/post/head,但是内容類型定義成了規定以外的,比如json

①當是簡單請求的時候,請求頭會被加上一個Origin字段,标明了來源。響應頭中需要設定以下幾個字段:

1)Access-Control-Allow-Origin : 這個屬性字段最重要,沒有浏覽器就報錯,說你響應中沒有這東西。但是就算是有,但是伺服器給這個字段設定的值,跟你目前的域不一樣,也就是跟你請求頭中的Origin不一樣,那浏覽器也一樣報錯,說你域跟人家伺服器要求的域不一樣。要是想讓所有來源都能通路,那伺服器就把這個屬性值設成 * 。

2)Access-Control-Allow-Credentials:這是個起輔助作用的字段了,是布爾型,伺服器可以不設定,不設定就是false。這個東西意思就是請求中可不可以帶cookie。

3)Access-Control-Expose-Headers:也是個輔助作用的字段,意思就是請求方可以用JS擷取響應頭中的哪些字段,預設不設定就是6個,分别是:Cache-Control、Content-language、Content-Type、Expires、Last-Modified、Pragma。

②當是非簡單請求的時候,請求頭被加上Origin和Access-Control-Request-Method字段,有時也會被加上Access-Control-Request-Headers

有些請求是因為添加了自定義的請求頭才成為的非簡單請求,這時候請求頭中就會帶上Access-Control-Request-Headers參數,表明了你自定義了哪些請求頭字段。

非簡單請求,浏覽器會在真正的請求之前先發送一次請求,因為這次請求不簡單,是以可能不被伺服器允許,為了節省資源,就先發送一個輕量的小請求探探路,這個請求叫預檢請求。預檢請求使用的請求方法是options,沒有請求體,就帶一些元資訊在頭裡邊,具體就是Origin和Access-Control-Request-Method。Access-Control-Request-Method的意思是後邊的真正的請求用的是什麼請求方法,比如說你要發送一個put請求修改某條資料,那打頭的預檢請求就記錄真正的要改資料的這個請求用的是put方法,然後浏覽器從預檢請求頭中拿到Origin和Access-Control-Request-Method判斷讓不讓你這個Origin域的請求以Access-Control-Request-Method請求方式請求資源。

伺服器同樣要在響應頭中設定一些字段,在簡單請求的那種基礎之上,又多了一個字段,是必須的一個字段,跟Origin一樣沒設定或者設定的不對浏覽器都會報錯。

1)Access-Control-Allow-Methods:表示允許哪些請求方法被允許跨域。要是不設定這個響應字段,那浏覽器直接報錯,後續的真正請求不會被執行;但是就算設定了值,但是跟後邊真正請求的請求方法不一樣,還是會報錯,且真正的請求不會被執行。

2)Access-Control-Max-Age:代表本次預檢的有效時間。以後再有符合響應頭中規定的域以規定的方法發送相同接口的請求,浏覽器在有效時間内就不用再發預檢了,直接發真正的請求。

SpringBoot(SpringMVC)處理跨域

一、自定義過濾器

①過濾器

public class CorsFilter implements Filter {
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletResponse res = (HttpServletResponse) servletResponse;
        res.setHeader("Access-Control-Allow-Origin", "*");
        res.setHeader("Access-Control-Allow-Credentials", "true");
        res.setHeader("Access-Control-Allow-Methods", "*");
        res.setHeader("Access-Control-Allow-Headers", "*");

        filterChain.doFilter(servletRequest,res);
    }
}
           

②配置過濾器

此處注意過濾器的路徑配置規則和springmvc的攔截器路徑規則是有差別的。

過濾器/hello/,攔截帶有/hello開始的所有層級請求,比如/hello/a/b會被攔截。

攔截器/hello/,隻是攔截下邊的一層,比如說/hello/a或者/hello/b都會被攔截,但是/hello/a/b就不會,

要想攔截多層請求,可以/hello/**。

攔截器路徑比過濾器靈活了點。

@Configuration
public class CorsFilterConfiguration {

    @Bean
    public FilterRegistrationBean filterRegistrationBean(){
        FilterRegistrationBean<Filter> registrationBean = new FilterRegistrationBean<>();
        registrationBean.setFilter(new CorsFilter());
        registrationBean.addUrlPatterns("/*");

        return registrationBean;
    }
}
           

二、自定義攔截器

①攔截器

public class Corsinterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        response.setHeader("Access-Control-Allow-Origin", "*");
        response.setHeader("Access-Control-Allow-Credentials", "true");
        response.setHeader("Access-Control-Allow-Methods", "*");
        response.setHeader("Access-Control-Allow-Headers", "*");
        return true;
    }
}
           

②配置攔截器

@Configuration
public class CorsWebConfiguration implements WebMvcConfigurer{
    
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new Corsinterceptor())
                .addPathPatterns("/**");
    }
}
           

三、使用SpringMVC的注解@CrossOrigin

此注解可以加在方法上,也可以加在類上。

加在方法上,隻對目前方法進行處理;

加在類上,對這個類的所有方法都進行相同的處理。

示例代碼:

@RestController
public class HelloController {

    /**
     * DefaultCorsProcessor 用來處理@CrossOrigin配置的跨域邏輯
     */
    @RequestMapping("/hello")
    @CrossOrigin(origins = "*",allowCredentials = "true",allowedHeaders = "*",methods = RequestMethod.POST)
    public String hello(){
        return "hello";
    }
}
           

這是一個在普通不過的controller了,我把@CrossOrigin加在了方法上,表示隻對目前這個方法有效。

使用這個注解往往會遇到很多的坑,很多人習慣直接用預設的,就直接@CrossOrigin放在方法上或者類上,用預設值,這樣的确可以,但是碰到了某些情況,你會發現就是tm的跨域失敗,浏覽器就是報錯。

首先看下這個注解的源碼:

public @interface CrossOrigin {

    @AliasFor("origins")
    String[] value() default {};

    @AliasFor("value")
    String[] origins() default {};

    String[] allowedHeaders() default {};

    String[] exposedHeaders() default {};

    RequestMethod[] methods() default {};

    String allowCredentials() default "";

}
           

我對源碼做了精簡,這就是我們前面提到的,要在響應頭中添加的一些東西,但是,我們要注意什麼呢?這是springmvc的注解,既然它做出了這個注解,那就是有處理這個注解的類,經我查找,是DefaultCorsProcessor類,這個類的處理邏輯不同于我前邊寫的簡單的攔截器和過濾器的處理邏輯,咱們寫的那些是會在響應頭中設定好cors相關的字段,讓浏覽器根據響應頭中的字段進行判斷跨域是否成功,但是DefaultCorsProcessor是直接在背景做了判斷,如果跨域請求的條件不符合配置的條件,響應頭中不會給你添加任何cors相關的字段,直接給你個狀态403,并附上資訊:Invalid CORS request XXX

上源碼:

public class DefaultCorsProcessor implements CorsProcessor {

    @Override
    @SuppressWarnings("resource")
    public boolean processRequest(@Nullable CorsConfiguration config, HttpServletRequest request,
                                  HttpServletResponse response) throws IOException {

        //響應頭中添加校驗資訊,證明伺服器已經校驗過了
        response.addHeader(HttpHeaders.VARY, HttpHeaders.ORIGIN);
        response.addHeader(HttpHeaders.VARY, HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD);
        response.addHeader(HttpHeaders.VARY, HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS);

        //判斷是否是跨域請求
        if (!CorsUtils.isCorsRequest(request)) {
            return true;
        }

        //判斷是否已經被别的攔截器或過濾器處理過了
        if (response.getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN) != null) {
            logger.trace("Skip: response already contains \"Access-Control-Allow-Origin\"");
            return true;
        }

        //判斷是否是一個預檢請求
        boolean preFlightRequest = CorsUtils.isPreFlightRequest(request);

        //沒配置cors的相關資訊,執行相關代碼
        //但是就算咱們在@CrossOrigin沒進行任何配置,也會有預設值的,預設值見下方debug貼圖
        if (config == null) {
            //如果是預檢請求,但是沒配置cors的相關資訊,直接響應一個403
            //但是就算咱們不配置,也會有預設值,是以不會執行下面代碼
            if (preFlightRequest) {
                rejectRequest(new ServletServerHttpResponse(response));
                return false;
            }
            else {
                return true;
            }
        }
        //執行進一步判斷,判斷請求方法符不符合、請求來源是否符合配置好的域集合
        return handleInternal(new ServletServerHttpRequest(request), new ServletServerHttpResponse(response), config, preFlightRequest);
    }

    /**
     * 這方法就是那個在響應頭設定403,插上報錯資訊的公共方法
     */
    protected void rejectRequest(ServerHttpResponse response) throws IOException {
        response.setStatusCode(HttpStatus.FORBIDDEN);
        response.getBody().write("Invalid CORS request".getBytes(StandardCharsets.UTF_8));
        response.flush();
    }

    /**
     * 執行進一步判斷,判斷請求方法符不符合、請求來源是否符合配置好的域集合
     */
    protected boolean handleInternal(ServerHttpRequest request, ServerHttpResponse response,
                                     CorsConfiguration config, boolean preFlightRequest) throws IOException {

        //這塊是關鍵點一,如果請求來源與配置中的允許域集合沒有比對上的,就直接給你個無情403
        String requestOrigin = request.getHeaders().getOrigin();
        String allowOrigin = checkOrigin(config, requestOrigin);
        HttpHeaders responseHeaders = response.getHeaders();
        if (allowOrigin == null) {
            logger.debug("Reject: '" + requestOrigin + "' origin is not allowed");
            rejectRequest(response);
            return false;
        }

        //這是關鍵點二,如果請求的方法和配置中的允許方法集合沒比對上,直接無情403
        //其中getMethodToUse有點兒意思,擷取請求的方法是分情況的,要是個簡單請求,那過來的請求就是真正的請求,直接擷取
        //這個請求是哪種請求方法就行了;
        //但是這要是一個預檢請求,那預檢請求的方法是不能代表真正的請求的,是以需要從請求頭中拿到Access-Control-RequestMethod的值
        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;
        }

        //這是關鍵點三,驗證請求頭中除了簡單請求頭以外的額外頭資訊清單
        //非簡單請求不是有個規定麼,比如說post是簡單請求,但是多了自定義的頭資訊就變成了非簡單請求
        //多的這些頭資訊在這裡取出來進行判斷
        //服務端預設配置是*,啥頭都行
        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;
        }

        //下邊就是順利通過了校驗,進行響應頭資訊的設定
        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());
        }

        response.flush();
        return true;
    }

}
           

@CrossOrigin預設值

跨域、同源政策、CORS以及如何跨域請求資源

可以看出,預設比對的請求方式是get/post/head請求,預設所有域都行,預設所有多出的請求頭都行,預設預檢緩存在浏覽器的時間是30分鐘。

什麼時候會走DefaultCorsProcessor中的邏輯代碼?

并不是任何的請求都會被攔截到這段邏輯中,有以下幾個條件:

①請求是簡單請求,但是未配置@CrossOrigin注解,那麼不會添加cors攔截器到執行鍊。

②請求是簡單請求,但是配置了@CrossOrigin注解,那麼會添加一個cors的攔截器到執行鍊中,進行執行。

③請求隻要是非簡單請求,就會将此攔截器加到執行鍊中并替換掉真正的Controller。

簡單看下源碼:

進入DispatcherServlet的doDispatch方法,觀察getHandler(processedRequest)方法如下:

擷取請求的對應處理器執行鍊

mappedHandler = getHandler(processedRequest);
           

進入getHandler方法,它會根據5種請求風格來比對擷取處理鍊,我們主要看RequestMappingHandlerMapping這種

跨域、同源政策、CORS以及如何跨域請求資源

進入mapping.getHandler(request)方法:

public final HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {
		.......根據請求位址擷取處理鍊,代碼省略

		//主要觀察的方法
		if (hasCorsConfigurationSource(handler) || CorsUtils.isPreFlightRequest(request)) {
			CorsConfiguration config = (this.corsConfigurationSource != null ? this.corsConfigurationSource.getCorsConfiguration(request) : null);
			CorsConfiguration handlerConfig = getCorsConfiguration(handler, request);
			config = (config != null ? config.combine(handlerConfig) : handlerConfig);
			executionChain = getCorsHandlerExecutionChain(request, executionChain, config);
		}

		return executionChain;
	}
           

這個if判斷是我們要觀察的,它的邏輯就是:

如果你配置了跨域的配置資訊,比如說@CrossOrigin注解。或者請求是一個預檢請求,那麼就會額外擷取一個處理跨域的攔截器到處理鍊中,我們跟進getCorsHandlerExecutionChain(request, executionChain, config)方法。

protected HandlerExecutionChain getCorsHandlerExecutionChain(HttpServletRequest request,
			HandlerExecutionChain chain, @Nullable CorsConfiguration config) {

		if (CorsUtils.isPreFlightRequest(request)) {
			HandlerInterceptor[] interceptors = chain.getInterceptors();
			chain = new HandlerExecutionChain(new PreFlightHandler(config), interceptors);
		}
		else {
			chain.addInterceptor(0, new CorsInterceptor(config));
		}
		return chain;
	}
           

此方法進行了一次判定,如果請求是預檢請求,那麼處理鍊中就不會包含真正處理請求的controller,處理鍊的主體變成了處理跨域的攔截器。

如果不是預檢請求,也就是說來的是一個跨域請求,但不是預檢請求,那麼就将處理跨域的攔截器添加到處理鍊中,Controller依然是處理鍊的主體。

我們回到DispatcherServlet的doDispatch方法:

跨域、同源政策、CORS以及如何跨域請求資源

在擷取到處理鍊之後,會擷取一個擴充卡,也就是擴充卡模式,用擴充卡執行Handler,也就是處理鍊中的Controller和攔截器。

我截取部分代碼:

//擷取擴充卡對象,之後用擴充卡模式調用處理器中的相應處理方法
				HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
				
				//調用處理鍊中,攔截器的前置方法,也就是攔截器的preHandle方法
				if (!mappedHandler.applyPreHandle(processedRequest, response)) {
					return;
				}
				
				// 如果攔截器放行了,則會執行處理器,可能是Controller,也可能是跨域處理攔截器
				mv = ha.handle(processedRequest, response, mappedHandler.getHandler());

				//調用處理鍊中,攔截器的前置方法,也就是攔截器的postHandle方法
				applyDefaultViewName(processedRequest, mv);
				mappedHandler.applyPostHandle(processedRequest, response, mv);
           

如果在擷取處理鍊時,由于請求是預檢請求,那麼傳回的處理鍊的主體對象是一個跨域處理對象,裡邊會持有其他攔截器,那麼在擴充卡調用主體處理請求的時候,實際并不會執行到Controller的代碼。

例如我這個測試所傳回的處理鍊主體:

跨域、同源政策、CORS以及如何跨域請求資源

可以看出是一個AbstractHandlerMapping中的一個内部類對象PreFlightHandler。

擴充卡會調用handle方法:

跨域、同源政策、CORS以及如何跨域請求資源

handle方法裡又會調用PreFlightHandler的handleRequest方法:

跨域、同源政策、CORS以及如何跨域請求資源

可以看到,此時調用了DefaultCorsProcessor中的處理方法。

跨域、同源政策、CORS以及如何跨域請求資源

DefaultCorsProcessor被包裝在了跨域處理攔截對象中。

總結:

使用@CrossOrigin解決跨域,我們就要規範的使用@RequestMapping注解,get請求就是@GetMapping,post請求就是@PostMapping,put請求就是@PutMapping,delete請求就是@DeleteMapping