跨域多方位解决方案

本次分享由趣头条cpc商业化技术部(周志祥-混元霹雳手)进行分享!欢迎你们投递简历加入趣头条,邮箱地址为qianjiongli@qutoutiao.net 期待您的加入。html

你了解跨域吗?

了解(continue) 不了解 (end)前端

为什么会产生跨域?

跨域问题来源于浏览器同源策略的限制问题致使的。vue

浏览器为什么要设置同源策略?

正是由于浏览器要出于安全考虑。若是缺乏了同源策略,浏览器很容易受到XSSCSRF等攻击。(XSSCSRF能够单独成为一个额外的知识点) 此时会致使一个域名下网页的操做就能够直接拿到另外一个非同域名下网页的任何信息,或者一个网页能够随意请求到不一样域名服务器下的接口数据。node

什么是同源策略?

同源策略是一种约定,这是浏览器核心的安全功能点之一。所谓的同源策略指的是【协议 + 域名 + 端口】三者相同,若是两个相同的域名指向同一个ip地址,也是非同源的状况。同时地址印射对应的ip二者也是非同源状况。web

同源策略会存在那些限制?

DOM节点ajax

对于DOM节点只能操做当前域名下网页打开的DOM节点内容。chrome

存储信息vue-cli

对于cookiesessionStoragelocalStorageindexedDB等存储信息也不能非同源获取express

ajax请求json

对于ajax网络请求时,请求处于非同域的状况下会被浏览器自动拦截报错。

举几个形成跨域的场景的例子?

前面说过当协议、域名、端口号中任意一个不相同时,都是跨域。一样包括(一级域名与二级域名的不一样) 互相请求资源的状况下是一种跨域状态。

跨域的地址场景图

经过什么方式能够解决跨域?

能够经过JSONP的原理

首先明白对于浏览器加载资源时能够经过:

  1. img
  2. script
  3. link

以上几个标签是容许跨域加载资源的。意思就是在www.baidu.com域名下静态html文件中的script标签能够加载wwww.google.com服务器下的脚本资源等。

经过以上标签能够加载跨域资源的理解,那咱们能够经过包装手段从其它域获取到指望的数据。

讲讲JSONP的实现原理?

以前已经有了原理的思路的铺垫。那就利用script标签这一容许跨域加资源的特性包装数据进行讲解。

实现流程

// index.html
// jsonp的实现模拟
function jsonp({ url, params, callback }) {
  return new Promise((resolve, reject) => {
    let script = document.createElement('script')
    window[callback] = function(data) {
      resolve(data)
      document.body.removeChild(script)
    }
    params = { ...params, callback }
    let arrs = []
    for (let key in params) {
      arrs.push(`${key}=${params[key]}`)
    }
    script.src = `${url}?${arrs.join('&')}`
    document.body.appendChild(script)
  })
}

// 调用方式
jsonp({
  url: 'http://localhost:3000/getUser',
  params: { name: 'peter' },
  callback: 'user'
}).then(data => {
  console.log(data)
})
复制代码

经过以上代码实现了一个基本的JSONP的调用执行代码。

  1. 声明一个JSONP的模拟函数, 传入的参数分别为请求地址请求参数先后端约定的包装函数名、 内部经过返回promise机制来优雅的解决数据返回的获取方式。

  2. 经过script不存在跨域请求资源的机制建立一个script临时标签。把向后台请求的地址和参数组合成query参数的形式。 请求地址: http://localhost:3000/getUser?name=peter&callback=user

关健点是把包装的函数名(key做为callback, value做为user) 包装函数名是先后端一个约定。

  1. 最后组装后的script标签插入到document文档中,此时浏览器就会自动向标地址发起请求。

后台返回的结果原理

// app.js 用express脚手架模拟的配合前台callback封装的返回结果

app.get('/getUser', function(req, res, next) {
  let { name, callback } = req.query
  console.log(name) // peter
  console.log(callback) // user
  res.send(`${callback}({
    code: 0,
    msg: '请求成功',
    data: {
      id: 1234
    }
  })`)
});
复制代码

