基础知识
CORS(Cross-Origin Resource
Sharing),跨域资源共享,是浏览器跨域的官方解决方案。相比其他常见的跨域解决方案(jsonp、iframe、postMessage),CORS具有以下优点:
- 前端代码优雅。CORS由浏览器和后台交互完成,前端开发者感受不到和同源通信的差别,代码完全一样;
- 规范标准,兼容性好,IE10以上都支持,浏览器之间几乎没有差异;
- 支持所有类型的HTTP请求,功能完善。(相比之下,jsonp只支持get,对RESTful风格接口很不友好);
- 跨平台统一。同一个接口可以同时供WEB和APP使用,不需额外处理;
- 错误信息可以被XMLHttpRequest的onerror捕获,便于调试。
原理
CORS不需要前端代码做任何处理,一切交互都由浏览器和服务端完成。请求分为两种:简单请求(simple request)和非简单请求(not-so-simple request)。符合以下3个条件即可使用简单请求:
- 请求方法是HEAD、GET或POST;
- Http Header只包含Accept、Accept-Language、Content-Language、Last-Event-ID、Content-Type,没有其他字段;
- Content-Type的值是application/x-www-form-urlencoded、multipart/form-data或text/plain。
不符合上述条件的任何一项,即作为非简单请求处理。非简单请求只是多加了一次OPTIONS请求,其余操作与简单请求一致。
简单请求
浏览器发起简单请求的时候,会自动在header中增加Origin字段,用来告知服务器本次请求来自哪个地址。字段的值完成包含“协议+域名+端口”,因为这三者任意一个跟接口地址不一致,都会造成跨域。这个字段是浏览器添加的,前端代码里请求既不需要、也没办法修改。
服务器接收到请求之后,需要判断header的Origin字段。如果不在许可范围内,后台需要返回一个正常的、不带额外header的响应。浏览器发现缺少Access-Control-Allow-Origin字段,则抛出错误。注意,不管请求是否成功,http响应的状态码都可以是200,所以不能通过状态码去识别错误,只能通过XMLHttpRequest的onerror回调函数捕获错误。
如果Origin指定的域名在许可范围内,后台需要返回一个带有以下额外header的响应:
- Access-Control-Allow-Origin:必填。可以填请求时Origin字段的值,表示允许本次跨域请求,也可以填“*”,表示允许任意地址的请求。
- Access-Control-Allow-Credentials:选填。表示是否允许发送cookie,默认为false。
- Access-Control-Expose-Headers:选填。在跨域访问时,XMLHttpRequest对象的getResponseHeader()方法只能拿到一些最基本的字段(Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma),如果要获取header中的其他字段,需要把字段名配置在这里。
非简单请求
浏览器发起非简单请求的时候,会先发起一次"preflight"(预检)请求,请求的方法为OPTIONS。预检请求的header中包含以下两个字段:
- Access-Control-Request-Method: 必填。本次请求用到的方法。
- Access-Control-Request-Headers: 选填。本次请求会在header中额外附带的字段。
由于这次请求是浏览器自动发起的,前端代码量既不需求、也没有办法控制,仅需要服务端去接收并做响应即可。只有服务端返回允许请求的响应,浏览器才会正式发送XMLHttpRequest请求,否则就报错,同样通过onerror回调函数捕获。
服务端对预检请求的响应,header中应带有以下字段:
- Access-Control-Allow-Methods: 必填。本接口允许的请求方法。
- Access-Control-Allow-Headers: 选填。本接口允许的header字段。
- Access-Control-Allow-Credentials: 选填。表示是否允许发送cookie,默认为false。
- Access-Control-Max-Age: 选填,本次预检请求有效期,单位秒。有效期内重复发起请求时,不需要再发送预检请求。
预检请求完成以后,就可以正常发送真正的请求了,这个跟简单请求是完全一致的。
实践
根据上述原理,服务端想要支持CORS,须实现两个功能:
- 对OPTIONS预检请求正确响应;
- 对其他类型的所有请求附带正确的header。
这两个功能需要拦截http请求并修改响应,可以在Nginx或者框架路由中完成,而不用修改业务代码。以下提供三类常见的解决方案。
Nginx
通过Nginx即可实现CORS,不需要修改程序代码。Nginx配置可能会用到以下功能:
- $request_method:获取请求的方法,判断到'OPTIONS'则返回204;
- $http_origin:获取请求的地址(即http请求header里的origin字段);
- add_header:给响应增加头部。
举例:
server {
...
location / {
#处理预检请求
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Origin https://blog.oonne.com;
add_header Access-Control-Max-Age 600;
add_header Access-Control-Allow-Methods GET, POST, PUT, DELETE, OPTIONS;
add_header Access-Control-Allow-Headers 'Content-Type, x-auth-token';
add_header Content-Length 0 ;
return 204;
}
#其他请求添加头部
add_header Access-Control-Allow-Origin $http_origin;
add_header Access-Control-Allow-Credentials true;
add_header Access-Control-Expose-Headers Content-Length;
proxy_pass http://127.0.0.1:8080/;
}
}
备注:http响应码204表示成功但没数据,200表示成功,预检请求没有数据,正确的状态码应该是204。(虽然返回200前端也能正常处理)
Node.js
以Express为例,我们可以实现一个中间件:
app.use(function (req, res, next) {
if (req.method == 'OPTIONS') {
res.header('Access-Control-Allow-Origin', 'https://blog.oonne.com');
res.header('Access-Control-Max-Age', 1728000);
res.header('Access-Control-Allow-Methods', 'PUT, POST, GET, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, x-auth-token');
res.send(204);
} else {
res.header('Access-Control-Allow-Origin', 'https://blog.oonne.com');
res.header('Access-Control-Allow-Credentials', true);
res.header('Access-Control-Allow-Headers', 'Content-Type');
next();
}
});
PHP
Larave有CORS中间件,参考文档使用即可,原理跟Express差不多,这里不用多做介绍。
Yii2的路由模式比较特殊,需要修改Controller里的behaviors。我们先可以实现一个基础的Controller,其他Controller都继承他。然后在behaviors中定义一个corsFilter:
<?php
namespace frontend\controllers;
use Yii;
use yii\web\Response;
use frontend\filters\OptionsFilter;
use frontend\filters\CorsFilter;
class Controller extends \yii\rest\Controller
{
public function behaviors()
{
$behaviors = parent::behaviors();
$behaviors['contentNegotiator']['formats'] = [
'application/json' => Response::FORMAT_JSON
];
$behaviors['corsFilter'] = [
'class' => CorsFilter::className(),
'cors' => [
'Access-Control-Allow-Credentials' => true,
'Access-Control-Max-Age' => 3600,
'Access-Control-Request-Method' => ['POST', 'GET'],
'Access-Control-Request-Headers' => ['Content-Type', 'X-Auth-Token'],
'Origin' => Yii::$app->params['apiOrigin'],
]
];
$behaviors['verbFilter'] = [
'class' => OptionsFilter::className(),
'actions' => $this->verbs(),
];
return $behaviors;
}
}
其中,CorsFilter需要对OPTIONS预检请求做特殊处理,如下:
<?php
namespace frontend\filters;
use Yii;
use yii\filters\Cors;
class CorsFilter extends Cors
{
public function beforeAction($action)
{
$this->request = $this->request ?: Yii::$app->getRequest();
$this->response = $this->response ?: Yii::$app->getResponse();
$this->overrideDefaultSettings($action);
$requestCorsHeaders = $this->extractHeaders();
$responseCorsHeaders = $this->prepareHeaders($requestCorsHeaders);
$this->addCorsHeaders($this->response, $responseCorsHeaders);
// clear all options method
$verb = Yii::$app->getRequest()->getMethod();
if ($verb=='OPTIONS'){
$this->response->statusCode = 204;
$this->response->send();
return false;
}
return true;
}
}
同时,Controller里其他的behaviors配置,都需要保证对OPTIONS请求的正常返回。比如上面用到的OptionsFilter,用于过滤请求的方法的,我们需要保证所有的请求都允许OPTIONS方法,如下:
<?php
namespace frontend\filters;
use Yii;
use yii\filters\VerbFilter;
use yii\base\ActionEvent;
use yii\web\MethodNotAllowedHttpException;
class OptionsFilter extends VerbFilter {
/**
* @param ActionEvent $event
* @return bool
* @throws MethodNotAllowedHttpException when the request method is not allowed.
*/
public function beforeAction($event)
{
$action = $event->action->id;
if (isset($this->actions[$action])) {
$verbs = $this->actions[$action];
} elseif (isset($this->actions['*'])) {
$verbs = $this->actions['*'];
} else {
return $event->isValid;
}
//对OPTIONS请求做特殊处理
$verb = Yii::$app->getRequest()->getMethod();
if ($verb=='OPTIONS'){
return $event->isValid;
}
$allowed = array_map('strtoupper', $verbs);
if (!in_array($verb, $allowed)) {
$event->isValid = false;
Yii::$app->getResponse()->getHeaders()->set('Allow', implode(', ', $allowed));
throw new MethodNotAllowedHttpException('Method Not Allowed. This URL can only handle the following request methods: ' . implode(', ', $allowed) . '.');
}
return $event->isValid;
}
}
如果你在继承的Controller里用到了其他behaviors,也需要考虑预检请求的返回问题。
总结
本文介绍了CORS规范的原理,并给出了Nginx、Node.js、PHP的最佳实践。
本文未经许可禁止转载,如需转载关注微信公众号【工程师加一】并留言。