Spring Boot实现 CORS 跨域资源共享

CORS 是一个 W3C 标准,全称叫做"跨域资源共享"(Cross-Origin resource sharing); 在详细介绍 CORS 之前先简单介绍下什么是同源政策,这样才能了解到 CORS 的由来|必要性。

浏览器同源政策

"同源政策"是浏览器安全的基石,目前所有浏览器都实行这个政策。

含义

所谓"同源",是指以下三个相同:

  • 协议相同
  • 域名相同
  • 端口相同

举个例子:

当前网址被请求页面地址是否跨域(不同源)原因
http://www.wu-yikun.top/page.htmlhttp://www.wu-yikun.top/main.html同协议(http)、同域名(www.wu-yikun.top)、同端口(80
http://www.wu-yikun.top/page.htmlhttps://www.wu-yikun.top/other.html协议不同(httphttps
http://www.wu-yikun.top/page.htmlhttp://www.wu-yikun.com/page.html域名不同(www.wu-yikun.topwww.wu-yikun.com
http://www.wu-yikun.top/page.htmlhttp://www.wu-yikun.top:8090/other.html端口不同(80 与 8090)

现在再来看这副图,是不是一目了然了?

目的

节选自"阮一峰"老师的文章

同源政策的目的,是为了保证用户信息的安全,防止恶意的网站窃取数据。

设想这样一种情况:A 网站是一家银行,用户登录以后,又去浏览其他网站。如果其他网站可以读取 A 网站的 Cookie,会发生什么?

很显然,如果 Cookie 包含隐私(比如存款总额),这些信息就会泄漏。更可怕的是,Cookie 往往用来保存用户的登录状态,如果用户没有退出登录,其他网站就可以冒充用户,为所欲为。因为浏览器同时还规定,提交表单不受同源政策的限制。

由此可见,"同源政策"是必需的,否则 Cookie 可以共享,互联网就毫无安全可言了。

限制

随着互联网的发展,同源政策越来越严格。如果非同源,以下几种行为会受到限制。

  • CookieLocalStorageIndexDB 无法读取
  • DOM 无法获取
  • AJAX 请求无法发送

光是一个 AJAX 请求无法发送就可以遏制不少合理的用途了。

接下来介绍如何实现跨域资源共享以避免同源政策。

CORS 详解

简介

CORS 需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能,在 CORS 通信过程中,浏览器会自动完成,无需用户参与,所以要想实现 CORS 通信,关键是使得服务器支持 CORS 接口,便可跨源通信。

CORS 两类请求

浏览器将 CORS 请求分为两类:简单请求非简单请求

简单请求

只要同时满足以下两大条件,就属于简单请求:

  • 请求方法是以下三种类型之一
    • HEAD
    • GET
    • POST
  • HTTP 头信息不超出以下几种字段
    • Accept
    • Accept-Language
    • Content-Language
    • Content-Type: 只限于 application/x-www-form-urlencodedmultipart/form-datatext/plain 三者之一

凡是不同时满足以上两个条件的就属于非简单请求。

如下就是一个 CORS 简单请求:

以下是浏览器发送给服务器的请求报文:

GET /resources/public-data/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
Origin: https://foo.example

请求首部字段 Origin 表明该请求来源于 http://foo.example,再看看响应报文:

HTTP/1.1 200 OK
Date: Mon, 01 Dec 2021 00:23:53 GMT
Server: Apache/2
Access-Control-Allow-Origin: *
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Transfer-Encoding: chunked
Content-Type: application/xml

[XML Data]

本例中,服务端返回的 Access-Control-Allow-Origin: * 表明该资源可以被任意外域访问。

Access-Control-Allow-Origin: *

使用 OriginAccess-Control-Allow-Origin 就能完成最简单的访问控制。如果服务端仅允许来自 https://foo.example 的访问,该首部字段的内容如下:

Access-Control-Allow-Origin: https://foo.example

非简单请求

非简单请求是那种对服务器有特殊要求的请求,比如请求方法是 PUTDELETE,或者 Content-Type 字段类型是 application/json

非简单请求的 CORS 请求的一大特点,就是会在正式通信前增加一次 HTTP 查询请求,称为"预检"请求(Preflight request)。该"预检"请求的方法为 OPTIONS,"预检"请求的使用,可以避免跨域请求对服务器的用户数据产生未预期的影响。

如下是一个需要执行预检请求的 HTTP 请求:

如下所述,实际的 POST 请求不会携带 Access-Control-Request-* 首部,它们仅仅适用于 OPTIONS 请求。

下面是服务端和客户端完整的信息交互。

首先是预检请求

OPTIONS /doc HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
Origin: https://foo.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type

浏览器检测到,从 JavaScript 中发起的请求需要被预检。从上面的报文中,我们看到,第 1~10 行发送了一个使用 OPTIONS 方法 的“预检请求”。OPTIONS 是 HTTP/1.1 协议中定义的方法,用以从服务器获取更多信息。该方法不会对服务器资源产生影响。 预检请求中同时携带了下面两个首部字段:

  • Access-Control-Request-Method: POST
  • Access-Control-Request-Headers: X-PINGOTHER, Content-Type