后台会经过query参数进行解析。若是此时返回的结果是一个对象,对象中存在msg消息,请求状态码code,数据信息data

可能你会疑问为何返回的结果的值是放在一个user执行函数中。这就是JSONP的核心原理。回头再看看这段没有解释的代码段:

window[callback] = function(data) {
  resolve(data)
  document.body.removeChild(script)
}
复制代码

当执行本身封装的jsonp的方法的时候在全局定义一个函数。此函数名则是前端与后端约定的函数封装名。当后台返回结果时会执行约定好的全局函数。就是执行上方代码段, 数据参数会经过resolve执行返回。最后删除对应的请求script标签。

JSONP和AJAX对比,区别点在那里?

相同点:

JSONPajax二者相同点都是客户端向服务端发起请求。

不一样点:

JSONP属于利用script标签进行了非同源策略请求,而ajax是同源策略请求。

JSONP优缺点

优势:

JSONP的优势是兼容性很好。由于利用的是script标签能够非同源请求机制。这是每一个浏览器基础特性。

缺点:

只支持query参数的这种get请求方式,交互方式存在局限性。也容易受到xss的攻击。

若是后台不支持JSONP的封装方式怎么办?

能够经过CORS网络通讯技术。(全称Cross-Orgin Resource Sharing),对于CORS一样也须要先后端进行一个配合。可是关健点在于后台的配置。可能你会认为。即然是后台进行配置,为何前台也须要充分的了解。由于不管在生产仍是开发的模式下, 跨域首先对前端的影响面是最大的, 只有充分的了解才能向后台去表达后台才能准确的设置和进行配合。

简单的跨域请求须要建议后台进行什么设置?

前台模拟设置

先本地建立一个index.html写入请求脚本。经过http-server -p 4000启动在本地4000端口下。

// index.html
let url = 'http://localhost:3000/getUser';
let xhr = new XMLHttpRequest();
xhr.open('get', url, true);
xhr.send();
复制代码

后台模拟设置

经过express框架设置请求地址,服务启动在本地3000端口下。

// app.js
let express = require('express')
let app = express()

app.get('/getUser', function(req, res) {
  res.send({
    code: 0,
    msg: '请求成功',
    data: {
      id: 1234
    }
  })
})

app.listen(3000)
复制代码

浏览器返回结果

访问http://127.0.0.1:4000/index.html能够经过Network控制台能够看到浏览器端向后台http://localhost:3000/getUser服务接口地址发出请求。

若是Origin指定的源,不在许可范围内,服务器会返回一个正常的HTTP回应。浏览器发现,这个回应的头信息没有包含Access-Control-Allow-Origin字段,就知道出错了,从而抛出一个错误,被XMLHttpRequestonerror回调函数捕获。注意,这种错误没法经过状态码识别,由于HTTP回应的状态码有多是200。虽然返回的 Status Code 状态码是 200 OK,可是response响应头里并无返回指望的值。一样在console控制台能够发现:

Access to XMLHttpRequest at 'http://localhost:3000/getUser' from origin 'http://127.0.0.1:4000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
复制代码

CORS策略阻止了从http://127.0.0.1:4000访问http://localhost:3000/getuser处的XMLHttpRequest:请求的资源上没有'Access- control - allow-origin'头。

这就是一个最简单的CORS的安全策略,从报错能够很明显的明白你须要告诉后台须要设置'Access- control-allow-origin'头。

后台解决方案

// app.js中添加

app.use((req, res, next) => {
  res.setHeader('Access-Control-Allow-Origin', '*')
  // res.setHeader('Access-Control-Allow-Origin', req.headers.origin)
  next()
})

复制代码

在接收到请求时作一层中间件的过滤, 如下二者方式皆可。

  1. 返回时设置响应头的Access-Control-Allow-Origin*(表明全部域名向当前服务请求都容许跨域访问)
  2. 返回时设置响应头的Access-Control-Allow-Origin为指定的域名。其它域名都不容许进行一个跨域访问

设置Access-Control-Allow-Origin头就能够解决了全部的跨域问题了麻?

Access-Control-Allow-Origin头的设置仅仅只能解决简单的跨域请求

