ASP.NET MVC & WebApi 中实现Cors来让Ajax可以跨域访问

什么是Cors

CORS是一个W3C标准,全称是”跨域资源共享”(Cross-origin resource sharing)。
它允许浏览器向跨源服务器,发出XMLHttpRequest请求,从而克服了AJAX只能同源使用的限制。
本文详细介绍CORS的内部机制。

一、简介

CORS需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能,IE浏览器不能低于IE10。

整个CORS通信过程,都是浏览器自动完成,不需要用户参与。对于开发者来说,CORS通信与同源的AJAX通信没有差别,代码完全一样。浏览器一旦发现AJAX请求跨源,就会自动添加一些附加的头信息,有时还会多出一次附加的请求,但用户不会有感觉。

因此,实现CORS通信的关键是服务器。只要服务器实现了CORS接口,就可以跨源通信。

所以简单来说Ajax不能够跨域,完全是一个浏览器行为,其实Web服务器程序(比如ASP.NETPHP等)在默认情况下是无法辨别也不会去管到来的一个Http请求是不是一个跨域的Ajax请求,所谓的Ajax请求无法跨域完全是一个浏览器机制,是浏览器阻止了Ajax的跨域请求。而CORS正是用来解决这个问题的,W3C定制CORS标准给予了浏览器一种机制来允许Ajax的跨域请求。

二、两种请求

浏览器将CORS请求分成两类:简单请求(simple request)和非简单请求(not-so-simple request)。

只要同时满足以下两大条件,就属于简单请求。

(1) 请求方法是以下三种方法之一:

  • HEAD
  • GET
  • POST

(2)HTTP的头信息不超出以下几种字段:

  • Accept
  • Accept-Language
  • Content-Language
  • Last-Event-ID
  • Content-Type:只限于三个值application/x-www-form-urlencoded、multipart/form-data、text/plain

凡是不同时满足上面两个条件,就属于非简单请求。

浏览器对这两种请求的处理,是不一样的。

三、简单请求

3.1 基本流程

对于简单请求,浏览器直接发出CORS请求。具体来说,就是在头信息之中,增加一个Origin字段。

下面是一个例子,浏览器发现这次跨源AJAX请求是简单请求,就自动在头信息之中,添加一个Origin字段。

GET /cors HTTP/1.1
Origin: http://api.bob.com
Host: api.alice.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

上面的头信息中,Origin字段用来说明,本次请求来自哪个源(协议 + 域名 + 端口)。服务器根据这个值,决定是否同意这次请求。

如果Origin指定的源,不在许可范围内,服务器会返回一个正常的HTTP回应。浏览器发现,这个回应的头信息没有包含Access-Control-Allow-Origin字段(详见下文),就知道出错了,从而抛出一个错误,被XMLHttpRequestonerror回调函数捕获。注意,这种错误无法通过状态码识别,因为HTTP回应的状态码有可能是200

如果Origin指定的域名在许可范围内,服务器返回的响应,会多出几个头信息字段。

Access-Control-Allow-Origin: http://api.bob.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: FooBar
Content-Type: text/html; charset=utf-8

上面的头信息之中,有三个与CORS请求相关的字段,都以Access-Control-开头。

(1)Access-Control-Allow-Origin

该字段是必须的。它的值要么是请求时Origin字段的值,要么是一个*,表示接受任意域名的请求。

(2)Access-Control-Allow-Credentials

该字段可选。它的值是一个布尔值,表示是否允许发送Cookie。默认情况下,Cookie不包括在CORS请求之中。设为true,即表示服务器明确许可,Cookie可以包含在请求中,一起发给服务器。这个值也只能设为true,如果服务器不要浏览器发送Cookie,删除该字段即可。

(3)Access-Control-Expose-Headers

该字段可选。CORS请求时,XMLHttpRequest对象的getResponseHeader()方法只能拿到6个基本字段:Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma。如果想拿到其他字段,就必须在Access-Control-Expose-Headers里面指定。上面的例子指定,getResponseHeader('FooBar')可以返回FooBar字段的值。

