通过扩展让ASP.NET Web API支持W3C的CORS规范
支持JSONP》中我们实现了前者,并且在《W3C的CORS Specification》一文中我们对W3C的CORS规范进行了详细介绍,现
在我们通过一个具体的实例来演示如何利用ASP.NET Web API具有的扩展点来实现针对CORS的支持。
目录
一、ActionFilter OR HttpMessageHandler
二、用于定义CORS资源授权策略的特性——CorsAttribute
三、实施CORS授权检验的HttpMessageHandler——CorsMessageHandler
四、CorsMessageHandler针对简单跨域资源请求的授权检验
五、CorsMessageHandler针对Preflight Request的授权检验
一、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特性。
二、用于定义CORS资源授权策略的特性——CorsAttribute
我们将具有如下定义的CorsAttribute特性直接应用到某个HttpController或者定义其中的某个Action方法上来定义相关
的资源授权策略。简单起见,我们的授权策略只考虑请求站点,而忽略请求提供的自定义报头和携带的用户凭证。如下
面的代码片断所示,CorsAttribute具有一个只读属性AllowOrigins表示一组被授权站点对应的Uri数组,具体站点列表
在构造函数中指定。另一个只读属性ErrorMessage表示在请求没有通过授权检验情况下返回的错误消息。
1: [AttributeUsage( AttributeTargets.Class| AttributeTargets.Method)]
2: public class CorsAttribute: Attribute
3: {
4: public Uri[] AllowOrigins { get; private set; }
5: public string ErrorMessage { get; private set; }
6:
7: public CorsAttribute(params string[] allowOrigins)
8: {
9: this.AllowOrigins = (allowOrigins ?? new string[0]).Select(origin => new Uri
(origin)).ToArray();
10: }
11:
12: public bool TryEvaluate(HttpRequestMessage request, out IDictionary<string, string> headers)
13: {
14: headers = null;
15: string origin = request.Headers.GetValues("Origin").First();
16: Uri originUri = new Uri(origin);
17: if (this.AllowOrigins.Contains(originUri))
18: {
19: headers = this.GenerateResponseHeaders(request);
20: return true;
21: }
22: this.ErrorMessage = "Cross-origin request denied";
23: return false;
24: }
25:
26: private IDictionary<string, string> GenerateResponseHeaders(HttpRequestMessage request)
27: {
28: //设置响应报头"Access-Control-Allow-Methods"
29: string origin = request.Headers.GetValues("Origin").First();
30: Dictionary<string, string> headers = new Dictionary<string, string>();
31: headers.Add("Access-Control-Allow-Origin", origin);
32: if (request.IsPreflightRequest())
33: {
34: //设置响应报头"Access-Control-Request-Headers"
35: //和"Access-Control-Allow-Headers"
36: headers.Add("Access-Control-Allow-Methods", "*");
37: string requestHeaders = request.Headers.GetValues("Access-Control-Request-
Headers").FirstOrDefault();
38: if (!string.IsNullOrEmpty(requestHeaders))
39: {
40: headers.Add("Access-Control-Allow-Headers", requestHeaders);
41: }
42: }
43: return headers;
44: }
45: }
我们将针对请求的资源授权检查定义在TryEvaluate方法中,其返回至表示请求是否通过了授权检查,输出参数headers
通过返回的字典对象表示最终添加的CORS响应报头。在该方法中,我们从指定的HttpRequestMessage对象中提取表示请
求站点的“Origin”报头值。如果请求站点没有在通过AllowOrigins属性表示的授权站点内,则意味着请求没有通过授
权检查,在此情况下我们会将ErrorMessage属性设置为“Cross-origin request denied”。
在请求成功通过授权检查的情况下,我们调用另一个方法GenerateResponseHeaders根据请求生成我们需要的CORS响应报
头。如果当前为简单跨域资源请求,只会返回针对“Access-Control-Allow-Origin”的响应报头,其值为请求站点。对
于预检请求来说,我们还需要额外添加针对“Access-Control-Request-Headers”和“Access-Control-Allow-Methods
”的响应报头。对于前者,我们直接采用请求的“Access-Control-Request-Headers”报头值,而后者被直接设置为“*
”。
在上面的程序中,我们通过调用HttpRequestMessage的扩展方法IsPreflightRequest来判断是否是一个预检请求,该方
法定义如下。从给出的代码片断可以看出,我们判断预检请求的条件是:包含报头“Origin”和“Access-Control-
Request-Method”的HTTP-OPTIONS请求。
1: public static class HttpRequestMessageExtensions
2: {
3: public static bool IsPreflightRequest(this HttpRequestMessage request)
4: {
5: return request.Method == HttpMethod.Options &&
6: request.Headers.GetValues("Origin").Any() &&
7: request.Headers.GetValues("Access-Control-Request-Method").Any();
8: }
9: }
三、实施CORS授权检验的HttpMessageHandler——CorsMessageHandler
针对跨域资源共享的实现最终体现在具有如下定义的CorsMessageHandler类型上,它直接继承自DelegatingHandler。在
实现的SendAsync方法中,CorsMessageHandler利用应用在目标Action方法或者HttpController类型上CorsAttribute来
对请求实施授权检验,最终将生成的CORS报头添加到响应报头列表中。
1: public class CorsMessageHandler: DelegatingHandler
2: {
3: protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken)
4: {
5: //得到描述目标Action的HttpActionDescriptor
6: HttpMethod originalMethod = request.Method;
7: bool isPreflightRequest = request.IsPreflightRequest();
8: if (isPreflightRequest)
9: {
10: string method = request.Headers.GetValues("Access-Control-Request-Method").First();
11: request.Method = new HttpMethod(method);
12: }
13: HttpConfiguration configuration = request.GetConfiguration();
14: HttpControllerDescriptor controllerDescriptor =
configuration.Services.GetHttpControllerSelector().SelectController(request);
15: HttpControllerContext controllerContext = new HttpControllerContext
(request.GetConfiguration(), request.GetRouteData(), request)
16: {
17: ControllerDescriptor = controllerDescriptor
18: };
19: HttpActionDescriptor actionDescriptor = configuration.Services.GetActionSelector
().SelectAction(controllerContext);
20:
21: //根据HttpActionDescriptor得到应用的CorsAttribute特性
22: CorsAttribute corsAttribute = actionDescriptor.GetCustomAttributes<CorsAttribute>
().FirstOrDefault()??
23: controllerDescriptor.GetCustomAttributes<CorsAttribute>().FirstOrDefault();
24: if(null == corsAttribute)
25: {
26: return base.SendAsync(request, cancellationToken);
27: }
28:
29: //利用CorsAttribute实施授权并生成响应报头
30: IDictionary<string,string> headers;
31: request.Method = originalMethod;
32: bool authorized = corsAttribute.TryEvaluate(request, out headers);
33: HttpResponseMessage response;
34: if (isPreflightRequest)
35: {
36: if (authorized)
37: {
38: response = new HttpResponseMessage(HttpStatusCode.OK);
39: }
40: else
41: {
42: response = request.CreateErrorResponse(HttpStatusCode.BadRequest,
corsAttribute.ErrorMessage);
43: }
44: }
45: else
46: {
47: response = base.SendAsync(request, cancellationToken).Result;
48: }
49:
50: //添加响应报头
51: foreach (var item in headers)
52: {
53: response.Headers.Add(item.Key, item.Value);
54: }
55: return Task.FromResult<HttpResponseMessage>(response);
56: }
57: }
具体来说,我们通过注册到当前ServicesContainer上的HttpActionSelector根据请求得到描述目标Action的
HttpActionDescriptor对象,为此我们需要根据请求手工生成作为HttpActionSelector的SelectAction方法参数的
HttpControllerContext对象。对此有一点需要注意:由于预检请求采用的HTTP方法为“OPTIONS”,我们需要将其替换
成代表真正跨域资源请求的HTTP方法,也就是预检请求的“Access-Control-Request-Method”报头值。
在得到描述目标Action的HttpActionDescriptor对象后,我们调用其GetCustomAttributes<T>方法得到应用在Action方
法上的CorsAttribute特性。如果这样的特性不存在,在调用同名方法得到应用在HttpController类型上的
CorsAttribute特性。
接下来我们调用CorsAttribute的TryEvaluate方法对请求实施资源授权检查并得到一组CORS响应报头,作为参数的
HttpRequestMessage对象的HTTP方法应该恢复其原有的值。对于预检请求,在请求通过授权检查之后我们会创建一个状
态为“200, OK”的响应,否则会根据错误消息创建创建一个状态为“400, Bad Request”的响应。
对于非预检请求来说(可能是简单跨域资源请求,也可能是继预检请求之后发送的真正的跨域资源请求),我们调用基
类的SendAsync方法将请求交付给后续的HttpMessageHandler进行处理并最终得到最终的响应。我们最终将调用
CorsAttribute的TryEvaluate方法得到的响应报头逐一添加到响应报头列表中。
四、CorsMessageHandler针对简单跨域资源请求的授权检验
接下来我们通过于一个简单的实例来演示同源策略针对跨域Ajax请求的限制。如图右图所示,我们利用Visual Studio在
同一个解决方案中创建了两个Web应用。从项目名称可以看出,WebApi和MvcApp分别为ASP.NET Web API和MVC应用,后者
是Web API的调用者。我们直接采用默认的IIS Express作为两个应用的宿主,并且固定了端口号:WebApi和MvcApp的端
口号分别为“3721”和“9527”,所以指向两个应用的URI肯定不可能是同源的。我们在WebApi应用中定义了如下一个继
承自ApiController的ContactsController类型,它具有的唯一Action方法GetAllContacts返回一组联系人列表。
如下面的代码片断所示,用于获取所有联系人列表的Action方法GetAllContacts返回一个
JsonResult<IEnumerable<Contact>>对象,但是该方法上面应用了我们定义的CorsAttribute特性,并将
“https://localhost:9527”(客户端ASP.NET MVC应用的站点)设置为允许授权的站点。
1: public class ContactsController : ApiController
2: {
3: [Cors("https://localhost:9527")]
4: public IHttpActionResult GetAllContacts()
5: {
6: Contact[] contacts = new Contact[]
7: {
8: new Contact{ Name="张三", PhoneNo="123", EmailAddress="zhangsan@gmail.com"},
9: new Contact{ Name="李四", PhoneNo="456", EmailAddress="lisi@gmail.com"},
10: new Contact{ Name="王五", PhoneNo="789", EmailAddress="wangwu@gmail.com"},
11: };
12: return Json<IEnumerable<Contact>>(contacts);
13: }
14: }
在Global.asax中,我们采用如下的方式将一个CorsMessageHandler对象添加到ASP.NET Web API的消息处理管道中。
1: public class WebApiApplication : System.Web.HttpApplication
2: {
3: protected void Application_Start()
4: {
5: GlobalConfiguration.Configuration.MessageHandlers.Add(new CorsMessageHandler ());
6: //其他操作
7: }
8: }
接下来们在MvcApp应用中定义如下一个HomeController,默认的Action方法Index会将对应的View呈现出来。
1: public class HomeController : Controller
2: {
3: public ActionResult Index()
4: {
5: return View();
6: }
7: }
如下所示的是Action方法Index对应View的定义。我们的目的在于:当页面成功加载之后以Ajax请求的形式调用上面定义
的Web API获取联系人列表,并将自呈现在页面上。如下面的代码片断所示,Ajax调用和返回数据的呈现是通过调用
jQuery的getJSON方法完成的。在此基础上直接调用我们的ASP.NET MVC程序照样会得到如右图所示的结果.
1: <html>
2: <head>
3: <title>联系人列表</title>
4: <script type="text/javascript" src="@Url.Content("~/scripts/jquery-1.10.2.js")"></script>
1:
2: </head>
3: <body>
4: <ul id="contacts"></ul>
5: <script type="text/javascript">
6: $(function ()
7: {
8: var url = "https://localhost:3721/api/contacts";
9: $.getJSON(url, null, function (contacts) {
10: $.each(contacts, function (index, contact)
11: {
12: var html = "<li><ul>";
13: html += "<li>Name: " + contact.Name + "</li>";
14: html += "<li>Phone No:" + contact.PhoneNo + "</li>";
15: html += "<li>Email Address: " + contact.EmailAddress + "</li>";
16: html += "</ul>";
17: $("#contacts").append($(html));
18: });
19: });
20: });
21:
</script>
5: </body>
6: </html>
如果我们利用Fiddler来检测针对Web API调用的Ajax请求,如下所示的请求和响应内容会被捕捉到,我们可以清楚地看
到利用CorsMessageHandler添加的“Access-Control-Allow-Origin”报头出现在响应的报头集合中。
1: GET https://localhost:3721/api/contacts HTTP/1.1
2: Host: localhost:3721
3: Connection: keep-alive
4: Accept: application/json, text/javascript, */*; q=0.01
5: Origin: https://localhost:9527
6: User-Agent: Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/31.0.1650.57
Safari/537.36
7: Referer: https://localhost:9527/
8: Accept-Encoding: gzip,deflate,sdch
9: Accept-Language: en-US,en;q=0.8,zh-CN;q=0.6,zh-TW;q=0.4
10:
11: HTTP/1.1 200 OK
12: Cache-Control: no-cache
13: Pragma: no-cache
14: Content-Length: 205
15: Content-Type: application/json; charset=utf-8
16: Expires: -1
17: Server: Microsoft-IIS/8.0
18: Access-Control-Allow-Origin: https://localhost:9527
19: X-AspNet-Version: 4.0.30319
20: X-SourceFiles: =?UTF-8?B?
RTpc5oiR55qE6JGX5L2cXEFTUC5ORVQgV2ViIEFQSeahhuaetuaPnmFxOZXcgU2FtcGxlc1xDaGFwdGVyIDE0XFMxNDAzXFdlYkFwaVxh
cGlcY29udGFjdHM=?=
21: X-Powered-By: ASP.NET
22: Date: Wed, 04 Dec 2013 01:50:01 GMT
23:
24: [{"Name":"张三","PhoneNo":"123","EmailAddress":"zhangsan@gmail.com"},{"Name":"李
四","PhoneNo":"456","EmailAddress":"lisi@gmail.com"},{"Name":"王
五","PhoneNo":"789","EmailAddress":wangwu@gmail.com}]
五、CorsMessageHandler针对Preflight Request的授权检验
从上面给出的请求和响应内容可以确定Web API的调用采用的是“简单跨域资源请求”,所以并没有采用“预检”机制。
如何需要迫使浏览器采用预检机制,就需要了解我们在《W3C的CORS Specification》上面提到的简单跨域资源请求具有
的两个条件
采用简单HTTP方法(GET、HEAD和POST);
不具有非简单请求报头的自定义报头。
只要打破其中任何一个条件就会迫使浏览器采用预检机制,我们选择为请求添加额外的自定义报头。在ASP.NET MVC应用
用户调用Web API的View中,针对Ajax请求调用Web API的JavaScript程序被改写成如下的形式:我们在发送Ajax请求之
前利用setRequestHeader函数添加了两个名称分别为“'X-Custom-Header1”和“'X-Custom-Header2”的自定义报头。
1: <html>
2: <head>
3: <title>联系人列表</title>
4: <script type="text/javascript" src="@Url.Content("~/scripts/jquery-1.10.2.js")"></script>
1:
2: </head>
3: <body>
4: <ul id="contacts"></ul>
5: <script type="text/javascript">
6: $(function ()
7: {
8: $.ajax({
9: url : 'https://localhost:3721/api/contacts',
10: type : 'GET',
11: success : listContacts,
12: beforeSend : setRequestHeader
13: });
14: });
15:
16: function listContacts(contacts)
17: {
18: $.each(contacts, function (index, contact) {
19: var html = "<li><ul>";
20: html += "<li>Name: " + contact.Name + "</li>";
21: html += "<li>Phone No:" + contact.PhoneNo + "</li>";
22: html += "<li>Email Address: " + contact.EmailAddress + "</li>";
23: html += "</ul>";
24: $("#contacts").append($(html));
25: });
26: }
27:
28: function setRequestHeader(xmlHttpRequest)
29: {
30: xmlHttpRequest.setRequestHeader('X-Custom-Header1', 'Foo');
31: xmlHttpRequest.setRequestHeader('X-Custom-Header2', 'Bar');
32: }
33:
</script>
5: </body>
6: </html>
再次运行我们的ASP.NET MVC程序,依然会得正确的输出结果,但是针对Web API的调用则会涉及到两次消息交换,分别
针对预检请求和真正的跨域资源请求。从下面给出的两次消息交换涉及到的请求和响应内容可以看出:自定义的两个报
头名称会出现在采用“OPTIONS”作为HTTP方法的预检请求的“Access-Control-Request-Headers”报头中,利用
CorsMessageHandler添加的3个报头(“Access-Control-Allow-Origin”、“Access-Control-Allow-Methods”和
“Access-Control-Allow-Headers”)均出现在针对预检请求的响应中。
1: OPTIONS https://localhost:3721/api/contacts HTTP/1.1
2: Host: localhost:3721
3: Connection: keep-alive
4: Cache-Control: max-age=0
5: Access-Control-Request-Method: GET
6: Origin: https://localhost:9527
7: User-Agent: Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/31.0.1650.57
Safari/537.36
8: Access-Control-Request-Headers: accept, x-custom-header1, x-custom-header2
9: Accept: */*
10: Referer: https://localhost:9527/
11: Accept-Encoding: gzip,deflate,sdch
12: Accept-Language: en-US,en;q=0.8,zh-CN;q=0.6,zh-TW;q=0.4
13:
14: HTTP/1.1 200 OK
15: Cache-Control: no-cache
16: Pragma: no-cache
17: Expires: -1
18: Server: Microsoft-IIS/8.0
19: Access-Control-Allow-Origin: https://localhost:9527
20: Access-Control-Allow-Methods: *
21: Access-Control-Allow-Headers: accept, x-custom-header1, x-custom-header2
22: X-AspNet-Version: 4.0.30319
23: X-SourceFiles: =?UTF-8?B??=
24: X-Powered-By: ASP.NET
25: Date: Wed, 04 Dec 2013 02:11:16 GMT
26: Content-Length: 0
27:
28: --------------------------------------------------------------------------------
29: GET https://localhost:3721/api/contacts HTTP/1.1
30: Host: localhost:3721
31: Connection: keep-alive
32: Accept: */*
33: X-Custom-Header1: Foo
34: Origin: https://localhost:9527
35: X-Custom-Header2: Bar
36: User-Agent: Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/31.0.1650.57
Safari/537.36
37: Referer: https://localhost:9527/
38: Accept-Encoding: gzip,deflate,sdch
39: Accept-Language: en-US,en;q=0.8,zh-CN;q=0.6,zh-TW;q=0.4
40:
41: HTTP/1.1 200 OK
42: Cache-Control: no-cache
43: Pragma: no-cache
44: Content-Length: 205
45: Content-Type: application/json; charset=utf-8
46: Expires: -1
47: Server: Microsoft-IIS/8.0
48: Access-Control-Allow-Origin: https://localhost:9527
49: X-AspNet-Version: 4.0.30319
50: X-SourceFiles: =?UTF-8?B?
RTpc5oiR55qE6JGX5L2cXEFTUC5ORVQgV2ViIEFQSeahhuaetuaPreenmFxOZXcgU2FtcGxlc1xDaGFwdGVyIDE0XF9udGFjdHM=?=
51: X-Powered-By: ASP.NET
52: Date: Wed, 04 Dec 2013 02:11:16 GMT
53:
54: [{"Name":"张三","PhoneNo":"123","EmailAddress":"zhangsan@gmail.com"},{"Name":"李
四","PhoneNo":"456","EmailAddress":"lisi@gmail.com"},{"Name":"王
五","PhoneNo":"789","EmailAddress":wangwu@gmail.com}]