简单的跨域请求条件:

条件1: 只能容许如下的请求方法

  • GET
  • HEAD
  • POST

条件2: Content-Type容许条件

  • text/plain
  • multipart/form-data
  • application/x-www-form-urlencoded

条件3: 不能超过http的头信息如下字段

  • Accept
  • Accept-Language
  • Content-Language
  • Last-Event-ID

那其它请求方式如何解决?属于什么类型的跨域请求?

其它的请求方式被称之为复杂的跨域请求。一旦不符合简单跨域请求策略的时候那就是复杂的跨域请求:

复杂的跨域请求解释:

  1. 除了简单的跨域请求的方法。好比PUTDELETE
  2. 除了简单的跨域请求的Content-type类型。好比application/json
  3. 自定义的header
  4. 不一样域名下的cookie传输

尝试解决复杂跨域的几种状况

1.put、delete等请求方法形成复杂请求

// 修改请求方法
- xhr.open('get', url, true);
+ xhr.open('put', url, true);
复制代码
// 修改后台接收请求方法
app.put('/getUser') // 省略... 对于后台只是把get请求换成put接收请求
复制代码

在浏览器的netWork中发现并无发送put请求,在General中的Request Method发现发送了一个OPIONS的预检请求(关于预检后续会在解决跨域问题中经过关闭浏览器策略中专门介绍相关详细知识点)

同时浏览器中会被发出报错信息:

Access to XMLHttpRequest at 'http://localhost:3000/getUser' from origin 'http://127.0.0.1:4000' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. 复制代码

解决方案:

// 在app.use中添加新的设置头
// res.setHeader('Access-Control-Allow-Methods', '*')
res.setHeader('Access-Control-Allow-Methods', 'PUT')
复制代码

以上设置了接收容许那些请求方法:

  • 设置*, 表示全部请求方法都容许。
  • 设置对应的请求方法以逗号分隔。

2.content-type形成复杂请求

+ xhr.setRequestHeader('content-type', 'application/json');
复制代码

在以前谈论简单跨域请求条件二, 关于content-type类型对于简单的跨域请求只支持三种。设置其它的则会产生复杂的跨域请求。当设置content-type: application/json的状况下,一样的浏览器会发出报错信息:

Access to XMLHttpRequest at 'http://localhost:3000/getUser' from origin 'http://127.0.0.1:4000' has been blocked by CORS policy: Request header field content-type is not allowed by Access-Control-Allow-Headers in preflight response.
复制代码

从报错提示能够看出后台须要对复杂跨域请求content-type进行一个额外的设置:

// 在app.use中添加新的设置头
+ res.setHeader('Access-Control-Allow-Headers', 'content-type')
复制代码

3.自定义头形成复杂请求

+ xhr.setRequestHeader('X-Customer-Header', 'value');
复制代码

在以前谈论简单跨域请求条件三中, 除了以上几种http请求头以后,都属于自定义头。在请求带入时会形成复杂的跨域请求, 一样的浏览器会发出报错信息。

Access to XMLHttpRequest at 'http://localhost:3000/getUser' from origin 'http://127.0.0.1:4000' has been blocked by CORS policy: Request header field x-customer-header is not allowed by Access-Control-Allow-Headers in preflight response.
复制代码

一样的原理对于前台设置的自定义头后,后台在接收的时候一样也要进行容许设置接收前台自定义传输出来的自定义头。

res.setHeader('Access-Control-Allow-Headers', 'content-type, X-Customer-Header')
// res.setHeader('Access-Control-Allow-Headers', '*')
复制代码

Access-Control-Allow-Headers设置的时候,能够用逗号分隔,进行多个自定义头的设定。同时也能够传入*,容许所任何自定义头。

谈谈CROS中的cookie?

绝对同域的状况下

在绝对同域的状况下。前台向后台请求的接口或者请求文件的时候,会自动把cookie带入请求头中。

在非同域的状况下

在非同域的状况下。须要使用CORS的策略进行传输。默认状况下,cookie并不会带入请求头中,须要对xhr设置请求凭证。