3.2 withCredentials 属性

上面说到,CORS请求默认不发送CookieHTTP认证信息。如果要把Cookie发到服务器,一方面要服务器同意,指定Access-Control-Allow-Credentials字段。

Access-Control-Allow-Credentials: true

另一方面,开发者必须在AJAX请求中打开withCredentials属性。

var xhr = new XMLHttpRequest();
xhr.withCredentials = true;

否则,即使服务器同意发送Cookie,浏览器也不会发送。或者,服务器要求设置Cookie,浏览器也不会处理。

但是,如果省略withCredentials设置,有的浏览器还是会一起发送Cookie。这时,可以显式关闭withCredentials

xhr.withCredentials = false;

需要注意的是,如果要发送CookieAccess-Control-Allow-Origin就不能设为星号,必须指定明确的、与请求网页一致的域名。同时,Cookie依然遵循同源政策,只有用服务器域名设置的Cookie才会上传,其他域名的Cookie并不会上传,且(跨源)原网页代码中的document.cookie也无法读取服务器域名下的Cookie

四、非简单请求

4.1 预检请求

非简单请求是那种对服务器有特殊要求的请求,比如请求方法是PUTDELETE,或者Content-Type字段的类型是application/json

非简单请求的CORS请求,会在正式通信之前,增加一次HTTP查询请求,称为”预检”请求(preflight)。

浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些HTTP动词和头信息字段。只有得到肯定答复,浏览器才会发出正式的XMLHttpRequest请求,否则就报错。

下面是一段浏览器的JavaScript脚本。

var url = 'http://api.alice.com/cors';
var xhr = new XMLHttpRequest();
xhr.open('PUT', url, true);
xhr.setRequestHeader('X-Custom-Header', 'value');
xhr.send();

上面代码中,HTTP请求的方法是PUT,并且发送一个自定义头信息X-Custom-Header

浏览器发现,这是一个非简单请求,就自动发出一个”预检”请求,要求服务器确认可以这样请求。下面是这个”预检”请求的HTTP头信息。

OPTIONS /cors HTTP/1.1
Origin: http://api.bob.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header
Host: api.alice.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

“预检”请求用的请求方法是OPTIONS(请注意:这就是为什么ASP.NET MVCWebApi的路由无法解析CORS预检请求到ControllerAction的原因,因为ASP.NET MVCWebApiRoute只会对常规的Http方法:Get,Put,Delete,Post请求生效,而OPTIONS方法的预检请求并不会被ASP.NET MVCWebApi的路由处理),表示这个请求是用来询问的。头信息里面,关键字段是Origin,表示请求来自哪个源。

除了Origin字段,”预检”请求的头信息包括两个特殊字段。

(1)Access-Control-Request-Method

该字段是必须的,用来列出浏览器的CORS请求会用到哪些HTTP方法,上例是PUT

(2)Access-Control-Request-Headers

该字段是一个逗号分隔的字符串,指定浏览器CORS请求会额外发送的头信息字段,上例是X-Custom-Header

4.2 预检请求的回应

服务器收到”预检”请求以后,检查了OriginAccess-Control-Request-MethodAccess-Control-Request-Headers字段以后,确认允许跨源请求,就可以做出回应。

HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2.0.61 (Unix)
Access-Control-Allow-Origin: http://api.bob.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Content-Type: text/html; charset=utf-8
Content-Encoding: gzip
Content-Length: 0
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain

上面的HTTP回应中,关键的是Access-Control-Allow-Origin字段,表示http://api.bob.com可以请求数据。该字段也可以设为星号,表示同意任意跨源请求。

Access-Control-Allow-Origin: *

如果浏览器否定了”预检”请求,会返回一个正常的HTTP回应,但是没有任何CORS相关的头信息字段。这时,浏览器就会认定,服务器不同意预检请求,因此触发一个错误,被XMLHttpRequest对象的onerror回调函数捕获。控制台会打印出如下的报错信息。

