让ASP.NET Web API支持JSONP和W3C的CORS规范是解决“跨域资源共享”的两种途径,在《通过扩展让ASP.NET Web API支持JSONP》中我们实现了前者,并且在《W3C的CORS Specification》一文中我们对W3C的CORS规范进行了详细介绍,现在我们通过一个具体的实例来演示如何利用ASP.NET Web API具有的扩展点来实现针对CORS的支持。
目录
一、ActionFilter OR HttpMessageHandler
二、用于定义CORS资源授权策略的特性——CorsAttribute
三、实施CORS授权检验的HttpMessageHandler——CorsMessageHandler
四、CorsMessageHandler针对简单跨域资源请求的授权检验
五、CorsMessageHandler针对Preflight Request的授权检验
通过上面针对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特性。
我们将具有如下定义的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: }
针对跨域资源共享的实现最终体现在具有如下定义的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方法得到的响应报头逐一添加到响应报头列表中。
接下来我们通过于一个简单的实例来演示同源策略针对跨域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特性,并将“http://localhost:9527”(客户端ASP.NET MVC应用的站点)设置为允许授权的站点。
1: public class ContactsController : ApiController
2: {
3: [Cors("http://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的消息处理管道中。在此基础上直接调用我们的ASP.NET MVC程序照样会得到如右图所示的结果
1: public class WebApiApplication : System.Web.HttpApplication
2: {
3: protected void Application_Start()
4: {
5: GlobalConfiguration.Configuration.MessageHandlers.Add(new CorsMessageHandler ());
6: //其他操作
7: }
8: }
如果我们利用Fiddler来检测针对Web API调用的Ajax请求,如下所示的请求和响应内容会被捕捉到,我们可以清楚地看到利用CorsMessageHandler添加的“Access-Control-Allow-Origin”报头出现在响应的报头集合中。
1: GET http://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: http://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: http://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: http://localhost:9527
19: X-AspNet-Version: 4.0.30319
20: X-SourceFiles: =?UTF-8?B?RTpc5oiR55qE6JGX5L2cXEFTUC5ORVQgV2ViIEFQSeahhuaetuaPnmFxOZXcgU2FtcGxlc1xDaGFwdGVyIDE0XFMxNDAzXFdlYkFwaVxhcGlcY29udGFjdHM=?=
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}]
从上面给出的请求和响应内容可以确定Web API的调用采用的是“简单跨域资源请求”,所以并没有采用“预检”机制。如何需要迫使浏览器采用预检机制,就需要了解我们在《W3C的CORS Specification》上面提到的简单跨域资源请求具有的两个条件
只要打破其中任何一个条件就会迫使浏览器采用预检机制,我们选择为请求添加额外的自定义报头。在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 : 'http://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 http://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: http://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: http://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: http://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 http://localhost:3721/api/contacts HTTP/1.1
30: Host: localhost:3721
31: Connection: keep-alive
32: Accept: */*
33: X-Custom-Header1: Foo
34: Origin: http://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: http://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: http://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}]