以前对“跨域” 的含义只知道个大概,即浏览器出于安全考虑,不允许域名是 a 的网页,访问域名 b 的资源。
今天在 v2ex 上看了一篇关于跨域的帖子 ,对跨域 和预检 (preflight)有了更进一步的了解,打算自己做一个实验,来验证并加深自己的理解。
跨域 首先要澄清的是:跨域只会出现在浏览器环境中! ,纯服务端之间不存在这个问题。
所谓的跨域 ,就是:网页地址的域名是 A ,但是接口请求的地址是 B 。这就是跨域,跨越了不同的域(名)想要去请求资源 。它的严格定义和介绍可以看这里 。
事实上,协议、域名、端口三者有任意一个不同,就算“跨域”了 ,例如:
http://mofish.zone:3000 和 http://mofish.zone:9527 是跨域
http://x 和 https://x 是跨域
发生跨域问题的时候,我们一般会在控制台看到下面的错误:
1 2 Access to fetch at 'http://backend.com/test/cors/ping' from origin 'http://frontend.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
解决跨域问题 那么如何解决跨域问题呢?
粗略来讲,核心就是后端借助请求头 Access-Control-Allow-Origin 告诉浏览器,允许下列的 xxx 域名,访问我后端的资源
关于跨域还有其他一系列相关的 header,这里就不细看了。
这里的后端是相对前端来说的,它可以是真实的 web 服务,也可以是 nginx,也可以是其他任何可以修改 response header 的七层代理或中间件。
我们在 java springboot 服务中,可以通过给 controller 中的方法签名上增加 @CrossOrigin 注解来实现,比如下面的例子
1 2 3 4 5 6 @CrossOrigin(origins = "http://frontend.com", allowCredentials = "true") @GetMapping("/test/cors/ping") @ResponseBody public String handshake () { return "PONG" ; }
浏览器在收到 response 之后,会发现 response 中有这么一个 header:Access-Control-Allow-Origin = http://frontend.com,这时候浏览器就会放行这个请求。
如果想偷懒的话,也可以做一个全局配置:
1 2 3 4 5 6 7 8 9 10 11 @Configuration public class WebCorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings (CorsRegistry registry) { registry.addMapping("/**" ) .allowedOrigins("http://frontend.com" ) .allowedMethods("*" ) .allowedHeaders("*" ) .allowCredentials(true ); } }
亦或者,我们也可以在 nginx 上配置,当处理发往后端 backend.com 的请求时,自动添加上面的这个 header:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 server { listen 80 ; server_name backend.com; location / { proxy_pass http://real.backend.com/; add_header 'Access-Control-Allow-Origin' '*' always; add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE' always; add_header 'Access-Control-Allow-Headers' 'Authorization, X-Requested-With' always; if ($request_method = OPTIONS ) { add_header 'Access-Control-Max-Age' 1728000 ; add_header 'Content-Type' 'text/plain charset=UTF-8' ; add_header 'Content-Length' 0 ; return 204 ; } proxy_set_header Host $host ; proxy_set_header X-Real-IP $remote_addr ; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for ; proxy_set_header X-Forwarded-Proto $scheme ; } }
疑问:为什么本地开发环境没事? 前端开发的时候本地页面地址是 localhost ,接口地址肯定是其他的,为什么不跨域呢?
因为现在前端项目,开发用的脚手架通常会在本地起一个代理,统一将请求转发到后端,所以不会出现跨域问题。
动手实验 1. 修改本地 hosts 因为要在本地环境模拟,所以在 hosts 文件中将域名指向 127.0.0.1
1 2 127.0.0.1 frontend.com 127.0.0.1 backend.com
2. 创建前端页面 每次点击按钮,向后端发送一个请求,然后将 result 打印出来
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" > <title > 跨域测试前端</title > </head > <body > <h1 > Frontend on frontend.com</h1 > <button onclick ="callApi()" > 调用后端 API</button > <pre id ="result" > </pre > <script > function callApi ( ) { fetch ('http://backend.com/test/cors/ping' , {}) .then (response => response.text ()) .then (data => { document .getElementById ('result' ).textContent = data; }) .catch (err => { document .getElementById ('result' ).textContent = 'Error: ' + err; }); } </script > </body > </html >
3. 创建 web 服务 1 2 3 4 5 6 7 8 9 10 @RestController public class TestCorsController { @GetMapping("/test/cors/ping") @ResponseBody public String handshake () { return "PONG" ; } }
4. 修改 nginx 配置 当浏览器访问 http://frontend.com/index.html 的时候,返回上面创建的 html 页面。
当页面请求 http://backend.com/test/* 的时候会转发到我部署在 http://localhost:9010 上的 spring web 服务
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 server { listen 80 ; server_name frontend.com; location /index { root C:/idea_project/demo/src/main/resources/; index index.html; } } server { listen 80 ; server_name backend.com; location /test/ { proxy_pass http://localhost:9010; proxy_set_header Host $host ; proxy_set_header X-Real-IP $remote_addr ; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for ; } }
5. 发起请求,触发跨域问题 点击按钮,发起请求,可以看到前端提示 CORS 错误:
在 console 打印了详细的信息:
6. 配置 @CrossOrigin,解决跨域 我们修改 java 代码,加上 @CrossOrigin 注解:
1 2 3 4 5 6 7 8 9 10 11 12 @Slf4j @RestController public class TestCorsController { @CrossOrigin(origins = "http://frontend.com", allowCredentials = "true") @GetMapping("/test/cors/ping") @ResponseBody public String handshake () { log.info("Ping received, return PONG" ); return "PONG" ; } }
前端再次访问,就可以访问成功了。我们可以看到在 response header 中,明确的指出了 http://frontend.com 这个地址:
预检请求 当我们访问网页时,经常会看到请求类型为 OPTIONS 的预检请求,这也是浏览器用来确保跨域安全的策略之一。
我们上面 html 代码中,构造了一个最简单的 ping-pong 请求,遇到这种简单请求 ,浏览器会直接发送,不会预检。
简单请求 的准确定义比较复杂,简单来说,就是满足下面所有条件的请求:
请求方法 GET、HEAD 或 POST
只能使用这些“安全的请求头”:Accept、Accept-Language、Content-Language、DPR、Downlink、Save-Data、Viewport-Width、Width
对于POST请求,如果Content-Type头部的值是 application/x-www-form-urlencoded、multipart/form-data或text/plain,则不会触发预检请求
请求中不能使用自定义 header(包括凭证信息,如Cookies、HTTP认证信息等)
从最后一条我们可以知道,只要给请求带上任意自定义 header,就可以构造一个“非简单请求”
当遇到了非简单请求 时,浏览器就会先发送一个 OPTIONS 预检请求,问问后端配置的跨域策略是什么 ,如果能满足,则继续请求,如果不满足,就会报跨域错误。
下面我修改 html 网页源码,给请求携带一个自定义的 header:
1 2 3 4 5 6 7 8 9 10 11 12 function callApi ( ) { fetch ('http://backend.com/test/cors/ping' , { headers : { "X-Custom-Header" : "my_header" } }) .then (response => response.text ()) .then (data => { document .getElementById ('result' ).textContent = data; }) .catch (err => { document .getElementById ('result' ).textContent = 'Error: ' + err; }); }
这时候再去访问后端,就会触发预检请求 :
对于 spring web 项目,框架通常会帮我们处理预检请求,我们可以利用下面的 filter,来记录收到的预检请求:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @Slf4j @Component public class OptionRequestFilter extends OncePerRequestFilter { @Override protected void doFilterInternal (HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { if ("OPTIONS" .equalsIgnoreCase(request.getMethod())) { log.info("Received OPTIONS request to: " + request.getRequestURI()); } filterChain.doFilter(request, response); } }