原因:cors 请求未能成功_Cors最佳实践

基础知识

CORS(Cross-Origin Resource Sharing),跨域资源共享,是浏览器跨域的官方解决方案。相比其他常见的跨域解决方案(jsonp、iframe、postMessage),CORS具有以下优点:

  1. 前端代码优雅。CORS由浏览器和后台交互完成,前端开发者感受不到和同源通信的差别,代码完全一样;

  2. 规范标准,兼容性好,IE10以上都支持,浏览器之间几乎没有差异;

  3. 支持所有类型的HTTP请求,功能完善。(相比之下,jsonp只支持get,对RESTful风格接口很不友好);

  4. 跨平台统一。同一个接口可以同时供WEB和APP使用,不需额外处理;

  5. 错误信息可以被XMLHttpRequest的onerror捕获,便于调试。

原理

CORS不需要前端代码做任何处理,一切交互都由浏览器和服务端完成。请求分为两种:简单请求(simple request)和非简单请求(not-so-simple request)。符合以下3个条件即可使用简单请求:

  1. 请求方法是HEAD、GET或POST;

  2. Http Header只包含Accept、Accept-Language、Content-Language、Last-Event-ID、Content-Type,没有其他字段;

  3. Content-Type的值是application/x-www-form-urlencoded、multipart/form-data或text/plain。

不符合上述条件的任何一项,即作为非简单请求处理。非简单请求只是多加了一次OPTIONS请求,其余操作与简单请求一致。

简单请求

27c37474df2532754077aedf387b4f2d.png

浏览器发起简单请求的时候,会自动在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中的其他字段,需要把字段名配置在这里。

非简单请求

353c6ea63338a294f4cd4b86f8f740cd.png

浏览器发起非简单请求的时候,会先发起一次"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,须实现两个功能:

  1. 对OPTIONS预检请求正确响应;

  2. 对其他类型的所有请求附带正确的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:

<?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的最佳实践。

70bb328f0a466c333cf68960d61ae92d.png