xhr.withCredentials = true
复制代码

简单的跨域请求与cookie

若是此时是简单的跨域请求, 设置withCredentials = true的状况下。请求头中会带入cookie信息, 后台接收请求而且会发送到前台, 此时浏览器端从response中能够看到数据已经返回,可是并不能获取的后台返回的数据, 由于此时会被xhr的错误进行捕获,浏览器控制台会出现如下提示:

Access to XMLHttpRequest at 'http://localhost:3000/getUser' from origin 'http://localhost:4000' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true' when the request's credentials mode is 'include'. The credentials mode of requests initiated by the XMLHttpRequest is controlled by the withCredentials attribute. 复制代码

复杂的跨域请求

若是此时是复杂的跨域请求,设置withCredentials = true的状况下。此时会发送一个OPTIONS请求。浏览器发出的错误信息仍然是与简单的跨域请求报错一致。

解决方案

此时前台发送cookie凭证, 一样的后台同样须要赞成接收凭证。

res.setHeader('Access-Control-Allow-Credentials', true)
复制代码

反向原理:

若是后台赞成接收凭证。而前台没有设置发送凭证的状况下。就算后台发送到前台的响应头中设置了cookie信息(set-cookie头),不管是简单的跨域请求仍是复杂的跨域请求都会致使cookie塞入无效,能够查看appliation/cookie中, 不会有后台写入的cookie信息。

保持同源策略

为了安全问题。cookie本质上仍是保持了同源策略的模式。在先后台都设置了发送/接收凭证以后, 对于反回的origin头的设置res.setHeader('Access-Control-Allow-Origin', '*') 不能为*, 须要设置成指定请求的来源 res.setHeader('Access-Control-Allow-Origin', req.headers.origin)

合法组合与非法组合。

当设置Credentials的时候,后台须要知道Access-Control-Allow的合法与非法组合性。 一旦Access-Control-Allow-Credentials设置为true的时候, 此时如下几个不能设置为*, 须要进行指定, 不然如下三者一率视为无效设置。

  • Access-Control-Allow-Headers
  • Access-Control-Allow-Origin
  • Access-Control-Allow-Methods

CORS状况下如何在xhr中拿到响应头中的信息?

能够经过xhr.getResponseHeader方法进行获取。可是此方法只能拿到6个基本字段:Cache-ControlContent-LanguageContent-TypeExpiresLast-ModifiedPragma

在后台响应的时候能够响应头中塞入一些自定义的头和值。

res.setHeader('name', 'peter')
复制代码

在响应体的报文中能够看到:

Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: content-type, X-Customer-Header
Access-Control-Allow-Methods: PUT
Access-Control-Allow-Origin: http://localhost:4000
Connection: keep-alive
Content-Length: 50
Content-Type: application/json; charset=utf-8
Date: Sun, 17 Feb 2019 08:18:08 GMT
ETag: W/"32-oUKytSTXnBL0hnySFj9PpHgmBQk"
name: peter   // 重点在这里
X-Powered-By: Express
复制代码

经过报文能够发现返回的不少以前后台设置的信息和这里最关健的name头信息。可是经过如下方法测试以后结论:

xhr.onreadystatechange = function() {
  if (xhr.readyState === 4) {
    if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
      console.log(xhr.getResponseHeader('Content-Type'))
      console.log(xhr.getResponseHeader('name'))
    }
  }
}
复制代码

xhr返回成功以后。分别获取两个头信息。

  • Content-Type 则会返回 application/json; charset=utf-8

  • name 则会提示报错信息,而且返回null空值。

Refused to get unsafe header "name" // 拒绝获取不安全的头信息“name”
复制代码

能够明确的知识,除了以前提到的以上六种头信息能够进行获取以外,其他的一概都须要在后台进行容许那响应些头访问的设置。

res.setHeader('Access-Control-Expose-Headers', 'name')
复制代码

此时浏览器中报错信息不会存在,同时也能打印出name在响应头中的值。注意 若是设置的值为 * 则无效。须要对指定字段头进行设置。

复杂的跨域请求会形成每次请求都发送一个OPTIONS请求,如何解决?

