同源策略是所有瀏覽器都必須遵循的一項(xiàng)安全原則,它的存在決定了瀏覽器在默認(rèn)情況下無法對(duì)跨域請求的資源做進(jìn)一步處理。為了實(shí)現(xiàn)跨域資源的共享,W3C制定了CORS規(guī)范。ASP.NET利用CorsMiddleware中間件提供了針對(duì)CORS規(guī)范的實(shí)現(xiàn)。(本文提供的示例演示已經(jīng)同步到《ASP.NET Core 6框架揭秘-實(shí)例演示版》)
[S2901]跨域調(diào)用API(源代碼)
[S2902]顯式指定授權(quán)Origin列表(源代碼)
[S2903]手工檢驗(yàn)指定Origin是否的權(quán)限(源代碼)
[S2904]基于策略的資源授權(quán)(匿名策略)(源代碼)
[S2905]基于策略的資源授權(quán)(具名策略)(源代碼)
[S2906]將CORS規(guī)則應(yīng)用到路由終結(jié)點(diǎn)上(代碼編程形式)(源代碼)
[S2907]將CORS規(guī)則應(yīng)用到路由終結(jié)點(diǎn)上(特性標(biāo)注形式)(源代碼)
[S2901]跨域調(diào)用API
為了方便在本機(jī)環(huán)境下模擬跨域API調(diào)用,我們通過修改Host文件將本地IP映射為多個(gè)不同的域名。我們以管理員身份打開文件“%windir%\System32\drivers\etc\hosts”,并以如下所示的方式添加了針對(duì)四個(gè)域名的映射。
127.0.0.1 www.foo.com 127.0.0.1 www.bar.com 127.0.0.1 www.baz.com 127.0.0.1 www.qux.com
我們的演示程序由圖1所示的兩個(gè)ASP.NET程序構(gòu)成。我們將API定義在Api項(xiàng)目中,App是一個(gè)JavaScript應(yīng)用程序,它會(huì)在瀏覽器環(huán)境下以跨域請求的方式調(diào)用承載于Api應(yīng)用中的API。
圖1 演示實(shí)例解決方案結(jié)構(gòu)
如下所示的Api程序中定義了表示聯(lián)系人的Contact記錄類型。我們注冊了針對(duì)路徑“/contacts”的路由使之以JSON的形式返回一組聯(lián)系人列表。在調(diào)用Application對(duì)象的Run方法啟動(dòng)時(shí),我們顯式指定了監(jiān)聽地址“http://0.0.0.0:8080”。
var app = Application.Create(); app.MapGet("/contacts", GetContacts); app.Run(url:"http://0.0.0.0:8080"); static IResult GetContacts() { var contacts = new Contact[] { new Contact("張三", "123", "[email protected]"), new Contact("李四","456", "[email protected]"), new Contact("王五", "789", "[email protected]") }; return Results.Json(contacts); } public readonly record struct Contact(string Name,string PhoneNo ,string EmailAddress);
下面的代碼片段展示了App應(yīng)用程序的完整定義。我們通過注冊針對(duì)根路徑的路由使之現(xiàn)一個(gè)包含聯(lián)系人列表的Web頁面,我們在該頁面中采用jQuery以AJAX的方式調(diào)用上面這個(gè)API獲取呈現(xiàn)的聯(lián)系人列表。我們將AJAX請求的目標(biāo)地址設(shè)置為“http://www.qux.com:8080/contacts”。在AJAX請求的回調(diào)操作中,可以將返回的聯(lián)系人以無序列表的形式呈現(xiàn)出來。
var app = Application.Create(); app.MapGet("/", Render); app.Run(url:"http://0.0.0.0:3721"); static IResult Render() { var html = @" <html> <body> <ul id='contacts'></ul> <script src='http://code.jquery.com/jquery-3.3.1.min.js'></script> <script> $(function() { var url = 'http://www.qux.com:8080/contacts'; $.getJSON(url, null, function(contacts) { $.each(contacts, function(index, contact) { var html = '<li><ul>'; html += '<li>Name: ' + contact.name + '</li>'; html += '<li>Phone No:' + contact.phoneNo + '</li>'; html += '<li>Email Address: ' + contact.emailAddress + '</li>'; html += '</ul>'; $('#contacts').append($(html)); }); }); }); </script > </body> </html>"; return Results.Text(content: html, contentType: "text/html");
然后先后啟動(dòng)應(yīng)用程序Api和App。如果利用瀏覽器采用映射的域名(www.foo.com)訪問App應(yīng)用,就會(huì)發(fā)現(xiàn)我們期待的聯(lián)系人列表并沒有呈現(xiàn)出來。如果按F12鍵查看開發(fā)工具,就會(huì)發(fā)現(xiàn)圖29-2所示的關(guān)于CORS的錯(cuò)誤,具體的錯(cuò)誤消息為“Access to XMLHttpRequest at 'http://www.qux.com:8080/contacts' from origin 'http://www.foo.com:3721' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.”。
圖2 跨域訪問導(dǎo)致聯(lián)系人無法呈現(xiàn)
有的讀者可能會(huì)想是否是AJAX調(diào)用發(fā)生錯(cuò)誤導(dǎo)致沒有得到聯(lián)系人信息呢。如果我們利用抓包工具捕捉AJAX請求和響應(yīng)的內(nèi)容,就會(huì)捕獲到如下所示的HTTP報(bào)文。可以看出AJAX調(diào)用其實(shí)是成功的,只是瀏覽器阻止了針對(duì)跨域請求返回?cái)?shù)據(jù)的進(jìn)一步處理。如下請求具有一個(gè)名為Origin的報(bào)頭,表示的正是AJAX請求的“源”,也就是跨域(Cross-Orgin)中的“域”。
GET http://www.qux.com:8080/contacts HTTP/1.1 Host: www.qux.com:8080 Connection: keep-alive Accept: application/json, text/javascript, */*; q=0.01 Origin: http://www.foo.com:3721 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.67 Safari/537.36 Referer: http://www.foo.com:3721/ Accept-Encoding: gzip, deflate Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7
HTTP/1.1 200 OK Date: Sat, 13 Nov 2021 11:24:58 GMT Server: Kestrel Content-Length: 205 [{"name":"張三","phoneNo":"123","emailAddress":"[email protected]"},{"name":"李四", "phoneNo":"456","emailAddress":"[email protected]"},{"name":"王五","phoneNo":"789", "emailAddress":"[email protected]"}]
[S2902]顯式指定授權(quán)Origin列表
我們可以利用注冊的CorsMiddleware中間件來解決上面這個(gè)問題。對(duì)于我們演示的實(shí)例來說,作為資源提供者的Api應(yīng)用如果希望將提供的資源授權(quán)給某個(gè)應(yīng)用程序,可以將作為資源消費(fèi)程序的“域”添加到授權(quán)域列表中。演示程序調(diào)用了UseCors擴(kuò)展方法完成了針對(duì)CorsMiddleware中間件的注冊,并指定了兩個(gè)授權(quán)的“域”。中間件涉及的服務(wù)則通過調(diào)用AddCors擴(kuò)展方法進(jìn)行注冊。
var builder = WebApplication.CreateBuilder(); builder.Services.AddCors(); var app = builder.Build(); app.UseCors(cors => cors.WithOrigins( "http://www.foo.com:3721", "http://www.bar.com:3721")); app.MapGet("/contacts", GetContacts); app.Run(url:"http://0.0.0.0:8080"); ...
由于Api應(yīng)用對(duì)“http://www.foo.com:3721”和“http://www.bar.com:3721”這兩個(gè)域進(jìn)行了顯式授權(quán),如果采用它們來訪問App應(yīng)用程序,瀏覽器上就會(huì)呈現(xiàn)出圖3所示的聯(lián)系人列表。倘若將瀏覽器地址欄的URL設(shè)置成未被授權(quán)的“http://www.baz.com:3721”,我們依然得不到想要的顯示結(jié)果。
圖3 針對(duì)域的顯式授權(quán)
下面從HTTP消息交換的角度來介紹這次由Api應(yīng)用響應(yīng)的報(bào)文有何不同。如下所示的是Api針對(duì)地址為“http://www.foo.com:3721”的響應(yīng)報(bào)文,可以看出它多了兩個(gè)名稱分別為Vary和Access-Control-Allow-Origin的報(bào)頭。前者與緩存有關(guān),它要求在對(duì)響應(yīng)報(bào)文實(shí)施緩存的時(shí)候,選用的Key應(yīng)該包含請求的Origin報(bào)頭值,它提供給瀏覽器授權(quán)訪問當(dāng)前資源的域。
HTTP/1.1 200 OK Date: Sat, 13 Nov 2021 11:24:58 GMT Server: Kestrel Vary: Origin Access-Control-Allow-Origin: http://www.foo.com:3721 Content-Length: 205 [{"name":"張三","phoneNo":"123","emailAddress":"[email protected]"},{"name":"李四", "phoneNo":"456","emailAddress":"[email protected]"},{"name":"王五","phoneNo":"789", "emailAddress":"[email protected]"}]
[S2903]手工檢驗(yàn)指定Origin是否的權(quán)限
對(duì)于我們演示的實(shí)例來說,當(dāng)AJAX調(diào)用成功并返回聯(lián)系人列表之后,瀏覽器正是利用Access-Control-Allow-Origin報(bào)頭確定當(dāng)前請求采用的域是否有權(quán)對(duì)獲取的資源做進(jìn)一步處理的。只有在授權(quán)明確之后,瀏覽器才允許執(zhí)行將數(shù)據(jù)呈現(xiàn)出來的操作。從演示程序可以看出“跨域資源共享”所謂的“域”是由協(xié)議前綴(如“http://”或者“https://”)、主機(jī)名(或者域名)和端口號(hào)組成的,但在很多情況下,資源提供在授權(quán)的時(shí)候往往只需要考慮域名,這樣的授權(quán)策略可以采用如下所示的方式來解決。UseCors擴(kuò)展方法返回一個(gè)CorsPolicyBuilder對(duì)象,我們調(diào)用它的SetIsOriginAllowed方法利用提供的Func<string, bool>來設(shè)置授權(quán)規(guī)則,此規(guī)則只會(huì)考慮域名。
var validOrigins = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "www.foo.com", "www.bar.com" }; var builder = WebApplication.CreateBuilder(); builder.Services.AddCors(); var app = builder.Build(); app.UseCors(cors => cors.SetIsOrigi0nAllowed( origin => validOrigins.Contains(new Uri(origin).Host))); app.MapGet("/contacts", GetContacts); app.Run(url:"http://0.0.0.0:8080"); ...
[S2904]基于策略的資源授權(quán)(匿名策略)
CORS本質(zhì)上還是屬于授權(quán)的問題,所以我們采用類似于第28章“授權(quán)”介紹的方式將資源授權(quán)的規(guī)則定義成相應(yīng)的策略,CorsMiddleware中間件就可以針對(duì)某個(gè)預(yù)定義的策略來實(shí)施跨域資源授權(quán)。在調(diào)用AddCors擴(kuò)展方法時(shí)可以采用如下所示的方式注冊一個(gè)默認(rèn)的CORS策略。
var validOrigins = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "www.foo.com", "www.bar.com" }; var builder = WebApplication.CreateBuilder(); builder.Services.AddCors(options => options.AddDefaultPolicy(policy => policy. SetIsOriginAllowed(origin => validOrigins.Contains(new Uri(origin).Host)))); var app = builder.Build(); app.UseCors(); app.MapGet("/contacts", GetContacts); app.Run(url:"http://0.0.0.0:8080"); ...
[S2905]基于策略的資源授權(quán)(具名策略)
除了注冊一個(gè)默認(rèn)的匿名CORS策略,我們還可以為注冊的策略命名。下面的演示程序在調(diào)用AddCors擴(kuò)展方法時(shí)注冊了一個(gè)名為“foobar”的CORS策略,在調(diào)用UseCors擴(kuò)展方法注冊CorsMiddleware中間件時(shí)就可以顯式地指定采用的策略名稱。
var validOrigins = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "www.foo.com", "www.bar.com" }; var builder = WebApplication.CreateBuilder(); builder.Services.AddCors(options => options.AddPolicy("foobar", policy => policy. SetIsOriginAllowed(origin => validOrigins.Contains(new Uri(origin).Host)))); var app = builder.Build(); app.UseCors(policyName:"foobar"); app.MapGet("/contacts", GetContacts); app.Run(url:"http://0.0.0.0:8080"); ...
[S2906]將CORS規(guī)則應(yīng)用到路由終結(jié)點(diǎn)上(代碼編程形式)
除了在調(diào)用UseCors擴(kuò)展方法時(shí)指定Cors策略外,我們還可以在注冊終結(jié)點(diǎn)的時(shí)候?qū)ors規(guī)則作為路由元數(shù)據(jù)應(yīng)用到終結(jié)點(diǎn)上。如下的演示程序在調(diào)用MapGet方法注冊了針對(duì)“/contacts”路徑的終結(jié)點(diǎn)后會(huì)返回一個(gè)RouteHandlerBuilder對(duì)象,它接著調(diào)用該對(duì)象的RequireCors擴(kuò)展方法來指定采用的CORS策略名稱。
var validOrigins = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "www.foo.com", "www.bar.com" }; var builder = WebApplication.CreateBuilder(); builder.Services.AddCors(options => options.AddPolicy("foobar", policy => policy. SetIsOriginAllowed(origin => validOrigins.Contains(new Uri(origin).Host)))); var app = builder.Build(); app.UseCors(); app.MapGet("/contacts", GetContacts).RequireCors(policyName:"foobar"); app.Run(url:"http://0.0.0.0:8080"); ...
[S2907]將CORS規(guī)則應(yīng)用到路由終結(jié)點(diǎn)上(特性標(biāo)注形式)
我們也可以按照如下的方式在終結(jié)點(diǎn)處理方法GetContacts上標(biāo)注EnableCorsAttribute特性,并利用其“policyName”參數(shù)來指定采用的CORS策略名稱。如果使用Lambda表達(dá)式來定義終結(jié)點(diǎn)處理器,我們可以將EnableCorsAttribute特性直接標(biāo)注在Lambda表達(dá)式前面。
using Microsoft.AspNetCore.Cors; var validOrigins = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "www.foo.com", "www.bar.com" }; var builder = WebApplication.CreateBuilder(); builder.Services.AddCors(options => options.AddPolicy("foobar", policy => policy. SetIsOriginAllowed(origin => validOrigins.Contains(new Uri(origin).Host)))); var app = builder.Build(); app.UseCors(); app.MapGet("/contacts", GetContacts); app.Run(url:"http://0.0.0.0:8080"); [EnableCors(policyName: "foobar")] static IResult GetContacts() { var contacts = new Contact[] { new Contact("張三", "123", "[email protected]"), new Contact("李四","456", "[email protected]"), new Contact("王五", "789", "[email protected]") }; return Results.Json(contacts); } ...