首部字段 Access-Control-Request-Method 告知服务器,实际请求将使用 POST 方法。

首部字段 Access-Control-Request-Headers 告知服务器,实际请求将携带两个自定义请求首部字段:X-PINGOTHERContent-Type

服务器据此决定,该实际请求是否被允许。

预检响应

HTTP/1.1 204 No Content
Date: Mon, 01 Dec 2021 01:15:39 GMT
Server: Apache/2
Access-Control-Allow-Origin: https://foo.example
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
Access-Control-Max-Age: 86400
Vary: Accept-Encoding, Origin
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive

(1)服务器的响应携带了 Access-Control-Allow-Origin: https://foo.example,从而限制请求的源域。

(2)同时,携带的 Access-Control-Allow-Methods 表明服务器允许客户端使用 POSTGET 方法发起请求(与 Allow 响应首部类似,但其具有严格的访问控制)。

(3)首部字段 Access-Control-Allow-Headers 表明服务器允许请求中携带字段 X-PINGOTHERContent-Type。如果浏览器请求包括Access-Control-Request-Headers字段,则Access-Control-Allow-Headers字段是必需的。它也是一个逗号分隔的字符串,表明服务器支持的所有头信息字段,不限于浏览器在"预检"中请求的字段。

(4)最后,首部字段 Access-Control-Max-Age 表明该响应的有效时间为 86400 秒,也就是 24 小时。在有效时间内,浏览器无须为同一请求再次发起预检请求。请注意,浏览器自身维护了一个 最大有效时间,如果该首部字段的值超过了最大有效时间,将不会生效。

预检请求完成之后,发送实际请求

POST /doc HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
X-PINGOTHER: pingpong
Content-Type: text/xml; charset=UTF-8
Referer: https://foo.example/examples/preflightInvocation.html
Content-Length: 55
Origin: https://foo.example
Pragma: no-cache
Cache-Control: no-cache

实际响应报文

HTTP/1.1 200 OK
Date: Mon, 01 Dec 2021 01:15:40 GMT
Server: Apache/2
Access-Control-Allow-Origin: https://foo.example
Vary: Accept-Encoding, Origin
Content-Encoding: gzip
Content-Length: 235
Keep-Alive: timeout=2, max=99
Connection: Keep-Alive
Content-Type: text/plain

上面头信息中,Access-Control-Allow-Origin 字段是每次响应都必须包含的。

Spring Boot 解决跨域问题

讲了这么多,来点实际场景感受一下。

是不是觉得有种某名的"亲切感"?以下我们就来解决这种无力感吧。

通过 Filter 过滤器手动设置响应头

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
@Slf4j
@WebFilter(urlPatterns = {"/*"}, filterName = "corsFilter")
public class CorsFilter implements Filter {
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        log.info("启动跨域过滤器");
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse resp, FilterChain chain) throws IOException, ServletException {
        HttpServletResponse response = (HttpServletResponse) resp;
        // 手动设置响应头解决跨域访问
        response.setHeader("Access-Control-Allow-Origin", "*");
        response.setHeader("Access-Control-Allow-Methods", "POST, PUT, GET, OPTIONS, DELETE");
        // 设置过期时间
        response.setHeader("Access-Control-Max-Age", "86400");
        response.setHeader("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization, uuid");
        // 支持 HTTP 1.1
        response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
        // 支持 HTTP 1.0. response.setHeader("Expires", "0");
        response.setHeader("Pragma", "no-cache");
        // 编码
        response.setCharacterEncoding("UTF-8");
        chain.doFilter(request, resp);
    }

    @Override
    public void destroy() {
        log.info("销毁跨域过滤器");
    }
}

使用 @CrossOrigin 注解(局部跨域)

@CrossOrigin 注解源码:

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
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 "";

    long maxAge() default -1L;
}

使用 @CrossOrigin 注解:

@CrossOrigin(origins = "*", allowedHeaders = "*", maxAge = 86400)
@PostMapping("/login")
public String login(@RequestBody User user) {
	TODO..
}

不过通过 @CrossOrigin 注解的源代码注定了它只能针对单个接口进行跨域配置,即局部跨域。虽然它比如上的 Filter 过滤器更简便,但这明显不是我们想要的,实际开发中也很少使用该注解。

实现 WebMvcConfigurer 接口

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
            	// 表明允许哪些域访问, 简单点可为 *
                .allowedOrigins("http://localhost:3000")
                .allowedHeaders("*")
                .allowedMethods("*")
            	// allowCredentials(true): 表示附带身份凭证
            	// 一旦使用 allowCredentials(true) 方法,则 allowedOrigins("*") 需要指明特定的域,而不能是 *
                .allowCredentials(true)
                .maxAge(86400);
    }
}

