基础知识
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请求,其余操作与简单请求一致。
简单请求
![c2b8bdd23d45610d8941701131b24da8.png](https://img.php1.cn/3cd4a/189d8/978/7dbdf0f38ad53545.jpeg)
浏览器发起简单请求的时候,会自动在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中的其他字段,需要把字段名配置在这里。
非简单请求
![d68641d60a89f70378aca626b14d6e3c.png](https://img.php1.cn/3cd4a/1eebe/cd5/8343fdbffb0056b5.webp)
浏览器发起非简单请求的时候,会先发起一次"preflight"(预检)请求,请求的方法为OPTIONS。预检请求的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(); }});
也可以使用独立的CORS库,使用方法参考官方文档。
PHP
Larave有CORS中间件,参考文档使用即可,原理跟Express差不多,这里不用多做介绍。
Yii2的路由模式比较特殊,需要修改Controller里的behaviors。我们先可以实现一个基础的Controller,其他Controller都继承他。然后在behaviors中定义一个corsFilter:
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预检请求做特殊处理,如下:
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方法,如下:
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的最佳实践。
![c85e197d042379b81ab52683a968a484.png](https://img.php1.cn/3cd4a/1eebe/cd5/d942b7ec373849c3.webp)