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.NET
或PHP
等)在默认情况下是无法辨别也不会去管到来的一个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
字段(详见下文),就知道出错了,从而抛出一个错误,被XMLHttpRequest
的onerror
回调函数捕获。注意,这种错误无法通过状态码识别,因为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
请求默认不发送Cookie
和HTTP
认证信息。如果要把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;
需要注意的是,如果要发送Cookie
,Access-Control-Allow-Origin
就不能设为星号,必须指定明确的、与请求网页一致的域名。同时,Cookie
依然遵循同源政策,只有用服务器域名设置的Cookie
才会上传,其他域名的Cookie
并不会上传,且(跨源)原网页代码中的document.cookie
也无法读取服务器域名下的Cookie
。
四、非简单请求
4.1 预检请求
非简单请求是那种对服务器有特殊要求的请求,比如请求方法是PUT
或DELETE
,或者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 MVC
和WebApi
的路由无法解析CORS
预检请求到Controller
和Action
的原因,因为ASP.NET MVC
和WebApi
的Route
只会对常规的Http
方法:Get,Put,Delete,Post
请求生效,而OPTIONS
方法的预检请求并不会被ASP.NET MVC
和WebApi
的路由处理),表示这个请求是用来询问的。头信息里面,关键字段是Origin
,表示请求来自哪个源。
除了Origin
字段,”预检”请求的头信息包括两个特殊字段。
(1)Access-Control-Request-Method
该字段是必须的,用来列出浏览器的CORS
请求会用到哪些HTTP
方法,上例是PUT
。
(2)Access-Control-Request-Headers
该字段是一个逗号分隔的字符串,指定浏览器CORS
请求会额外发送的头信息字段,上例是X-Custom-Header
。
4.2 预检请求的回应
服务器收到”预检”请求以后,检查了Origin
、Access-Control-Request-Method
和Access-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的比较
CORS
与JSONP
的使用目的相同,但是比JSONP
更强大。
JSONP
只支持GET
请求,CORS
支持所有类型的HTTP
请求。JSONP
的优势在于支持老式浏览器,以及可以向不支持CORS
的网站请求数据。
如何在ASP.NET MVC
和WebApi
中响应Cors
的请求?
一、ActionFilter
OR HttpMessageHandler
通过上面针对W3C
的CORS
规范的介绍,我们知道跨域资源共享实现的途径就是资源的提供者利用预定义的响应报头表明自己是否将提供的资源授权给了客户端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
。此前得到的CorsRequestContext
和CorsPolicy
对象会作为参数调用CorsEngine
的EvaluatePolicy
方法,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
对象。
然后我们根据CorsRequestContext
的IsPreflight
属性判断当前是否是一个预检请求。对于预检请求,我们会直接调用基类的同名方法将请求传递给消息处理管道的后续环节作进一步处理,并最终得到表示响应的HttpResponse
对象。如下是CorsRequestContext
的IsPreflight属性的实现代码,主要就是根据判断Http
请求中是否有Origin
和Access-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
,并将此前得到的CorsRequestContext
和CorsPolicy
对象作为参数调用它的方法EvaluatePolicy
由此开始针对当前请求的CORS
资源授权检验,并最终得到表示检验结果的CorsResult
。
通过CorsResult
的IsValid
属性表示当前请求是否通过CORS
资源授权检验。对于预检请求,在请求通过授权检验的情况下,我们会创建一个状态为“200, OK
”的HttpResponseMessage
作为最终的响应,在返回之前我们调用自定义的扩展方法AddCorsHeaders
将从CorsResult
得到的CORS
响应报头添加到此HttpResponseMessage
的报头集合中。如果请求没有通过授权检验,我们会返回一个状态为“400, Bad Request
”的响应,通过CorsResult
的ErrorMessage
属性提取的错误消息(表示授权失败的原因)会作为响应的主体内容。
对于非预检请求来说,只有在它通过了资源授权检验的情况下,我们才会调用扩展方法AddCorsHeaders
将从CorsResult
得到的CORS
报头添加响应的报头集合中。换句话说,对于未取得授权的非预检跨域资源请求,MyCorsMessageHandler
没有对响应作任何的改变。
如下所示的是分别针对HttpRequestMessage
和HttpResponseMessage
定义的两个扩展方法,其中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
中,我们并不调用当前HttpConfiguration
的EnableCors
方法开启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());
//其他操作
}
}
HttpConfiguration
的EnableCors
方法
通过上面的介绍我们知道针对ASP.NET Web API
的CORS
编程首先需要做的就是在程序启动之前调用当前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的介绍和扩展可以查看这个链接的文章
下一篇: Qt中的缓冲区QBuffer