Cors最佳实践

2019-04-21

基础知识

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请求,其余操作与简单请求一致。


简单请求CORS简单请求


 浏览器发起简单请求的时候,会自动在header中增加Origin字段,用来告知服务器本次请求来自哪个地址。字段的值完成包含“协议+域名+端口”,因为这三者任意一个跟接口地址不一致,都会造成跨域。这个字段是浏览器添加的,前端代码里请求既不需要、也没办法修改。



 服务器接收到请求之后,需要判断header的Origin字段。如果不在许可范围内,后台需要返回一个正常的、不带额外header的响应。浏览器发现缺少Access-Control-Allow-Origin字段,则抛出错误。注意,不管请求是否成功,http响应的状态码都可以是200,所以不能通过状态码去识别错误,只能通过XMLHttpRequest的onerror回调函数捕获错误。


如果Origin指定的域名在许可范围内,后台需要返回一个带有以下额外header的响应:


非简单请求CORS非简单请求

浏览器发起非简单请求的时候,会先发起一次"preflight"(预检)请求,请求的方法为OPTIONS。预检请求的header中包含以下两个字段:

由于这次请求是浏览器自动发起的,前端代码量既不需求、也没有办法控制,仅需要服务端去接收并做响应即可。只有服务端返回允许请求的响应,浏览器才会正式发送XMLHttpRequest请求,否则就报错,同样通过onerror回调函数捕获。


服务端对预检请求的响应,header中应带有以下字段:


预检请求完成以后,就可以正常发送真正的请求了,这个跟简单请求是完全一致的。


实践

根据上述原理,服务端想要支持CORS,须实现两个功能:

  1. 对OPTIONS预检请求正确响应;
  2. 对其他类型的所有请求附带正确的header。


这两个功能需要拦截http请求并修改响应,可以在Nginx或者框架路由中完成,而不用修改业务代码。以下提供三类常见的解决方案。


Nginx

通过Nginx即可实现CORS,不需要修改程序代码。Nginx配置可能会用到以下功能:


举例:

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




本文未经许可禁止转载,如需转载关注微信公众号【工程师加一】并留言。