XMLHttpRequest cannot load http://api.alice.com.
Origin http://api.bob.com is not allowed by Access-Control-Allow-Origin.

服务器回应的其他CORS相关字段如下。

Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 1728000

(1)Access-Control-Allow-Methods

该字段必需,它的值是逗号分隔的一个字符串,表明服务器支持的所有跨域请求的方法。注意,返回的是所有支持的方法,而不单是浏览器请求的那个方法。这是为了避免多次”预检”请求。

(2)Access-Control-Allow-Headers

如果浏览器请求包括Access-Control-Request-Headers字段,则Access-Control-Allow-Headers字段是必需的。它也是一个逗号分隔的字符串,表明服务器支持的所有头信息字段,不限于浏览器在”预检”中请求的字段。

(3)Access-Control-Allow-Credentials

该字段与简单请求时的含义相同。

(4)Access-Control-Max-Age

该字段可选,用来指定本次预检请求的有效期,单位为秒。上面结果中,有效期是20天(1728000秒),即允许缓存该条回应1728000秒(即20天),在此期间,不用发出另一条预检请求。

4.3 浏览器的正常请求和回应

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

下面是”预检”请求之后,浏览器的正常CORS请求。

PUT /cors HTTP/1.1
Origin: http://api.bob.com
Host: api.alice.com
X-Custom-Header: value
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

上面头信息的Origin字段是浏览器自动添加的。

下面是服务器正常的回应。

Access-Control-Allow-Origin: http://api.bob.com
Content-Type: text/html; charset=utf-8

上面头信息中,Access-Control-Allow-Origin字段是每次回应都必定包含的。

五、与JSONP的比较

CORSJSONP的使用目的相同,但是比JSONP更强大。

JSONP只支持GET请求,CORS支持所有类型的HTTP请求。JSONP的优势在于支持老式浏览器,以及可以向不支持CORS的网站请求数据。


如何在ASP.NET MVCWebApi中响应Cors的请求?

一、ActionFilter OR HttpMessageHandler

通过上面针对W3CCORS规范的介绍,我们知道跨域资源共享实现的途径就是资源的提供者利用预定义的响应报头表明自己是否将提供的资源授权给了客户端JavaScript程序,而支持CORS的浏览器利用这些响应报头决定是否允许JavaScript程序操作返回的资源。对于ASP .NET Web API来说,如果我们具有一种机制能够根据预定义的资源授权规则自动生成和添加针对CORS的响应报头,那么资源的跨域共享就迎刃而解了。

那么如何利用ASP.NET Web API的扩展实现针对CORS响应报头的自动添加呢?可能有人首先想到的是利用HttpActionFilter在目标Action方法执行之后自动添加CORS响应报头。这种解决方案对于简单跨域资源请求是没有问题的,但是不要忘了:对于非简单跨域资源请求,浏览器会采用“预检(Preflight)”机制。目标Action方法只会在处理真正跨域资源请求的过程中才会执行,但是对于采用“OPTIONS”作为HTTP方法的预检请求,根本找不到匹配的目标Action方法。

为了能够有效地应付浏览器采用的预检机制,我们只能在ASP.NET Web API的消息处理管道级别实现对提供资源的授权检验和对CORS响应报头的添加。我们只需要为此创建一个自定义的HttpMessageHandler即可,不过在此之前我们先来介绍用于定义资源授权策略的CorsAttribute特性。

二、使用ActionFilter实现简单跨域请求的处理

基于ActionFilter的简单跨域访问设置,定义了一个ActionAllowOriginAttribute,继承于ActionFilterAttribute,代码如下:

 public class ActionAllowOriginAttribute : ActionFilterAttribute
 {
        public string[] AllowSites { get; set; }
        public override void OnActionExecuting(System.Web.Mvc.ActionExecutingContext filterContext)
        {
            AllowOriginAttribute.onExcute(filterContext, AllowSites);
            base.OnActionExecuting(filterContext);
        }
 }

