跨域,对后端工程师来说,可谓既熟悉又陌生。

这两个月我以架构师的角色参与一款教育产品的孵化,有了一段难忘的跨域之旅

写这篇文章,我想分享我在跨域这个知识点上的经历和思考,希望对大家有所启发。

1 遇见跨域

产品有多端:机构端,局方端 ,家长端等 。每端都有独立的域名,有的是在PC上访问,有的是通过微信公众号来访问,有的是扫码后H5展现。

接入层调用的接口域名统一使用 api.training.com这个独立的域名,通过Nginx来配置请求转发。

通常,我们提到的跨域指:CORS

CORS是一个W3C标准,全称是"跨域资源共享"(Cross-origin  resource  sharing), 它需要浏览器和服务器同时支持他,允许浏览器向跨源服务器发送XMLHttpRequest请求,从而克服 AJAX 只能同源使用的限制。

那么如何定义同源呢?我们先看下一个典型的网站的地址:

同源是指:协议、域名、端口号完全相同

下表给出了与 URL http://www.training.com/dir/page.html 的源进行对比的示例:

当用户通过浏览器访问应用(http://admin.training.com)时,调用接口的域名非同源域名(http://api.training.com),这是显而易见的跨域场景。

2  CORS详解

跨域资源共享标准新增了一组 HTTP 首部字段,允许服务器声明哪些源站通过浏览器有权限访问哪些资源。

规范要求,对那些可能对服务器数据产生副作用的 HTTP 请求方法(特别是 GET 以外的 HTTP 请求,或者搭配某些 MIME 类型的 POST 请求),浏览器必须首先使用 OPTIONS 方法发起一个预检请求(preflight request),从而获知服务端是否允许该跨域请求。

服务器确认允许之后,才发起实际的 HTTP 请求。在预检请求的返回中,服务器端也可以通知客户端,是否需要携带身份凭证(包括 Cookies 和 HTTP 认证相关数据)。

2.1 简单请求

当请求同时满足如下条件时,CORS验证机制会使用简单请求, 否则CORS验证机制会使用预检请求。

  1. 使用GET、POST、HEAD其中一种方法;

  2. 只使用了如下的安全首部字段,不得人为设置其他首部字段;

  • Accept

  • Accept-Language

  • Content-Language

  • Content-Type 仅限三种之一:text/plain,multipart/form-data,application/x-www-form-urlencoded:

  • HTML头部 header field字段:DPR、Download、Save-Data、Viewport-Width、WIdth

请求中的任意 XMLHttpRequestUpload  对象均没有注册任何事件监听器;XMLHttpRequestUpload 对象可以使用 XMLHttpRequest.upload 属性访问;

请求中没有使用 ReadableStream 对象。


简单请求模式,浏览器直接发送跨域请求,并在请求头中携带Origin的头,表明这是一个跨域的请求。服务器端接到请求后,会根据自己的跨域规则,通过Access-Control-Allow-Origin和Access-Control-Allow-Methods响应头,来返回验证结果。

应答中携带了跨域头 Access-Control-Allow-Origin。使用 Origin 和 Access-Control-Allow-Origin 就能完成最简单的访问控制。本例中,服务端返回的 Access-Control-Allow-Origin: * 表明,该资源可以被任意外域访问。如果服务端仅允许来自 http://admin.training.com 的访问,该首部字段的内容如下:

Access-Control-Allow-Origin: http://admin.training.com

现在,除了 http://admin.training.com,其它外域均不能访问该资源。

2.2 预检请求

浏览器在发现页面发出的请求非简单请求,并不会立即执行对应的请求代码,而是会触发预先请求模式。预先请求模式会先发送preflight request(预先验证请求),preflight request是一个OPTION请求,用于询问要被跨域访问的服务器,是否允许当前域名下的页面发送跨域的请求。在得到服务器的跨域授权后才能发送真正的HTTP请求。

OPTIONS请求头部中会包含以下头部:

服务器收到OPTIONS请求后,设置头部与浏览器沟通来判断是否允许这个请求。

如果preflight request验证通过,浏览器才会发送真正的跨域请求。

3  后端配置

后端配置我尝试过两种方式,经过两个月的测试,都能非常稳定的运行。

  • MND推荐的Nginx配置;

  • SpringBoot自带CorsFilter配置。

▍MND推荐的Nginx配置

Nginx配置相当于在请求转发层配置。

location / {if ($request_method = 'OPTIONS') {add_header 'Access-Control-Allow-Origin' '*';add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';## Custom headers and headers various browsers *should* be OK with but aren't#add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';## Tell client that this pre-flight info is valid for 20 days#add_header 'Access-Control-Max-Age' 1728000;add_header 'Content-Type' 'text/plain; charset=utf-8';add_header 'Content-Length' 0;return 204;}if ($request_method = 'POST') {add_header 'Access-Control-Allow-Origin' '*' always;add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range' always;add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;}if ($request_method = 'GET') {add_header 'Access-Control-Allow-Origin' '*' always;add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range' always;add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;}
}

在配置Access-Control-Allow-Headers属性的时候,因为自定义的header包含签名和token,数量较多。为了简洁方便,我把Access-Control-Allow-Headers配置成 * 。

在Chrome和firefox下没有任何异常,但在IE11下报了如下的错:

Access-Control-Allow-Headers 列表中不存在请求标头 content-type。

原来IE11要求预检请求返回的Access-Control-Allow-Headers的值必须以逗号分隔。

▍SpringBoot自带CorsFilter

首先基础框架里默认有如下跨域配置。

public void addCorsMappings(CorsRegistry registry) {registry.addMapping("/**").allowedOrigins("*").allowedMethods("POST", "GET", "PUT", "OPTIONS", "DELETE").allowCredentials(true).allowedHeaders("*").maxAge(3600);
}

可是部署完成,进入还是报CORS异常:

从nginx和tomcat日志来看,仅仅收到一个OPTION请求,springboot应用里有一个拦截器ActionInterceptor,从header中获取token,调用用户服务查询用户信息,放入request中。当没有获取token数据时,会返回给前端JSON格式数据。

但从现象来看CorsMapping并没有生效。

为什么呢?实际上还是执行顺序的概念。下图展示了 过滤器,拦截器,控制器的执行顺序。

DispatchServlet.doDispatch()方法是SpringMVC的核心入口方法。

// Determine handler for the current request.
mappedHandler = getHandler(processedRequest);
if (!mappedHandler.applyPreHandle(processedRequest, response)) {return;
}
// Actually invoke the handler.
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());

那么CorsMapping在哪里初始化的呢?经过调试,定位于AbstractHandlerMapping

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

代码里有预检判断,通过 PreFlightHandler.handleRequest() 中处理,但是处于正常的业务拦截器之后。

最终选择CorsFilter 主要基于两点原因:

  • 过滤器的执行顺序优先级最高;

  • 通过调试CorsFilter的源码,发现源码有很多细节的处理。

private CorsConfiguration corsConfig() {CorsConfiguration corsConfiguration = new CorsConfiguration();corsConfiguration.addAllowedOrigin("*");corsConfiguration.addAllowedHeader("*");corsConfiguration.addAllowedMethod("*");corsConfiguration.setAllowCredentials(true);corsConfiguration.setMaxAge(3600L);return corsConfiguration;
}
@Bean
public CorsFilter corsFilter() {UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();source.registerCorsConfiguration("/**", corsConfig());return new CorsFilter(source);
}

下面的代码里,allowHeader是通配符 * 的时候,CorsFilter在设置 Access-Control-Allow-Headers 的时候,会将 Access-Control-Request-Headers 以逗号拼接起来,这样就可以避免IE11响应头的问题。

public List<String> checkHeaders(@Nullable List<String> requestHeaders) {if (requestHeaders == null) {return null;}if (requestHeaders.isEmpty()) {return Collections.emptyList();}if (ObjectUtils.isEmpty(this.allowedHeaders)) {return null;}boolean allowAnyHeader = this.allowedHeaders.contains(ALL);List<String> result = new ArrayList<>(requestHeaders.size());for (String requestHeader : requestHeaders) {if (StringUtils.hasText(requestHeader)) {requestHeader = requestHeader.trim();if (allowAnyHeader) {result.add(requestHeader);}else {for (String allowedHeader : this.allowedHeaders) {if (requestHeader.equalsIgnoreCase(allowedHeader)) {result.add(requestHeader);break;}}}}}return (result.isEmpty() ? null : result);
}

浏览器的执行效果如下:

4  preflight响应码:200 vs 204

后端配置完成之后,团队里的小伙伴问我:“勇哥,那预检请求返回的响应码到底是200还是204呀?”。这个问题真把我给问住了。

我司的API网关的预检响应码是200,CorsFilter预检响应码也是200。

MDN给的示例预检响应码全部是204。

https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS

我只能采取Google大法,赫然发现大名鼎鼎的API网关Kong的开发者也针对这个问题有一番讨论。

  1. MDN曾经推荐的preflight响应码是200 ,所以Kong也和MDN同步成200;

    The page was updated since then. See its contents on Sept 30th, 2018:

    https://web.archive.org/web/20180930031917/https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request

  2. 后来MDN将响应码修改204,于是Kong的开发者争论要不要和MDN保持同步。

    争论的核心点在于:有没有迫切的必要。200响应码运行得很好,似乎也将永远正常运行下去。而更换成204,不确定是否有隐藏问题。

  3. 说到底,框架开发者还是依赖于浏览器的底层实现。在这个问题上,没有足够权威的资料能够支撑框架开发者,而各个知识点都散落在网络的各个角落,充斥着不完整的细节和部分解决方案,这些都让框架开发者非常困惑。

最后,Kong的源码里预检响应码仍然是200,并没有和MDN保持同步。

我仔细查看了各大主流网站,95%预检响应码是200。而经过两个多月的测试,Nginx配置预检响应码204,在主流的浏览器Chrome , Firefox ,  IE11 也没有出现任何问题。

所以,200 works everywhere, 而204在当前主流的浏览器里也得到非常好的支持

5  Chrome: 非安全私有网络

本以为跨域问题就这样解决了。没想到还是有一个小插曲。

产品总监需要给客户做演示,我负责搞定演示环境。申请域名,准备阿里云服务器,应用打包,部署,一切都很顺利。

可是在公司内网访问演示环境,有一个页面一直报CORS报错,报错内容类似下图:

跨域的错误类型是:InsecurePrivateNetwork。

这和原来遇到的跨域错误完全不一样,我心里一慌。马上Google , 原来这是chrome更新到94之后新的特性,可以手工关闭这个特性。

  1. 打开 tab 页面  chrome://flags/#block-insecure-private-network-requests

  2. 将其  Block insecure private network requests 设置为 Disabled, 然后重启就行了, 这样子就相当于把这个功能禁用掉。

但这样是治标不治本呀。有点诡异的是,当我们不在公司内网访问演示环境的时候,演示环境完全正常,出错的页面也能正常访问。

仔细看官方的文档,CORS-RFC1918 指出如下三种请求会受影响。

  • 公共网络访问私有网络;

  • 公共网络访问本地设备;

  • 私有网络访问本地设备。

这样,我把问题定位在这个出错的第三方接口地址上。公司很多产品都依赖这个接口服务。当在公司内网访问的时候,该域名映射地址类似:172.16.xx.xx。

而这个ip正好是rfc1918上规定的私有网络。

10.0.0.0     -  10.255.255.255  (10/8 prefix)
172.16.0.0   -  172.31.255.255  (172.16/12 prefix)
192.168.0.0  -  192.168.255.255 (192.168/16 prefix)

内网通过Chrome访问这个页面的时候,会触发非安全私有网络拦截。

如何解决呢?官方给出的方案分两步走:

  1. 私有网络只能通过Https来访问;

  2. 未来,添加特定的预检头,比如说:Access-Control-Request-Private-Network等。

当然还有一些临时方法:

  • 关闭Chrome该特性;

  • 换用其他浏览器比如Firefox;

  • 关闭网络内网开手机热点;

  • 修改本地host绑定外网ip。

基于官方的方案 ,生产环境完全使用Https,公司内网访问就没有出现这样的跨域问题了。

6 复盘

美团Shepherd API网关的整体架构

API网关非常适合当前产品的架构。架构设计之初,系统多端都会调用我司的API网关。API网关可以SAAS部署和私有化部署,有单独的域名,提供完善的签名算法。考虑到上线时间节点,团队成员对于API网关的熟悉程度以及多套环境部署投入时间成本,为了尽快交付,从架构层面,我做了一些平衡和妥协。

接入层调用的接口域名统一使用 api.training.com这个独立的域名,通过Nginx来配置请求转发。同时,我和前端Leader统一了前后端协议,保持和我司API网关一致,为后续切回API网关做前置准备。

API网关可以做鉴权,限流,灰度等,同时可以配置CORS。内部服务端不用特别关注跨域这个问题。

腾讯API网关的配置界面

同时,在解决跨域的问题过程中,我的心态也发生了变化。从最初的轻视,到逐渐沉下心来,一步步理解CORS的原理,分清楚不同解决方案的优缺点,事情也就慢慢顺遂起来。我也观察到:”有的项目组已经反馈过Chrome非安全私有网络问题,并给出了解决方案。对于技术管理者来讲,一定要重视项目中反馈的问题,做好梳理分析,整理预案。这样当同类问题出现时,也会条理有序“。

7  写到最后

2016年,我参加左耳朵耗子陈皓老师技术演讲,他给我们讲了一个故事。

故事的大概是:“公司软件出现莫名BUG,用户的费用扣了,但调用第三方接口的时候经常出现网络问题。公司当时最厉害的人查了一周也没有解决,而陈皓老师正在看《TCP/IP 详解》这本书, netstat 一看,连接的状态是 CLOSE_WAIT ,意思是对方断开了连接,大概率估计是对方系统的问题。于是他去了对方那边帮他们看了一下代码,果然是判断条件出了问题,导致应用直接断开了链接。而这个问题只花了不到两个小时就解决了”。

当我想起陈皓老师的这个故事,回顾自己的跨域之旅,我深深的觉得细节是魔鬼,而解决问题也许就在某个不经意的细节里。

推荐

主流Java进阶技术(学习资料分享)

Java面试题宝典

加入Spring技术开发社区

PS:因为公众号平台更改了推送规则,如果不想错过内容,记得读完点一下“在看”,加个“星标”,这样每次新文章推送才会第一时间出现在你的订阅列表里。点“在看”支持我们吧

后端工程师的「跨域」之旅相关推荐

  1. 「跨域」利用node.js实践前端各种跨域方式(上)

    前言 常言道,"读万卷书,不如行万里路".技术的学习也是如此,唯有实践才能更清楚的明白原理和加深印象,因此本文会利用node.js对前端的各种跨域方式进行实践,强烈建议一步一步跟着 ...

  2. cors 前后端分离跨域问题_前后端分离之CORS跨域访问踩坑总结

    前言 前后端分离的开发模式越来越流行,目前绝大多数的公司与项目都采取这种方式来开发,它的好处是前端可以只专注于页面实现,而后端则主要负责接口开发,前后端分工明确,彼此职责分离,不再高度耦合,但是由于这 ...

  3. Vue整合nginx:(1)开发环境npm run dev下,通过nginx解决前后端分离造成的跨域问题

    Vue整合nginx:(1)开发环境npm run dev下,通过nginx解决前后端分离造成的跨域问题 参考文章: (1)Vue整合nginx:(1)开发环境npm run dev下,通过nginx ...

  4. node.js后端及Vue前端跨域解决方案

    node.js后端及Vue前端跨域解决方案 从目前我了解的情况来看,前后端跨域使用的方式有很多种,这里记录我使用最顺手的一种,即在后端使用cors跨域 node.js后端跨域解决方案 先看后端的入口文 ...

  5. 前后端分离项目,跨域问题解决方案

    1.什么是跨域? 要了解跨域,先要说说同源策略. 同源策略是由 Netscape 公司提出的一个著名的安全策略,所有支持 JavaScript 的浏览器都会使用这个策略. 由于浏览器同源策略的限制,非 ...

  6. cookies丢失 同域名_后端设置Cookie前端跨域获取丢失问题(基于springboot实现)

    1.跨域问题说明:后端域名为A.abc.com,前端域名为B.abc.com. 2.后端设置一个cookie发送给前台,domain应该是setDomain("abc.com"), ...

  7. vue前后分离session实现_vue2 前后端分离项目ajax跨域session问题解决

    最近学习使用vuejs前后端分离,重构一个已有的后台管理系统,遇到了下面这个问题: 实现跨域请求时,每次ajax请求都是新的session,导致无法获取登录信息,所有的请求都被判定为未登陆. 1. v ...

  8. php 跨域 验证_php 前后端分离开发进行跨域请求时ajax发送验证参数token的header头解决方法...

    php前后端分离开发中要实现前后端参数信息交互,必须解决token标识验证问题. 步骤如下: 1.前端ajax发送请求时,要设置一个自定义header头.代码如下: $.ajax({ url:&quo ...

  9. 前端vue和django后端数据交互,跨域问题的解决

    一:前言 再前后端的数据交互问题上,经常会遇到跨域问题.即这个错误 Access to XMLHttpRequest at 'http://127.0.0.1:8000/api/test/' from ...

最新文章

  1. 阿里开源台柱 Ant Design 源码仓库被删了...
  2. 5.fork和vfork
  3. 每天学习一点,坚持学习!!
  4. C# 中使用 ThoughtWorks.QRCode.dll 生成指定尺寸和边框宽度的二维码
  5. Css网格布局-Grid布局
  6. java爬虫jsoup_Java爬虫之利用Jsoup自制简单的搜索引擎
  7. STM32工作笔记0031---基于STM32F103C8自己实现的跑马灯实验_使用STLINK调试
  8. 安卓文件操作全解:内部文件、公共文件、私有文件、app静态文件
  9. RxJava Subject
  10. 栅栏密码(Fence)——python解密
  11. chrome浏览器使用
  12. uniapp——获取退出登录
  13. 用matlab绘制动态心形图
  14. 自定义报表制作的注意事项——思迈特软件Smartbi报表工具
  15. HTTP网络劫持的原理与过程、网站被劫持怎么办?
  16. RDF 1.1 N-Triples
  17. lattice FPGA 官网MIPI参考设计diamond编译报NGO不能展开,怎么解决?
  18. 液态金属驱动机器人研究取得进展
  19. 【TaI】GPU-based Grass Simulation with Accurate Blade Reconstruction
  20. springboot学习记录--Maven setting配置

热门文章

  1. 底大一级压死人!华为Mate 30 Pro主摄CMOS或将达到1/1.5英寸
  2. 认真的吗?过户逝者手机号码却需双方本人到场 中国移动回应...
  3. 三星Galaxy A90翻转摄像头出变故:或仍将采用水滴屏设计
  4. 中国移动公布2019年智能家庭网关集采结果:华为、中兴中标
  5. iOS 13发布日期确定 苹果WWDC2019定档!
  6. 发际线不符合有经验形象,程序员面试遭拒绝,网友:只招秃子?
  7. npm ERR! code E404 npm ERR! 404 Not Found - GET https://registry.npmjs.com/@mlamp%2fuser-info-dropdo
  8. mysql的预准备语句_mysql – 将字符串插入到没有”的预准备语句中
  9. Unity界面插件NGUI核心组件说明
  10. 计算机怎么切换到音乐,电脑怎么换开关机背景音乐?电脑换开关机背景音乐的方法...