后端老司机的跨域之旅
跨域,对后端工程师来说,可谓既熟悉又陌生。
这两个月我以架构师的角色参与一款教育产品的孵化,有了一段难忘的跨域之旅。
写这篇文章,我想分享我在跨域这个知识点的经历和思考,希望对大家有所启发。
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 验证机制会使用预检请求。
使用 GET、POST、HEAD 其中一种方法;
只使用了如下的安全首部字段,不得人为设置其他首部字段;
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 的访问,该首部字段的内容如下:
现在,除了 http://admin.training.com,其它外域均不能访问该资源。
2.2 预检请求
浏览器在发现页面发出的请求非简单请求,并不会立即执行对应的请求代码,而是会触发预先请求模式。预先请求模式会先发送 preflight request(预先验证请求),preflight request 是一个 OPTION 请求,用于询问要被跨域访问的服务器,是否允许当前域名下的页面发送跨域的请求。在得到服务器的跨域授权后才能发送真正的 HTTP 请求。
OPTIONS 请求头部中会包含以下头部:
服务器收到 OPTIONS 请求后,设置头部与浏览器沟通来判断是否允许这个请求。
如果 preflight request 验证通过,浏览器才会发送真正的跨域请求。
3 后端配置
后端配置我尝试过两种方式,经过两个月的测试,都能非常稳定的运行。
MND 推荐的 Nginx 配置;
SpringBoot 自带 CorsFilter 配置。
▍MND 推荐的 Nginx 配置
Nginx 配置相当于在请求转发层配置。
在配置 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
首先基础框架里默认有如下跨域配置。
可是部署完成,进入还是报 CORS 异常:
从 nginx 和 tomcat 日志来看,仅仅收到一个 OPTION 请求,springboot 应用里有一个拦截器 ActionInterceptor,从 header 中获取 token,调用用户服务查询用户信息,放入 request 中。当没有获取 token 数据时,会返回给前端 JSON 格式数据。
但从现象来看 CorsMapping 并没有生效。
为什么呢?实际上还是执行顺序的概念。下图展示了 过滤器,拦截器,控制器的执行顺序。
DispatchServlet.doDispatch()
方法是 SpringMVC 的核心入口方法。
那么 CorsMapping 在哪里初始化的呢?经过调试,定位于 AbstractHandlerMapping。
代码里有预检判断,通过 PreFlightHandler.handleRequest()中处理,但是处于正常的业务拦截器之后。
最终选择 CorsFilter 主要基于两点原因:
过滤器的执行顺序优先级最高;
通过调试 CorsFilter 的源码,发现源码有很多细节的处理。
下面的代码里,allowHeader 是通配符 * 的时候,CorsFilter 在设置 Access-Control-Allow-Headers 的时候,会将 Access-Control-Request-Headers 以逗号拼接起来,这样就可以避免 IE11 响应头的问题。
浏览器的执行效果如下:
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 的开发者也针对这个问题有一番讨论。
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
后来 MDN 将响应码修改 204,于是 Kong 的开发者争论要不要和 MDN 保持同步。
争论的核心点在于:有没有迫切的必要。200 响应码运行得很好,似乎也将永远正常运行下去。而更换成 204,不确定是否有隐藏问题。
说到底,框架开发者还是依赖于浏览器的底层实现。在这个问题上,没有足够权威的资料能够支撑框架开发者,而各个知识点都散落在网络的各个角落,充斥着不完整的细节和部分解决方案,这些都让框架开发者非常困惑。
最后,Kong 的源码里预检响应码仍然是 200,并没有和 MDN 保持同步。
我仔细查看了各大主流网站,95%预检响应码是 200。而经过两个多月的测试,Nginx 配置预检响应码 204,在主流的浏览器 Chrome , Firefox , IE11 也没有出现任何问题。
所以,200 works everywhere , 而 204 在当前主流的浏览器里也得到非常好的支持。
5 Chrome: 非安全私有网络
本以为跨域问题就这样解决了。没想到还是有一个小插曲。
产品总监需要给客户做演示,我负责搞定演示环境。申请域名,准备阿里云服务器,应用打包,部署,一切都很顺利。
可是在公司内网访问演示环境,有一个页面一直报 CORS 报错,报错内容类似下图:
跨域的错误类型是:<font color="red">InsecurePrivateNetwork</font>。
这和原来遇到的跨域错误完全不一样,我心里一慌。马上 Google , 原来这是 chrome 更新到 94 之后新的特性,可以手工关闭这个特性。
打开 tab 页面 chrome://flags/#block-insecure-private-network-requests
将其 Block insecure private network requests 设置为
Disabled
, 然后重启就行了, 这样子就相当于把这个功能禁用掉。
但这样是治标不治本呀。有点诡异的是,当我们不在公司内网访问演示环境的时候,演示环境完全正常,出错的页面也能正常访问。
仔细看官方的文档,CORS-RFC1918 指出如下三种请求会受影响。
公共网络访问私有网络;
公共网络访问本地设备;
私有网络访问本地设备。
这样,我把问题定位在这个出错的第三方接口地址上。公司很多产品都依赖这个接口服务。当在公司内网访问的时候,该域名映射地址类似:172.16.xx.xx。
而这个 ip 正好是 rfc1918 上规定的私有网络。
内网通过 Chrome 访问这个页面的时候,会触发非安全私有网络拦截。
如何解决呢?官方给出的方案分两步走:
私有网络只能通过 Https 来访问;
未来,添加特定的预检头,比如说:Access-Control-Request-Private-Network 等。
当然还有一些临时方法:
关闭 Chrome 该特性;
换用其他浏览器比如 Firefox;
关闭网络内网开手机热点;
修改本地 host 绑定外网 ip。
基于官方的方案 ,生产环境完全使用 Https,公司内网访问就没有出现这样的跨域问题了。
6 复盘
API 网关非常适合当前产品的架构。架构设计之初,系统多端都会调用我司的 API 网关。API 网关可以 SAAS 部署和私有化部署,有单独的域名,提供完善的签名算法。考虑到上线时间节点,团队成员对于 API 网关的熟悉程度以及多套环境部署投入时间成本,为了尽快交付,从架构层面,我做了一些平衡和妥协。
接入层调用的接口域名统一使用 api.training.com
这个独立的域名,通过 Nginx 来配置请求转发。同时,我和前端 Leader 统一了前后端协议,保持和我司 API 网关一致,为后续切回 API 网关做前置准备。
API 网关可以做鉴权,限流,灰度等,同时可以配置 CORS。内部服务端不用特别关注跨域这个问题。
同时,在解决跨域的问题过程中,我的心态也发生了变化。从最初的轻视,到逐渐沉下心来,一步步理解 CORS 的原理,分清楚不同解决方案的优缺点,事情也就慢慢顺遂起来。 我也观察到:”有的项目组已经反馈过 Chrome 非安全私有网络问题,并给出了解决方案。对于技术管理者来讲,一定要重视项目中反馈的问题,做好梳理分析,整理预案。这样当同类问题出现时,也会条理有序“。
7 写到最后
2017 年,我参加左耳朵耗子陈皓老师技术演讲,他给我们讲了一个故事。
故事的大概是:“公司软件出现莫名 BUG,用户的费用扣了,但调用第三方接口的时候经常出现网络问题。公司当时最厉害的人查了一周也没有解决,而陈皓老师正在看《TCP/IP 详解》这本书, netstat
一看,连接的状态是 CLOSE_WAIT
,意思是对方断开了连接,大概率估计是对方系统的问题。于是他去了对方那边帮他们看了一下代码,果然是判断条件出了问题,导致应用直接断开了链接。而这个问题只花了不到两个小时就解决了”。
当我想起陈皓老师的这个故事,回顾自己的跨域之旅,我深深的觉得细节是魔鬼,而解决问题也许就在某个不经意的细节里。
如果我的文章对你有所帮助,还请帮忙点赞、在看、转发一下,你的支持会激励我输出更高质量的文章,非常感谢!
版权声明: 本文为 InfoQ 作者【makemyownlife】的原创文章。
原文链接:【http://xie.infoq.cn/article/5a9e645e5d35c4c23aa34341e】。文章转载请联系作者。
评论