WEB开发们都知道,出于安全原因,浏览器有个同源策略,不同源的客户端脚本在没有明确授权的情况下,不能读写对方资源。一个HTTP请求的URL的协议、域名、端口三者中的任何一个与当前源不同,则视为跨域请求。如果不做处理,我们看到chrome抛出一个错误:
而在实际的场景中,我们会有很多情况下需要进行跨域请求,所以跨域解决方案和其原理几乎是WEB开发必须要掌握的知识。下面列举几种常见的跨域方案:
跨域的几种解决方案
-
JSONP:这是跨域请求的一个经典方案,其主要原理是通过JS动态创建
<script>
标签获取指定资源,然后前后端约定一个callback来获取json数据,<script>
、<iframe>
这些具有src
属性的标签都是可直接跨域获取资源的,这种方式其实只是巧妙地绕过跨域限制,而且有其局限性,比如很明显的,只能发送GET请求,而且要判断请求是否失败也比较棘手。 - Proxy代理:由于同源策略只是浏览器的限制,服务器端并没有这个限制,所以只要A域客户端将请求发送一个代理服务器,然后由代理服务器去请求B域服务器就行了,比如前后端分离的工程,本地调试的时候我们启用nodejs代理服务、线上部署通过nginx代理转发等,都属于这个跨域模式。同样的,这个本质上也只是绕过浏览器的跨域限制而已。
- CORS(Cross-Origin Resource Sharing):跨域资源共享标准,本文重点研究对象。
CORS初尝试
假设现在服务端有个获取股票列表的接口,并已设置允许跨域(后文将介绍如何设置),其中
客户端地址:http://localhost:3000
服务端地址:http://localhost:7001
页面上设置了个按钮用以获取股票列表:
获取股票列表的前端代码:
axios({
url: 'http://localhost:7001/api/getStocks',
}).then((res) => {
const data = res.data;
this.setState((prevState) => ({
list: prevState.list.concat(data.data),
}));
});
此时发送的请求状态为:
可以看到请求直接成功并返回了数据,乍看之下除了Response Headers
多了一些Access-Control-Allow-*
字段外,和普通请求没什么区别。
过了段时间,出于安全角度考虑,现在要对这个接口进行token验证,,所以增加了一个请求头字段 access-token:
axios({
url: 'http://localhost:7001/api/getCounts',
headers: {
'access-token': 'abcdefg',
},
}).then((res) => {
const data = res.data;
this.setState({
count: data.data,
});
});
这时再查看请求的发送情况,奇怪的事情出现了,现在浏览器竟然发出去了两个请求!查看之后,会发现第一个请求方法为OPTIONS
,状态码为204,什么数据都没有返回!第二个请求才是我们真正想要的请求,GET
请求,且状态码为200,将股票列表返回了:
所以第一个
OPTIONS
请求是什么?为什么会发送这个请求?
CORS工作原理
CORS新增了一组 HTTP 首部字段,允许服务器声明哪些源站有权限访问哪些资源。另外,规范要求,对那些可能对服务器数据产生副作用的 HTTP 请求,浏览器必须首先使用 OPTIONS
方法发起一个预检请求(preflight request),从而获知服务端是否允许该跨域请求。预检请求头中Access-Control-Request-Method
字段告诉服务器实际请求的方法,Access-Control-Request-Headers
字段告知服务器实际请求中需要携带的自定义参数。服务器确认允许之后,才发起实际的 HTTP 请求。在预检请求的返回中,服务器端也可以通知客户端,是否需要携带身份凭证(包括 Cookies 和 HTTP 认证相关数据)。
简单请求和非简单请求
一般把无需发送OPTIONS
的请求叫做简单请求,把需要发送OPTIONS
的请求称为非简单请求或复杂请求;
其中简单请求必须满足以下几个条件(不满足所有下面条件的即为非简单请求):
- 请求方式只限于 GET、 HEAD、POST;
- 除以下头部信息外,不能自定义其他请求头字段 :
- Accept
- Accept-Language
- Content-Language
- Content-Type(需要注意额外的限制)
- Last-Event-ID
- Content-Type 的值只限于以下三种:
- text/plain
- multipart/form-data
- application/x-www-form-urlencoded
附带身份凭证的请求
CORS (还有Fetch )的一个有趣特性是,可以基于 HTTP cookies 和 HTTP 认证信息发送身份凭证。一般而言,对于跨域 XMLHttpRequest
或 Fetch
请求,浏览器不会发送身份凭证信息。如果要发送凭证信息,需要设置某个特殊标志位,例如我们的代码 axios 中可以加入withCredentials
字段表示跨域请求时需要携带凭证:
axios({
url: 'http://localhost:7001/api/getStocks',
withCredentials: true, // 设置携带凭证
}).then((res) => {
const data = res.data;
this.setState((prevState) => ({
list: prevState.list.concat(data.data),
}));
});
此时我们发送一个简单请求会发现一个奇怪的事情:
明明请求已经返回了数据,但是页面上并没有渲染出来,事实上此时Chrome浏览器已经在控制台出现了报错信息:
这是因为如果跨域请求想要附带身份凭证,必须在服务端设置Access-Control-Allow-Credentials
为true
,否则浏览器将不会把响应内容返回给请求的发送者。
另外,对于附带身份凭证的请求,服务器不得设置Access-Control-Allow-Origin
的值为*
。
CORS响应头字段
注:以下例子为NodeJs中Egg框架的设置方法(事实上,Egg框架中你会选择
egg-cors
插件进行跨域设置),不同语言和框架请参照各自的文档。
1. Access-Control-Allow-Origin
语法为:Access-Control-Allow-Origin: <origin> | *
,其中origin
参数的值指定了允许访问该资源的外域 URI,如果跨域请求中携带了cookie,则不能指定其值为*
。如:
ctx.set('Access-Control-Allow-Origin', 'http://localhost:3000');
2. Access-Control-Allow-Methods
语法为:Access-Control-Allow-Methods: <method>[, <method>]*
,用于预检请求的响应。其指明了实际请求所允许使用的 HTTP 方法。如:
ctx.set('Access-Control-Allow-Methods', 'GET, POST, PUT, OPTIONS, DELETE');
3. Access-Control-Allow-Headers
语法为:Access-Control-Allow-Headers: <field-name>[, <field-name>]*
,用于预检请求的响应。其指明了实际请求中允许携带的首部字段。如:
ctx.set('Access-Control-Allow-Headers', 'Content-Type, access-token');
4. Access-Control-Allow-Credentials
指定了当浏览器的credentials
设置为true
时是否允许浏览器读取response的内容。当用在对preflight
预检请求的响应中时,它指定了实际的请求是否可以使用credentials
。请注意:简单GET
请求不会被预检;如果对此类请求的响应中不包含该字段,这个响应将被忽略掉,并且浏览器也不会将相应内容返回给网页。
如:
ctx.set('Access-Control-Allow-Credentials', true);
5. Access-Control-Expose-Headers
在跨域访问时,XMLHttpRequest
对象的getResponseHeader()
方法只能拿到一些最基本的响应头:Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma。如果要访问其他头,则需要服务器设置本响应头。如:
ctx.set('Access-Control-Expose-Headers', 'access-token');
6. Access-Control-Max-Age
语法为:Access-Control-Max-Age: <delta-seconds>
,指定了preflight
请求的结果能够被缓存多久(单位:秒)。在有效时间内,浏览器无须为同一请求再次发起预检请求。请注意,浏览器自身维护了一个最大有效时间,如果该首部字段的值超过了最大有效时间,将不会生效。如:
ctx.set('Access-Control-Max-Age', 86400); // 86400秒内,即24小时内都有效
CORS请求头字段
1. Origin
origin 参数的值为源站 URI。它不包含任何路径信息,只是服务器名称。
2. Access-Control-Request-Method
用于预检请求。其作用是,将实际请求所使用的 HTTP 方法告诉服务器。
3. Access-Control-Request-Headers
用于预检请求。其作用是,将实际请求所携带的首部字段告诉服务器。
源码(Egg框架)
- router:
'use strict';
module.exports = app => {
const { router, controller } = app;
router.get('/', controller.home.index);
router.get('/api/getStocks', controller.home.getStocks);
};
- controller:
'use strict';
const Controller = require('egg').Controller;
class HomeController extends Controller {
async index() {
this.ctx.body = 'hello world';
}
async getStocks() {
const { ctx } = this;
const stocks = [{
name: '上证指数',
code: '1A0001'
}, {
name: '万科A',
code: '000002'
}, {
name: '滨江集团',
code: '002244'
}];
ctx.body = {
code: 0,
message: 'success',
data: stocks,
};
}
}
module.exports = HomeController;
- config/plugin
'use strict';
/** @type Egg.EggPlugin */
exports.validate = {
enable: true,
package: 'egg-validate',
};
exports.cors = {
enable: true,
package: 'egg-cors',
}
- config/config.default
'use strict';
/**
* @param {Egg.EggAppInfo} appInfo app info
*/
module.exports = appInfo => {
/**
* built-in config
* @type {Egg.EggAppConfig}
**/
const config = exports = {};
config.keys = appInfo.name + '_1574314669249_9332';
config.middleware = ['errorHandler'];
config.cors = {
origin: 'http://localhost:3000',
allowMethods: 'GET, HEAD, PUT, POST, DELETE, PATCH, OPTIONS',
allowHeaders: 'access-token',
credentials: true,
};
config.security = {
// 关闭csrf验证
csrf: {
enable: false,
},
// 白名单
domainWhiteList: ['*']
};
const userConfig = {
myAppName: 'cors',
};
return {
...config,
...userConfig,
};
};