核心代码其实很简单,就这么几行,主要生成CORS响应中规定的Http Header : Access-Control-Allow-Origin

public class AllowOriginAttribute
{
    public static void onExcute(ControllerContext context, string[] AllowSites)
    {
        var origin = context.HttpContext.Request.Headers["Origin"];
        Action action = () =>
        {
            context.HttpContext.Response.AppendHeader("Access-Control-Allow-Origin", origin);

        };
        if (AllowSites != null && AllowSites.Any())
        {
            if (AllowSites.Contains(origin))
            {
                action();
            }
        }

    }
}

二、使用自定义的HttpMessageHandler实现非简单跨域请求的处理

CORS授权检验

在介绍自定义HttpMessageHandler之前,我们先看一下微软官方定义的Asp.Net WebApi CORS处理类System.Web.Http.Cors.CorsMessageHandler的处理流程:
这里写图片描述
实现在System.Web.Http.Cors.CorsMessageHandler中的具体CORS授权检验流程基本上体现在上图中。它首先根据表示当前请求的HttpRequestMessage对象创建CorsRequestContext对象。然后利用注册的CorsProviderFactory得到对应的CorsProvider对象,并利用后者得到针对当前请求的资源授权策略,这是一个CorsPolicy对象。

接下来,CorsMessageHandler会获取注册的CorsEngine。此前得到的CorsRequestContextCorsPolicy对象会作为参数调用CorsEngineEvaluatePolicy方法,CORS资源授权检验由此开始。授权检验结束之后,CorsMessageHandler会得到表示检验结果的CorsResult对象。

对于预检请求,CorsMessageHandler会直接创建HttpResponseMessage对象予以响应。具体来说,如果预检请求通过了授权检验,一个状态为“200, OK”的HttpResponseMessage会被创建出来,通过CorsResult得到CORS响应报头会被添加到这个HttpResponseMessage对象的报头集合中。如果授权检验失败,创建的HttpResponseMessage具有的状态为“400, Bad Request”,CorsResult携带的错误响应会作为响应的主体内容。

对于非预检请求,它会将当前请求传递给消息处理管道的后续部分进行进一步处理,并最终得到表示响应消息的HttpResponseMessage。只有在请求通过授权检查的情况下,由CorsResult得到的CORS响应报头才会被添加到此HttpResponseMessage的报头集合中。

实例演示:创建MyCorsMessageHandler模拟具体采用的授权检验

针对简单和非简单跨域资源共享的实现最终体现在具有如下定义的MyCorsMessageHandler类型上,它直接继承自DelegatingHandler,用于模拟System.Web.Http.Cors.CorsMessageHandler的实现机制。


public class MyCorsMessageHandler : DelegatingHandler
{
    protected async override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        //根据当前请求创建CorsRequestContext
        CorsRequestContext context = request.CreateCorsRequestContext();

        //针对非预检请求:将请求传递给消息处理管道后续部分继续处理,并得到响应
        HttpResponseMessage response = null;
        if (!context.IsPreflight)
        {
            response = await base.SendAsync(request, cancellationToken);
        }

        //利用注册的CorsPolicyProviderFactory得到对应的CorsPolicyProvider
        //借助于CorsPolicyProvider得到表示CORS资源授权策略的CorsPolicy
        HttpConfiguration configuration = request.GetConfiguration();
        CorsPolicy policy = await configuration.GetCorsPolicyProviderFactory().GetCorsPolicyProvider(request)
            .GetCorsPolicyAsync(request, cancellationToken);

        //获取注册的CorsEngine
        //利用CorsEngine对请求实施CORS资源授权检验,并得到表示检验结果的CorsResult对象
        ICorsEngine engine = configuration.GetCorsEngine();
        CorsResult result = engine.EvaluatePolicy(context, policy);

