前后端分离中的请求跨域问题

现在的WEB应用很多情况下都是前后端分离的,使用RESTful架构很经常遇到的一个问题的就是跨域问题。
我们主要在这里讨论的是,在前端请求服务端接口的时候遇到的跨域的问题,不讨论前端之间的跨域互调。

什么是跨域

只要协议、域名、端口有任何一个不同,都被当作是不同的域。简单地理解就是因为JavaScript同源策略的限制,a.com 域名下的js无法操作b.com或是c.a.com域名下的对象。

URL 说明 是否允许通信
http://www.a.com/a.js
http://www.a.com/b.js
在同一域名下 允许
http://www.a.com/lab/a.js
http://www.a.com/script/b.js
同域名下不同文件夹 允许
http://www.a.com:8000/a.js
http://www.a.com/b.js
同域名下不同端口 不允许
http://www.a.com/a.js
https://www.a.com/b.js
同一域名,不同协议 不允许
http://www.a.com/a.js
http://70.32.92.74/b.js
域名和域名对应的IP 不允许
http://www.a.com/a.js
http://script.a.com/b.js
相同主域名,不同子域名 不允许
http://www.a.com/a.js
http://a.com/b.js
同一域名,不同二级域名(同上) 不允许
http://www.cnblogs.com/a.js
http://www.a.com/b.js
不同域名 不允许

同源策略

同源策略(Same origin policy)是一种约定,指的是浏览器对不同源的脚本或者文本的访问方式进行的限制。同源策略限制的不同源之间的交互主要针对的是js中的XMLHttpRequest等请求。
下面这些情况是完全不受同源策略限制的:

  • 页面中的链接
  • 重定向
  • 表单提交
  • 跨域资源嵌入(但是浏览器限制了Javascript不能读写加载的内容)

跨域资源共享 CORS

CORS 是 Cross Origin Resource Sharing 的缩写,定义了浏览器和服务器间共享内容的新方式,通过它浏览器和服务器可以安全地进行跨域访问,它是 JSONP 的现代继任者。服务器上的 CORS 配置可以精细地指定允许跨域访问的条件:来源域、HTTP 方法、请求头、内容类型……等等。并且,CORS 让 XMLHttpRequest 也可以跨域,我们可以像往常一样编写 AJAX 调用代码。

跨源资源共享标准通过新增一系列 HTTP 头,让服务器能声明哪些来源可以通过浏览器访问该服务器上的资源。另外,对那些会对服务器数据造成破坏性影响的 HTTP 请求方法(特别是 GET 以外的 HTTP 方法,或者搭配某些MIME类型的POST请求),标准强烈要求浏览器必须先以 OPTIONS 请求方式发送一个预请求(preflight request),从而获知服务器端对跨源请求所支持 HTTP 方法。在确认服务器允许该跨源请求的情况下,以实际的 HTTP 请求方法发送那个真正的请求。服务器端也可以通知客户端,是不是需要随同请求一起发送信用信息(包括 Cookies 和 HTTP 认证相关数据)。

简单请求

所谓的简单,是指:

  • 只使用 GET, HEAD 或者 POST 请求方法。如果使用 POST 向服务器端传送数据,则数据类型(Content-Type)只能是 application/x-www-form-urlencoded, multipart/form-data 或 text/plain中的一种。
  • 不会使用自定义请求头(类似于 X-Modified 这种)。
简单请求.png

上面的请求头中,Origin字段用来说明本次请求来自哪个源(协议 + 域名 + 端口)。服务器根据这个值,决定是否同意这次请求。
如果Origin指定的源不在允许的范围内(服务器配置),服务端就会返回一个正常的HTTP回应。接收到该回应的浏览器发现回应头没有包含Access-Control-Allow-Origin字段字段,就抛出一个异常。但是,这种错误无法通过状态码识别,因为HTTP回应的状态码有可能是200。
如果Origin指定的域名在许可范围内,服务器返回的响应,会多出几个以’Access-Control-开头’的头信息字段。

  • Access-Control-Allow-Origin:它的值要么是请求时Origin字段的值,要么是一个*,表示接受任意域名的请求。
  • Access-Control-Allow-Credentials:表示Cookie是否要包含在请求中发送给服务器。
  • Access-Control-Expose-Headers:CORS请求时,XMLHttpRequest对象的getResponseHeader()方法只能拿到6个基本字段:Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma。如果想拿到其他字段,就必须在Access-Control-Expose-Headers里面指定。也就是该字段是设置浏览器允许访问的服务器的头信息的白名单。

非简单请求

不满足“简单请求”条件的请求为非简单请求。
不同于简单请求,非简单请求的时候会在正式通信之前,增加一次HTTP查询请求,称为”预检”请求(preflight)。

预检请求

预请求”要求必须先发送一个 OPTIONS 请求给目的站点,来查明这个跨站请求对于目的站点是不是安全可接受的。这样做,是因为跨站请求可能会对目的站点的数据造成破坏。 当请求具备以下条件,就会被当成预请求处理:

  • 请求以 GET, HEAD 或者 POST 以外的方法发起请求。或者,使用 POST,但请求数据为 application/x-www-form-urlencoded, multipart/form-data 或者 text/plain 以外的数据类型。比如说,用 POST 发送数据类型为 application/json,application/xml 或者 text/xml 的 XML 数据的请求。
  • 使用自定义请求头(比如添加诸如 X-PINGOTHER)