经过以上的全部对复杂的跨域请求的分析清楚的认识到,那些请求方式会形成发送预检,一句话归纳,**Access-Control-Max-Age 这个响应首部表示 preflight request (预检请求)的返回结果(即 Access-Control-Allow-Methods 和Access-Control-Allow-Headers 提供的信息) 能够被缓存多久。**这样对network中的请求观察和请求性能来讲都不友好。若是作到友好又安全的机制。

对预检进行一个时间请求有效期

res.setHeader('Access-Control-Max-Age', 600)
复制代码

对预检请求设置10分钟的过时时间(时间能够根据项目状况进行自定义)。可是对于每一个浏览器的缓存时间机制都不同。在本地调试的时候,有时候你会发现设置了预检的过时时间并不生效。注意一下可能开启了浏览器的Disable cache致使了此缘由

在先后端联调时,不经过后端设置,如何解决跨域问题?

关闭浏览器跨域策略。

经过以前分析整个跨域模式是由前台浏览器的所做所为形成的。为了安全,浏览器对跨域请求作了一系列的验证。那是否能够想一想, 经过手动关闭浏览器跨域策略是否是能够解决根本性的问题。

Mac 建立一个chrome.sh文件

#!/bin/bash
#!/bin/sh 
open -a "Google Chrome" --args --disable-web-security  --user-data-dir

exit 0
复制代码

经过终端运行:

sh 加上chrome.sh文件地址
复制代码

注意: 在运行终端命令的时候,先检查是否已经启动过chrome,若是启动过须要手动关闭整个chrome的进程。

成功结果:

输入URL地址以后。全部的跨域问题会一并解决。

原理

虽然浏览器的跨域策略已经被关闭了。不存在任何浏览发送的跨域行为, 其内部原理正是由于浏览器会对简单的跨域请求作了拦截和复杂的跨域请求作了发送预检。

简单的跨域请求经过什么进行拦截?

在理解简单的跨域请求时先须要理解两个请求头的字段。

request请求头中的Origin

请求首部字段 Origin 指示了请求来自于哪一个站点。该字段仅指示服务器名称,并不包含任何路径信息。该首部用于 CORS 请求。

通俗的说就是告诉服务器此时是从那个域名地址发送来的。只有在CORS的状况下Origin才会在请求头中出现。

request请求头中的HOST

Host 请求头指明了服务器的域名(对于虚拟主机来讲),以及(可选的)服务器监听的TCP端口号。 若是没有给定端口号,会自动使用被请求服务的默认端口(好比请求一个HTTPURL会自动使用80端口)。 HTTP/1.1 的全部请求报文中必须包含一个Host头字段。若是一个 HTTP/1.1 请求缺乏Host 头字段或者设置了超过一个的 Host 头字段,一个400(Bad Request)状态码会被返回。

通俗的说就是浏览器向服务端发送请求时, 所请求的服务器的域名地址。

响应头中的Access-Control-Allow-Origin

响应头指定了该响应的资源是否被容许与前台请求头给定的origin共享。

结论

因此跨域请求返回浏览器以后。虽然数据会返回可是。浏览器会比对请求头中的Origin与响应头中的Access-Control-Allow-Origin是不是共享匹配,若是不匹配。浏览器的xhr会捕获错误而且在浏览器端控制台抛出错误。并不能拿到指望的数据。

复杂的请求浏览器是如何检测跨域的?

对于复杂的请求跨域, 浏览器一旦检测此发送的请求头存在属于复杂的跨域请求时, 首先会发送一个预请求, 请求头中包函着如下重要的内容:

  1. Access-Control-Request-Headers(若是有自定义头或者content-type类形不属于简单请求的类型的状况下才会出来)
  2. Access-Control-Request-Method(除了简单的请求方法才会出现)

而且在发送预检请求时并不会把请求数据和cookie信息带入请求信息中。

什么是预检请求?

CORS中会使用 OPTIONS 方法发起一个预检请求(preflight request), 以获知服务器是否容许该实际请求。"预检请求“的使用,能够避免跨域请求对服务器的用户数据产生未预期的影响。