        //针对预检请求
        //如果请求通过授权检验,返回一个状态为“200, OK”的响应并添加CORS报头
        //如果授权检验失败,返回一个状态为“400, Bad Request”的响应并指定授权失败原因
        if (context.IsPreflight)
        {
            if (result.IsValid)
            {
                response = new HttpResponseMessage(HttpStatusCode.OK);
                response.AddCorsHeaders(result);
            }
            else
            {
                response = request.CreateErrorResponse(HttpStatusCode.BadRequest,
                    string.Join(" |", result.ErrorMessages.ToArray()));
            }
        }
        //针对非预检请求
        //CORS报头只有在通过授权检验情况下才会被添加到响应报头集合中
        else if (result.IsValid)
        {
            response.AddCorsHeaders(result);
        }
        return response;
    }
}

如上面的代码片断所示,我们首选在实现的SendAsync方法中调用自定义的扩展方法CreateCorsRequestContext根据表示当前请求的HttpRequestMessge对象创建出表示针对CORS的跨域资源请求上下文的CorsRequestContext对象。

然后我们根据CorsRequestContextIsPreflight属性判断当前是否是一个预检请求。对于预检请求,我们会直接调用基类的同名方法将请求传递给消息处理管道的后续环节作进一步处理,并最终得到表示响应的HttpResponse对象。如下是CorsRequestContext的IsPreflight属性的实现代码,主要就是根据判断Http请求中是否有OriginAccess-Control-Request-Method两个Http Header,以及Http方法是否是OPTIONS

public class CorsRequestContext
{
    //CorsRequestContext的其它代码....

    public bool IsPreflight
    {
        get
        {
            var request = HttpContext.Current.Request;

            return request.HttpMethod == HttpMethod.Options.ToString() &&
                   request.Headers.GetValues("Origin").Any() &&
                   request.Headers.GetValues("Access-Control-Request-Method").Any();
        }
    }
}

我们接下来从表示当前请求的HttpRequestMessge对象中直接获取当前HttpConfiguration对象,并调用扩展方法GetCorsPolicyProviderFactory得到注册在它上面的CorsPolicyProviderFactory,进而得到由它提供的GetCorsPolicyProvider。通过调用此GetCorsPolicyProvider的方法GetCorsPolicyAsync,我们会得到目标Action方法采用的CORS资源授权策略,这是一个CorsPolicy对象。

在这之后,我们调用HttpConfiguration对象的另一个扩展方法GetCorsEngine得到注册其上的CorsEngine,并将此前得到的CorsRequestContextCorsPolicy对象作为参数调用它的方法EvaluatePolicy由此开始针对当前请求的CORS资源授权检验,并最终得到表示检验结果的CorsResult

通过CorsResultIsValid属性表示当前请求是否通过CORS资源授权检验。对于预检请求,在请求通过授权检验的情况下,我们会创建一个状态为“200, OK”的HttpResponseMessage作为最终的响应,在返回之前我们调用自定义的扩展方法AddCorsHeaders将从CorsResult得到的CORS响应报头添加到此HttpResponseMessage的报头集合中。如果请求没有通过授权检验,我们会返回一个状态为“400, Bad Request”的响应,通过CorsResultErrorMessage属性提取的错误消息(表示授权失败的原因)会作为响应的主体内容。

对于非预检请求来说,只有在它通过了资源授权检验的情况下,我们才会调用扩展方法AddCorsHeaders将从CorsResult得到的CORS报头添加响应的报头集合中。换句话说,对于未取得授权的非预检跨域资源请求,MyCorsMessageHandler没有对响应作任何的改变。

如下所示的是分别针对HttpRequestMessageHttpResponseMessage定义的两个扩展方法,其中CreateCorsRequestContext方法根据HttpRequestMessage创建CorsRequestContext对象,而AddCorsHeaders方法则将从CorsResult中获取的CORS响应报头添加到指定的HttpResponseMessage中。