也就是说,我们平常的时候的以json为请求体的post、put请求,以及delete方法的请求都会被进行预请求处理。
“预检”请求用的请求方法是OPTIONS,表示这个请求是用来询问的。

请求预处理.png

服务器回应预检请求的字段除了与“简单请求”的一样外,还有:

  • Access-Control-Allow-Methods:指明资源可以被请求的方式有哪些(一个或者多个)。这个响应头信息在客户端发出预检请求的时候会被返回。
  • Access-Control-Max-Age:这个头告诉我们这次预请求的结果的有效期是多久,单位是秒,在此期间,不用发出另一条预检请求。
  • Access-Control-Allow-Headers:指明在实际的请求中,可以使用哪些自定义HTTP请求头。

一旦服务器通过了”预检”请求,以后每次浏览器正常的CORS请求,就都跟简单请求一样,会有一个Origin头信息字段。服务器的回应,也都会有一个Access-Control-Allow-Origin头信息字段。

附带凭证信息的请求

默认情况下,CORS是不发送Cookie和HTTP验证信息的。如果想要把这些信息发送给服务器,那么,服务器必须Access-Control-Allow-Credentials:true;同时,Ajax请求的时候也必须加上xhr.withCredentials = true;
特别注意的是:如果要发送Cookie,Access-Control-Allow-Origin就不能设为星号,必须指定明确的、与请求网页一致的域名。同时,Cookie依然遵循同源政策,只有用服务器域名设置的Cookie才会上传,其他域名的Cookie并不会上传,且(跨源)原网页代码中的document.cookie也无法读取服务器域名下的Cookie。

一个例子

服务端以Spring MVC为例,客户端以fetch为例。

服务端:

public class WafCorsFilter extends OncePerRequestFilter {

    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    public static final String WAF_CORS_ALLOW_ORIGIN = "waf.cors.allow.origin";
    public static final String WAF_CORS_ALLOW_METHODS = "waf.cors.allow.methods";
    public static final String WAF_CORS_ALLOW_HEADERS = "waf.cors.allow.headers";
    public static final String WAF_CORS_MAX_AGE = "waf.cors.max.age";

    static{
        Properties defaultProperties = WafProperties.getDefaultProperties();
        defaultProperties.setProperty(WAF_CORS_ALLOW_ORIGIN, "*");
        defaultProperties.setProperty(WAF_CORS_ALLOW_METHODS, "GET, POST, HEAD, OPTIONS, PUT, DELETE, TRACE, PATCH");
        defaultProperties.setProperty(WAF_CORS_ALLOW_HEADERS, "Origin, Accept, X-Requested-With, Content-Type, Access-Control-Request-Method, Access-Control-Request-Headers, Authorization, Cache-control, Orgname, vorg");
        defaultProperties.setProperty(WAF_CORS_MAX_AGE, "1800");
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        logger.debug("WAF CorsFilter doFilter start");

        // 设定CORS的初始化参数
        // cors.allowed.origins *:Any origin is allowed to access the resource
        response.addHeader("Access-Control-Allow-Origin", WafProperties.getProperty(WAF_CORS_ALLOW_ORIGIN));
        // cors.allowed.methods Access-Control-Allow-Methods: A comma separated
        // list of HTTP methods that can be used to access the resource
        response.addHeader("Access-Control-Allow-Methods", WafProperties.getProperty(WAF_CORS_ALLOW_METHODS));
        // cors.allowed.headers Access-Control-Allow-Headers: A comma separated
        // list of request headers that can be used when making an actual
        // request
        response.addHeader("Access-Control-Allow-Headers", WafProperties.getProperty(WAF_CORS_ALLOW_HEADERS));
        // cors.preflight.maxage Access-Control-Max-Age The amount of seconds,
        // browser is allowed to cache the result of the pre-flight request
        response.addHeader("Access-Control-Max-Age", WafProperties.getProperty(WAF_CORS_MAX_AGE));

        filterChain.doFilter(request, response);
        logger.debug("WAF CorsFilter doFilter end");
    }
}

客户端:

/**
 * 请求处理
 * @param url url地址
 * @param body 请求数据
 * @param method 方法
 * @param withAuthToken 是否是有校验码
 * @returns {Promise.<T>}
 */
request(url, body, method = "GET", withAuthToken = true) {
    const _method = method.toUpperCase();
    let headers = {
        'Accept': 'application/json',
        'Content-Type': 'application/json; charset=UTF-8'
    }
    if (withAuthToken) {
        headers['Authorization'] = new AuthUtil().getAuthHeader(url, _method);
    }
    let settings = {
        method: _method,
        headers: headers
    }
    if (!['get', 'head'].includes(_method) && body) {
        settings['body'] = JSON.stringify(body);
    }
    return fetch(url, settings).then(response =>
        response.json().then(json => ({json, response}))
    ).then(({json, response}) => {
        if (!response.ok) {
            return Promise.reject(json);
        }
        return json;
    });
}