当浏览器请求头中发出request-Header或者request-Method时。此时服务端须要赞成这两个请求头中对应的信息经过容许。须要在响应返回的时候对响应头作出响应处理。须要对Access-Control-Allow-MethodsAccess-Control-Allow-Headers设置。

原理图:

附带Credentials(身份凭证的)请求属于简单的跨域请求仍是复杂的跨域请求?

关于CredentialsCORS中原理性已经讲的很明白了。可是这里想讲的就是在xhrCredentials设置为true时。此时只是简单的跨域请求,不会发送预检(OPTIONS)请求, 若是此时是复杂的跨域请求。会发送预检(OPTIONS)请求。

因此Credentials是否会发送预检,主要须要经过其它请求头的断定来决定是否须要发送预检。

原理图:

总结:

只有当request请求头与response返回头一一对应上了。互相容许经过共享策略。对于简单的跨域请求则不会被捕获错误.对于复杂的跨域请求则会发送真正的请求。同时会把cookie等传输数据带入请求体中。因此说关闭浏览器跨域策略就是关闭了浏览器对响应Origin头匹配时再也不捕获,同时也会关闭对应的OPTIONS预检请求。直接发送给对应的后台服务器。因此说本质上虽然存在跨域,可是服务端永远是返回数据。一切的错误或者没有发送真正的请求都是浏览器的安全机制所为。

如何经过代理劫持机制解决跨域?

前面咱们已经知道浏览器向服务器请求是存在跨域问题,可是服务器向服务器发送请求是不存在跨域问题。经过MS(middle server)进行请求劫持以后,经过服务端向服务端发送请求,再二次返回给浏览器端。

示意图:

在各大框架中都经过脚手架启动node服务承载着项目。例如vue-cli中就利用了http-proxy-middle进行一个请求的代理拦截,向目标服务器发送请求来解决跨域问题。

// 经过express启用3000端口

// index.html
<script>
  let url = '/api/getUser';
  let xhr = new XMLHttpRequest();
  xhr.open('post', url, true);
  xhr.setRequestHeader('content-type', 'application/json');
  xhr.setRequestHeader('X-Customer-Header', 'value');
  xhr.send();
  xhr.onreadystatechange = function() {
    if (xhr.readyState === 4) {
      if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
        console.log(1)
        console.log(xhr.response)
      }
    }
  }
  
</script>


const proxyOption = {
	target: 'http://localhost:4000',
	pathRewrite: {
        '^/api/' : '/'  // 重写请求,api/解析为/
    },
    changeOrigin:true
};

app.use('/api', proxy(proxyOption))

复制代码
// 后台服务启动4000端口
app.post('/getUser', (req, res, next) => {
  res.send({
    code: 1
  })
})
复制代码

3000端口的静态文件发送ajax请求的时候,自己就是在一个域名下,不会形成任何跨域问题,同时会被app.use('/api/')捕获拦截,同时改写url地址向服务端4000端进行请求发送数据。此时就是server端与server端的请求通讯。当4000端口的server接收到请求以后把数据返回给3000端口的server端,同时再返回给请求的ajax

用node原生API如何实现?

app.use('/api', (req, res) => {
  const reqHttp = http.request({
    host: '127.0.0.1',
    path: '/getUser',
    port: '4000',
    method: req.method,
    headers: req.headers
  }, (resHttp) => {
    let body = ''
    resHttp.on('data', (chunk) => {
      console.log(chunk.toString())
      body += chunk
    });
    resHttp.on('end', () => {
      res.end(body)
    });
  })
  reqHttp.end()
});
复制代码

以上代码本质上是模拟了代理劫持的方式,同时当拦截到url开头以/api起始的请求以后,经过node原生http模块的request方法向对应的后台发送请求,同时把浏览器请求过来的一些请求体,请求头等数据一并传给server端。经过http模块监听的结束方法最后把数据再返回到client浏览器端。这样造成了二次转方式解决跨域问题。总体就是利用了服务端向服务发送请求不会有跨域策略的限制,就是所谓的同源策略。由于浏览器会作options等预检的检测,而服务端并不会。