public static class CorsExtensions
{
    public static CorsRequestContext CreateCorsRequestContext(this HttpRequestMessage request)
    {
        CorsRequestContext context = new CorsRequestContext
        {
            RequestUri = request.RequestUri,
            HttpMethod = request.Method.Method,
            Host = request.Headers.Host,
            Origin = request.GetHeader("Origin"),
            AccessControlRequestMethod = request.GetHeader("Access-Control-Request-Method")
        };

        string requestHeaders = request.GetHeader("Access-Control-Request-Headers");
        if (!string.IsNullOrEmpty(requestHeaders))
        {
            Array.ForEach(requestHeaders.Split(','), header => context.AccessControlRequestHeaders.Add(header.Trim()));
        }
        return context;
    }

    public static void AddCorsHeaders(this HttpResponseMessage response, CorsResult result)
    {
        foreach (var item in result.ToResponseHeaders())
        {
            response.Headers.TryAddWithoutValidation(item.Key, item.Value);
        }
    }

    private static string GetHeader(this HttpRequestMessage request, string name)
    {
        IEnumerable<string> headerValues;
        if (request.Headers.TryGetValues(name, out headerValues))
        {
            return headerValues.FirstOrDefault();
        }
        return null;
    }
}

为了验证我们这个用于模拟CorsMessageHandler的自定义HttpMessageHandler是否能够真正为ASP.NET Web API提供针对CORS的支持,我们通过上面介绍的方式为WebApi应用安装“Microsoft ASP.NET Web API 2 Cross-Origin Support”这个NuGet包后,将EnableCorsAttribute特性应用到定义在ContactsController上并作如下的设置。

[EnableCors("http://localhost:9527", "*", "*")]
public class ContactsController : ApiController
{
    public IHttpActionResult GetAllContacts()
    {
        //省略实现
    }
}

Global.asax中,我们并不调用当前HttpConfigurationEnableCors方法开启ASP.NET Web API针对CORS的支持,而是采用如下的方式将创建的CorsMessageHandler对象添加到消息处理管道中。如果现在运行ASP.NET MVC程序,通过调用Web API以跨域Ajax请求得到的联系人列表依然会显示在浏览器上。

public class WebApiApplication : System.Web.HttpApplication
{
    protected void Application_Start()
    {
        GlobalConfiguration.Configuration.MessageHandlers.Add(new MyCorsMessageHandler());
        //其他操作
    }
}

HttpConfigurationEnableCors方法

通过上面的介绍我们知道针对ASP.NET Web APICORS编程首先需要做的就是在程序启动之前调用当前HttpConfiguration的扩展方法EnableCors开启对CORS的支持,那么该方法中具体实现了怎样操作呢?由于ASP.NET Web API针对CORS的支持最终是通过CorsMesssageHandler这个自定义的HttpMessageHandler来实现的,所以对于HttpConfiguration的扩展方法EnableCors来说,其核心操作就是对CorsMesssageHandler予以注册。

public static class CorsHttpConfigurationExtensions
{
    public static void EnableCors(this HttpConfiguration httpConfiguration);
    public static void EnableCors(this HttpConfiguration httpConfiguration, ICorsPolicyProvider defaultPolicyProvider);
}

public class AttributeBasedPolicyProviderFactory : ICorsPolicyProviderFactory
{
    //其他成员
    public ICorsPolicyProvider DefaultPolicyProvider { get; set; }
}

如上面的代码片断所示,HttpConfiguration具有两个重载的EnableCors方法。其中一个可以指定一个默认的CorsPolicyProvider,如果调用此方法并指定一个具体的CorsPolicyProvider对象,一个AttributeBasedPolicyProviderFactory对象会被创建出来并注册到HttpConfiguration上。而指定的CorsPolicyProvider实际上会作为AttributeBasedPolicyProviderFactory对象的DefaultPolicyProvider属性。

如果想了解微软官方NuGet包Microsoft.AspNet.WebApi.Cors的介绍和扩展可以查看这个链接的文章

阅读更多
个人分类: ASP.NET MVC4|MVC5
上一篇CSS优先级
下一篇select2初始化默认值
想对作者说点什么? 我来说一句

没有更多推荐了,返回首页

关闭
关闭
关闭