以上这种方式在没有定义拦截器(Interceptor)的时候,使用起来一切正常,但如果你有一个全局的拦截器,比如检测用户登录的拦截器:

public class AuthenticationInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object object) throws Exception {
        // 从 http 请求头中取出 token
        String token = httpServletRequest.getHeader("token");
        // 检查是否登录
        if (token == null) {
        	throw new InvalidTokenException(ResultCode.INVALID_TOKEN.getCode(), "登录信息已过期,请重新登录");
        }
        return true;
    }
    
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {

    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {

    }
}

当自定义拦截器返回 true 时,一切正常,但是当拦截器抛出异常(或者返回 false)时,后续的 CORS 配置将不会生效。

为什么拦截器抛出异常 CORS 不生效呢?可以看看 GitHub 上提出的这个 issue:

when interceptor preHandler throw exception, the cors is broken #9595

大致内容如下:

有人提交了一个 issue,说明如果在自定义拦截器的 preHandler 方法中抛出异常的话,通过 CorsRegistry 设置的全局 CORS 配置就失效了,但是 Spring Boot 的成员不认为这是一个 BUG🐛。

然后提交者举了个具体的例子:

他先定义了 CorsRegistry,并添加了一个自定义的拦截器,拦截器中抛出异常:

然后发现 AbstractHandlerMapping 在添加 CorsInterceptor 的时候,是将 Cors 的拦截器加在拦截器链的最后:

那就会造成上面所说的问题:在自定义拦截器中抛出异常之后,CorsInterceptor 拦截器就没有机会执行向 response 中设置 CORS 相关响应头。

issuer 也给出了解决的方案,就是将用来处理 CORS 的拦截器 CorsInterceptor 夹在拦截器链的第一个位置:

这样的话,一旦请求来了之后,第一个拦截器就会为 response 设置相应的 CORS 响应头(例如: Access-Control-Allow-OriginAccess-Control-Allow-MethodsAccess-Control-Allow-Headers),后续如果其他自定义拦截器抛出异常,也不会有任何影响了!

感觉这是一个可行的解决方案,但是 Spring Boot 的成员认为这并不是 Spring Boot 的 Bug,而是 Spring Framework 的 Bug,所有将这个 issue 关闭了。

注入 CorsFilter 过滤器

使用过滤器就可以避免拦截器全局跨域配置冲突问题。代码如下:

@Configuration
public class CorsFilterConfiguration {

    @Bean
    public CorsFilter corsFilter() {
        // 创建 CorsConfiguration 对象后添加配置
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        // 设置放行哪些原始域
        corsConfiguration.addAllowedOrigin("*");
        // 放行哪些原始请求头部信息
        corsConfiguration.addAllowedHeader("*");
        // 放行哪些请求方法
        corsConfiguration.addAllowedMethod("*");

        // 添加映射路径
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", corsConfiguration);

        return new CorsFilter(source);
    }

}

⭐为什么过滤器可以避免冲突而拦截器不行呢?

因为过滤器依赖于 Servlet 容器,基于函数回调,它可以对几乎所有请求进行过滤。而拦截器是依赖于 Web 框架(如 Spring MVC 框架),基于反射通过 AOP 的方式实现的。

触发顺序如下所示:

因为过滤器在触发上是先于拦截器的,但是如果有多个过滤器的话,也需要将 CorsFilter 设置为第一个过滤器才行。

解决 CORS 跨域的其他角度|思路

服务端支持 CORS

🤨你在期待什么?

刚刚所讲的 [Spring Boot 解决跨域问题](#Spring Boot 解决跨域问题) 就是在阐述如何在服务端实现 CORS 跨域资源共享。

以上四种方法亲测有效!如有疑问,评论区见

😉若你使用 Spring 框架而非 Spring Boot,我也找到了一篇官方文档供你们参考:CORS support in Spring Framework

JSONP

利用 <script> 标签没有跨域限制的漏洞,网页跨域得到从其他来源动态产生的 JSON 数据。JSONP 请求一定需要对方的服务器做支持才可以。

注:有三个标签本身就允许跨域加载资源。

  • <img src="xxx">
  • <link href="xxx">
  • <script src="xxx">

JSONP 与 AJAX 相同,都是客户端向服务器发送请求,从服务器端获取数据的方式。但 AJAX 属于同源策略,JSONP 属于非同源策略(支持跨域请求)。

优点:简单、兼容性好,可用于解决主流浏览器的跨域数据访问的问题。

缺点:仅支持 GET 方法,具有局限性,不安全可能会遭受 XSS 攻击。

反向代理服务器

同源策略是浏览器需要遵守的标准,而如果是服务器向服务器请求则无需遵循同源策略了。所以通过反向代理服务器就可以有效地解决跨域问题了,代理服务器需要做如下几件事:

  1. 接受客户端请求
  2. 将请求转发给实际的服务器
  3. 将服务器的响应结果返回给客户端

Nginx 就是类似的反向代理服务器,跨域通过配置 Nginx 代理来解决跨域问题。

Reference

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值