由于HTTP協定是無狀态的,但對于認證來說,必然要通過一種機制來儲存使用者狀态,而最常用,也最簡單的就是Cookie了,它由浏覽器自動儲存并在發送請求時自動附加到請求頭中。盡管在現代Web應用中,Cookie已略顯笨重,但它依然是最為重要的使用者身份儲存方式。在 上一章 中整體的介紹了一下 ASP.NET Core 中的認證流程,而未提及具體的實作方式,較為抽象,那本章就通過一個完整的示例,以及對其原理的解剖,來詳細介紹一下Cookie認證,希望能幫助大家對 ASP.NET Core 認證系統有一個更深入的了解。
目錄
- 示例建立項目配置Cookie認證準備工作認證流程
- 補充JwtClaimTypesSessionStoreReacting to back-end changesPersistent and ExpiresUtc
- 源碼解析AddCookieCookieAuthenticationOptionsCookieAuthenticationHandler
示例
我們從零開始,一步一步來建立一個完整的 ASP.NET Core Cookie認證的詳細示例。
建立項目
我們首先建立一個空的 .NET Core 2.0 Web 項目:
在建立的空項目中,預設具有如下引用:
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.All" Version="2.0.0" />
</ItemGroup>
Microsoft.AspNetCore.All 是 ASP.NET Core 的全家桶,包含 Mvc, EFCore, Identity, NodeService, AzureAppServe 等等,并內建到了 .NET Core SDK 當中,個人感覺略坑,有違 .NET Core 輕量子產品化的理念,雖然使用看似更加友善了,卻也讓我們變得更加傻瓜化。在本文中為了更好的示範,就移除掉 Microsoft.AspNetCore.All 的引用,然後手動安裝需要的Nuget包,當然,你可以不這麼做,并略過此小節。
在本章中,會用到以下幾個項目中的Nuget包:
- HttpAbstractions, ASP.NET Core的核心項目,我們的每一個應用程式都需要引用該項目。
- Hosting, ASP.NET Core應用程式的宿主程式,必須引用。
- KestrelHttpServer, 最常用的Web伺服器,支援跨平台,在Windows下還可以使用HttpSysServer或IISIntegration。
- Logging,用來記錄日志,可将日志輸出到控制台,調試視窗,Windows事件日志等。
- Security,ASP.NET Core 認證系統,包括Cookies, JwtBearer, OAuth, OpenIdConnect等。
我們使用 DotNet CLI 來安裝Nuget包:
dotnet add package Microsoft.AspNetCore.Hosting --version 2.0.0
dotnet add package Microsoft.AspNetCore.Server.Kestrel --version 2.0.0
dotnet add package Microsoft.Extensions.Logging.Console --version 2.0.0
dotnet add package Microsoft.AspNetCore.Authentication.Cookies --version 2.0.0
由于WebHost.CreateDefaultBuilder包含在Microsoft.AspNetCore.All中,需要對Program做如下修改:
public static IWebHost BuildWebHost(string[] args) =>
new WebHostBuilder()
.UseKestrel()
.UseUrls("http://localhost:5000")
.UseContentRoot(Directory.GetCurrentDirectory())
.ConfigureLogging((hostingContext, logging) =>
{
logging.AddConsole();
})
.UseStartup<Startup>()
.Build();
如上,我們使用Kestrel伺服器,監聽5000端口,并将日志列印到控制台,需要注意的是我們并沒有使用UseIISIntegration,是以不支援在IIS下運作,需要使用控制台的方式來運作,修改Properties/launchSettings.json檔案:
{
"profiles": {
"Console": {
"commandName": "Project",
"launchBrowser": false,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
如上,将IIS的相關配置删除掉,隻保留控制台的啟動配置,然後在Starup檔案的Configure方法中添加如下代碼:
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
app.Run(async (context) =>
{
await context.Response.WriteAsync("Hello World!");
});
}
最後按下F5,啟動程式,在浏覽器中通路通路:http://localhost:5000/,輸出:
Hello World!
配置Cookie認證
在 ASP.NET Core 中,有一個非常重要的依賴注入系統,它貫穿于所有項目中。對于認證系統,同樣要先進行注冊:
public void ConfigureServices(IServiceCollection services)
{
services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
})
.AddCookie(options =>
{
// 在這裡可以根據需要添加一些Cookie認證相關的配置,在本次示例中使用預設值就可以了。
});
}
如上,我們隻配置了DefaultScheme,這樣,DefaultSignInScheme, DefaultSignOutScheme, DefaultChallengeScheme, DefaultForbidScheme 等都會使用該 Scheme 作為預設值。
AddCookie 用來注冊 CookieAuthenticationHandler,由它來完成身份認證的主要邏輯。
在注冊完服務之後,接下來便是注冊中間件,在 ASP.NET Core 中都是這個套路:
public void Configure(IApplicationBuilder app)
{
app.UseAuthentication();
}
如上,使用UseAuthentication方法注冊了AuthenticationMiddleware中間件,它會負責調用對應的Handler,在上一章中有詳細的介紹。
準備工作
既然是身份認證,那首先要有使用者,我們在這裡模拟一個使用者倉儲,用來實作使用者登入時的使用者名和密碼的檢查。
定義使用者類:
public class User
{
public int Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
public string PhoneNumber { get; set; }
public string Password { get; set; }
public DateTime Birthday { get; set; }
}
定義使用者倉儲:
public class UserStore
{
private static List<User> _users = new List<User>() {
new User { Id=1, Name="alice", Password="alice", Email="[email protected]", PhoneNumber="18800000001" },
new User { Id=1, Name="bob", Password="bob", Email="[email protected]", PhoneNumber="18800000002" }
};
public User FindUser(string userName, string password)
{
return _users.FirstOrDefault(_ => _.Name == userName && _.Password == password);
}
}
将UserStore注冊到DI系統中:
public void ConfigureServices(IServiceCollection services)
{
...
services.AddSingleton<UserStore>();
}
由于我們并沒有使用MVC,而使用字元串拼接的形式傳回HTML較為費勁,在這裡定義幾個生成HTML的擴充方法:
public static class HttpResponseExtensions
{
public static async Task WriteHtmlAsync(this HttpResponse response, Func<HttpResponse, Task> writeContent)
{
var bootstrap = "<link rel=\"stylesheet\" href=\"https://cdn.bootcss.com/bootstrap/3.3.7/css/bootstrap.min.css\" integrity=\"sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u\" crossorigin=\"anonymous\">";
response.ContentType = "text/html";
await response.WriteAsync(#34;<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"UTF-8\">{bootstrap}</head><body><div class=\"container\">");
await writeContent(response);
await response.WriteAsync("</div></body></html>");
}
public static async Task WriteTableHeader(this HttpResponse response, IEnumerable<string> columns, IEnumerable<IEnumerable<string>> data)
{
await response.WriteAsync("<table class=\"table table-condensed\">");
await response.WriteAsync("<tr>");
foreach (var column in columns)
{
await response.WriteAsync(#34;<th>{HtmlEncode(column)}</th>");
}
await response.WriteAsync("</tr>");
foreach (var row in data)
{
await response.WriteAsync("<tr>");
foreach (var column in row)
{
await response.WriteAsync(#34;<td>{HtmlEncode(column)}</td>");
}
await response.WriteAsync("</tr>");
}
await response.WriteAsync("</table>");
}
public static string HtmlEncode(string content) =>
string.IsNullOrEmpty(content) ? string.Empty : HtmlEncoder.Default.Encode(content);
}
認證流程
接下來,便可以在我們的應用程式中愉快的使用認證系統了。在本文中隻是最簡單的示範,便不使用MVC了,而是在Configure中通過中間件的形式來實作。
登入
首先,我們定義一個登入的頁面以及登入成功後身體令牌的發放:
app.Map("/Account/Login", builder => builder.Run(async context =>
{
if (context.Request.Method == "GET")
{
await context.Response.WriteHtmlAsync(async res =>
{
await res.WriteAsync(#34;<form method=\"post\">");
await res.WriteAsync(#34;<input type=\"hidden\" name=\"returnUrl\" value=\"{HttpResponseExtensions.HtmlEncode(context.Request.Query["ReturnUrl"])}\"/>");
await res.WriteAsync(#34;<div class=\"form-group\"><label>使用者名:<input type=\"text\" name=\"userName\" class=\"form-control\"></label></div>");
await res.WriteAsync(#34;<div class=\"form-group\"><label>密碼:<input type=\"password\" name=\"password\" class=\"form-control\"></label></div>");
await res.WriteAsync(#34;<button type=\"submit\" class=\"btn btn-default\">登入</button>");
await res.WriteAsync(#34;</form>");
});
}
else
{
var userStore = context.RequestServices.GetService<UserStore>();
var user = userStore.FindUser(context.Request.Form["userName"], context.Request.Form["password"]);
if (user == null)
{
await context.Response.WriteHtmlAsync(async res =>
{
await res.WriteAsync(#34;<h1>使用者名或密碼錯誤。</h1>");
await res.WriteAsync("<a class=\"btn btn-default\" href=\"/Account/Login\">傳回</a>");
});
}
else
{
var claimIdentity = new ClaimsIdentity("Cookie");
claimIdentity.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()));
claimIdentity.AddClaim(new Claim(ClaimTypes.Name, user.Name));
claimIdentity.AddClaim(new Claim(ClaimTypes.Email, user.Email));
claimIdentity.AddClaim(new Claim(ClaimTypes.MobilePhone, user.PhoneNumber));
claimIdentity.AddClaim(new Claim(ClaimTypes.DateOfBirth, user.Birthday.ToString()));
var claimsPrincipal = new ClaimsPrincipal(claimIdentity);
// 在上面注冊AddAuthentication時,指定了預設的Scheme,在這裡便可以不再指定Scheme。
await context.SignInAsync(claimsPrincipal);
if (string.IsNullOrEmpty(context.Request.Form["ReturnUrl"])) context.Response.Redirect("/");
else context.Response.Redirect(context.Request.Form["ReturnUrl"]);
}
}
}));
如上,我們在Get請求中傳回登入頁面,在Post請求中驗證使用者名密碼,比對成功後,建立使用者Claim, ClaimsIdentity, ClaimsPrincipal 最終通過SignInAsync方法将使用者身份寫入到響應Cookie中,完成身份令牌的發放。
授權
我們在登入中間件後面添加一個自定義的授權中間件,用來禁用匿名使用者的通路:
app.UseAuthorize();
UseAuthorize的實作很簡單,就是判斷使用者是否已認證認證,并跳過對首頁的驗證:
public static IApplicationBuilder UseAuthorize(this IApplicationBuilder app)
{
return app.Use(async (context, next) =>
{
if (context.Request.Path == "/")
{
await next();
}
else
{
var user = context.User;
if (user?.Identity?.IsAuthenticated ?? false)
{
await next();
}
else
{
await context.ChallengeAsync();
}
}
});
}
其實上面的實作和我們在MVC5中常用的[Authorize]特性非常相似。
個人資訊
再定義一個認證後才能通路的頁面,并把目前登入使用者的資訊展示出來:
app.Map("/profile", builder => builder.Run(async context =>
{
await context.Response.WriteHtmlAsync(async res =>
{
await res.WriteAsync(#34;<h1>你好,目前登入使用者: {HttpResponseExtensions.HtmlEncode(context.User.Identity.Name)}</h1>");
await res.WriteAsync("<a class=\"btn btn-default\" href=\"/Account/Logout\">退出</a>");
await res.WriteAsync(#34;<h2>AuthenticationType:{context.User.Identity.AuthenticationType}</h2>");
await res.WriteAsync("<h2>Claims:</h2>");
await res.WriteTableHeader(new string[] { "Claim Type", "Value" },
context.User.Claims.Select(c => new string[] { c.Type, c.Value }));
});
}));
退出
退出則直接調用SignOutAsync方法即可:
app.Map("/Account/Logout", builder => builder.Run(async context =>
{
await context.SignOutAsync();
context.Response.Redirect("/");
}));
首頁
最後,添加一個簡單的首頁,友善測試:
app.Run(async context =>
{
await context.Response.WriteHtmlAsync(async res =>
{
await res.WriteAsync(#34;<h2>Hello Cookie Authentication</h2>");
await res.WriteAsync("<a class=\"btn btn-default\" href=\"/profile\">我的資訊</a>");
});
});
運作
在浏覽器中打開http://localhost:5000/,顯示 "Hello Cookie Authentication",點選 “我的資訊” 按鈕:
請求:
GET http://localhost:5000/profile HTTP/1.1
Host: localhost:5000
響應:
HTTP/1.1 302 Found
Location: http://localhost:5000/Account/Login?ReturnUrl=%2Fprofile
因為我們沒有登入,會在授權中間件中會執行context.ChallengeAsync();方法,最終會跳轉到登入頁面,然後輸入使用者名密碼(alice/alice),登入成功:
請求:
POST http://localhost:5000/Account/Login?ReturnUrl=%2Fprofile HTTP/1.1
Host: localhost:5000
Content-Type: application/x-www-form-urlencoded
returnUrl=%2Fprofile&userName=alice&password=alice
響應:
HTTP/1.1 302 Found
Location: /profile
Set-Cookie: .AspNetCore.Cookies=CfDJ8B4XRZETkRhMt3mT9VduB8KTzFhbcuszdNDXZpaQI3zSOcOD8uAzjr-iHzNCPVgXrKqxfK-MrP5d5r9X1zfKOgg2_j54t0ccAQ5nshSmXnRvjIZ6id3GD5fDP9v2x1iV0JE7X9IdoA458DZjx6qm6971GeY5HYVnT7odwgQR8eRaHo0-Wacmt95QuC9IVSapqsShHOeu5ZowFmDAPXrlUHOSwBPAjiLkf8mNbu8U4ZcWFlaBXC9-H-2_ts5wyi-90zw6jGxX3o7tRiQB4qq8IDmIJbZtN4Nl8TKHHcTbyFl5Z__MrgrjJ7s4cGdnIoDJWB9ENw1IGRgF3Rib8KmhkwhlUyO2VMnuVI8vSP2PcwrkUGtudJwHMHrA8cuS021xpmIhkhgW3e82r_0_jxAh1nqG4zwTP5i8iLU6FsOLLWatveSWB441Ntqw-L-pYczsBAYFRT0Hh56ofUAxGd7aaGtDx0jvuuxW5gK245Pf0TKG-4G46yDwLrFtjNcN_GREbpwtHAz-I7XqiDZgS3nbzjik5s05NxB7d6X3aOFc5JHCwFxW-i-xW-ToJLZrp3Jo8W0bAxVwxZIW2PwZlVtyeYSkqByFRaDS4qcBywE2Bmar_TyJm9UpVWaL2s9KxpU_DHN6meYne5E5dH4-k1DoABl6FyNPn6xYfMWxzu0_7ZFhVJjCycScy1jggCv4Hk5nkltj9A3QrFpNb_HCk21Uek9g-7Zi150EKfDzhGjMto5_hbWcmQtUsHuLbZlnYTHXZ-7zELZOepAUts2ZGoUnEaI; path=/; samesite=lax; httponly
可以看到,響應封包在Cookie中附加了身份令牌,并會跳轉到之前未登入時通路的頁面/profile,跳轉後顯示如下:
如上,因為浏覽器會自動附帶上剛才寫入的Cookie,是以授權通過,并展示出我們在登入時設定的Claim。
最後,點選 “退出” ,響應封包中會将Cookie設定為空(清除Cookie):
請求:
GET http://localhost:5000/Account/Logout HTTP/1.1
Host: localhost:5000
響應:
HTTP/1.1 302 Found
Location: /
Set-Cookie: .AspNetCore.Cookies=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; samesite=lax
補充
JwtClaimTypes
在上面的示範中,我們可以看到Cookie中的值非常的長,而我們設定的Claim并不多,這是因為微軟内置的ClaimTypes都是一大串的ULR位址。而對于 ASP.NET Core 本身來說,它并不關心你使用的ClaimType是什麼,隻要你讀取與儲存時使用的ClaimType保持一緻就沒有問題。我們可以使用簡短的字元串name來代替ClaimTypes.Name,但是推薦的做法是直接使用JwtClaimTypes,因為它夠簡短而且通用。
首先添加ClaimTypes的Package引用:
dotnet add package IdentityModel --version 2.12.0
然後,将之前的添加Claim的代碼修改如下:
var claimIdentity = new ClaimsIdentity("Cookie", JwtClaimTypes.Name, JwtClaimTypes.Role);
claimIdentity.AddClaim(new Claim(JwtClaimTypes.Id, user.Id.ToString()));
claimIdentity.AddClaim(new Claim(JwtClaimTypes.Name, user.Name));
claimIdentity.AddClaim(new Claim(JwtClaimTypes.Email, user.Email));
claimIdentity.AddClaim(new Claim(JwtClaimTypes.PhoneNumber, user.PhoneNumber));
claimIdentity.AddClaim(new Claim(JwtClaimTypes.BirthDate, user.Birthday.ToString()));
需要注意的是在建立ClaimsIdentity時需要手動指定它的NameType和RoleType,否則它将會使用預設的ClaimTypes.Name和ClaimTypes.Role,這樣會導緻我們從ClaimsPrincipal中擷取Identity.Name屬性和執行IsInRole檢查時失敗。
運作,重新登入,看看效果如何:
請求:
POST http://localhost:5000/Account/Login?ReturnUrl=%2Fprofile HTTP/1.1
Host: localhost:5000
Content-Type: application/x-www-form-urlencoded
returnUrl=%2Fprofile&userName=alice&password=alice
響應:
HTTP/1.1 302 Found
Date: Sun, 24 Sep 2017 06:04:28 GMT
Location: /profile
Set-Cookie: .AspNetCore.Cookies=CfDJ8B4XRZETkRhMt3mT9VduB8JV9NE2zJ9YVQmTpAU3E9Op9rQHvJ7WvdcrarbTGWE7c_e2aLpoZCdDJ7-0fTFZGUwuLVMC0vD_eeE9ct2Vj7gHCPCVeK3qQPsQ2lNmKvPwPf82-CURFXGgFC1y-N17tXdT7RoZhLHskIHx7qNcxeicS7wiSDhQD3l3mgOgq0bdjWJTk3LnpHk8zS0fDhKp6Vd6vFvCyzzRJu1ax5Y27Bg3dZp4Zsa3I9HAp5wXmyp51de8scS25nyaV0FEd1YUWgC1LsuwOODrSPqMkokv7XQXQc8W212O2dHbuuJ1xYEr1i5_Gl1syIX3ZuPj1_wqcnAKu5keY0ZVJz45iGYIRC09hd4n8j1SEA8dDlhbslCtyZ6xMt6MdRFv1D7fhbt_g4RGDk7ZkjpnT6z9q3dTWNzkS3gSd9AekBNbUNw9ojZmTWoCFhZgxz-6Wwtcp9z7vIo; path=/; samesite=lax; httponly
是不是短了很多?好吧,其實感覺還是有點長,沒關系,下面再介紹一種更加徹底的優化方式。
而最終頁面上展示的Claims資訊如下:
SessionStore
終極的解決方案就是參考Session的原理,把Claims資訊則儲存在服務端,并為其設定一個ID,Cookie中則隻儲存該ID,這樣就可以在服務端通過該ID來檢索出完整的Claims資訊。不過注意,這并不是在使用 ASP.NET Core 中的Session,隻是參考其存儲方式。
那麼怎麼做呢?在前面注冊Cookie認證時,使用的AddCookie方法中,其CookieAuthenticationOptions參數還可以設定一個ITicketStore類型的SessionStore屬性,我們可以通過實作該接口來自定義Cookie的存取方式,在這裡,使用本地緩存來實作:
首先添加Microsoft.Extensions.Caching.Memory的Package引用:
dotnet add package Microsoft.Extensions.Caching.Memory --version 2.0.0
然後,定義MemoryCacheTicketStore類:
public class MemoryCacheTicketStore : ITicketStore
{
private const string KeyPrefix = "CSS-";
private IMemoryCache _cache;
public MemoryCacheTicketStore()
{
_cache = new MemoryCache(new MemoryCacheOptions());
}
public async Task<string> StoreAsync(AuthenticationTicket ticket)
{
var key = KeyPrefix + Guid.NewGuid().ToString("N");
await RenewAsync(key, ticket);
return key;
}
public Task RenewAsync(string key, AuthenticationTicket ticket)
{
var options = new MemoryCacheEntryOptions();
var expiresUtc = ticket.Properties.ExpiresUtc;
if (expiresUtc.HasValue)
{
options.SetAbsoluteExpiration(expiresUtc.Value);
}
options.SetSlidingExpiration(TimeSpan.FromHours(1));
_cache.Set(key, ticket, options);
return Task.FromResult(0);
}
public Task<AuthenticationTicket> RetrieveAsync(string key)
{
_cache.TryGetValue(key, out AuthenticationTicket ticket);
return Task.FromResult(ticket);
}
public Task RemoveAsync(string key)
{
_cache.Remove(key);
return Task.FromResult(0);
}
}
将MemoryCacheTicketStore配置到CookieAuthenticationOptions中:
.AddCookie(options =>
{
options.SessionStore = new MemoryCacheTicketStore();
});
再次重新登入,響應如下:
HTTP/1.1 302 Found
Location: /profile
Set-Cookie: .AspNetCore.Cookies=CfDJ8B4XRZETkRhMt3mT9VduB8JOI85seEY347RswRzSiL_BQlJTb4JeFqpJzXNW8xOH1CwUKjwsx4CJWyMV5Wwq61IV0Kz4If0LmmJpEicZi2uxmyE2jcCXw_IRaPOaP0eJYM-DkpsjlA_Qu9knFxrpGQaI_BuRbUbbVhy62V5vjwMzoSewmQiPblS1PbPiqXfjAGmF_ZaSM40kwNOboAP_SMoJjX0AtEzmsUqECWFPZLxLoOJJ10Kz16cnSjtxha_KXY7i8f95jVbnX3cj79-GQ5iXnRePBBR_2LsXI5eDW_6E; path=/; samesite=lax; httponly
這樣,Cookie中的值就非常簡短了(由于其還包含AuthenticationProperties序列化後的值,并沒有想象中的短),并且Cookie中的值不會再随着我們設定的Claims的增加而變長,在分布式環境下則可以使用分布式緩存來儲存。
Reacting to back-end changes
對于認證系統,身份令牌都會有一個有效期的概念,而Cookie認證中預設有效期是14天,是以隻要浏覽器沒有清除Cookie,并且Cookie沒有過期,便麼就一直可以驗證通過。但是,如果使用者修改了密碼,我們希望該Cookie失效,或者是使用者更新了Claims的資訊時,我們希望重新生成Cookie,否則我們取到的還是舊的Claims資訊。那麼,該怎麼做呢?
對此,網上比較流行的做法是在使用者資料庫中添加一個安全字段,當使用者修改了一些安全性資訊時,便更新該字段,并在Claim中加入此字段,一起寫入到Cookie中,驗證時便可以判斷該字段是否與資料庫一緻,若不一緻則驗證失敗或重新生成:
public static class LastChangedValidator
{
public static async Task ValidateAsync(CookieValidatePrincipalContext context)
{
var userRepository = context.HttpContext.RequestServices.GetRequiredService<IUserRepository>();
var userPrincipal = context.Principal;
string lastChanged = (from c in userPrincipal.Claims where c.Type == "LastUpdated" select c.Value).FirstOrDefault();
if (string.IsNullOrEmpty(lastChanged) || !userRepository.ValidateLastChanged(userPrincipal, lastChanged))
{
// 1. 驗證失敗 等同于 Principal = principal;
context.RejectPrincipal();
// 2. 驗證通過,并會重新生成Cookie。
context.ShouldRenew = true;
}
}
}
如上,1 和 2 兩種方式,我們可以根據實際情況選擇一種,而不應該同時存在。
在Cookie認證的配置中,提供了一系列的事件,其中便有一個OnValidatePrincipal事件,用來附加服務端的驗證邏輯:
.AddCookie(options =>
{
options.Events = new CookieAuthenticationEvents
{
OnValidatePrincipal = LastChangedValidator.ValidateAsync
};
});
如上,便完成了該事件的注冊,不過該驗證通常會查詢資料庫,損耗較大,可以通過設定驗證周期來提高性能,如:每5分鐘執行驗證一次(在MVC5中是有該配置的,Core中暫未發現)。
Persistent and ExpiresUtc
對于Cookie來說,預設的過期時間為Session,即關閉浏覽器後就清除。通常在使用者登入時會提供一個記住我的選項,用來保證在關閉浏覽時不清除Cookie。而在SignInAsync方法中,還接收一個AuthenticationProperties類型的參數,可以用來指定Cookie是否持久化以及過期時間:
await HttpContext.SignInAsync("MyCookieAuthenticationScheme", principal, new AuthenticationProperties
{
// 持久儲存
IsPersistent = true
// 指定過期時間
ExpiresUtc = DateTime.UtcNow.AddMinutes(20)
});
看一下CookieAuthenticationHandler中SignInAsync方法關于該配置的實作:
if (!signInContext.Properties.ExpiresUtc.HasValue)
{
signInContext.Properties.ExpiresUtc = issuedUtc.Add(Options.ExpireTimeSpan);
}
if (signInContext.Properties.IsPersistent)
{
var expiresUtc = signInContext.Properties.ExpiresUtc ?? issuedUtc.Add(Options.ExpireTimeSpan);
signInContext.CookieOptions.Expires = expiresUtc.ToUniversalTime();
}
隻有在IsPersistent為True時,才會在寫入Cookie指定Expires。需要注意的是浏覽器中的Cookie過期時間僅僅是用來指定浏覽器是否删除Cookie,而在Cookie存儲的值中,也會包含該Cookie認證的釋出時間和過期時間等,并在HandleAuthenticateAsync方法中對會其進行驗證,并不是說隻要你有Cookie就能驗證通過。
源碼解析
AddCookie
AddCookie已多次用過,無需多說,直接看源碼:
public static AuthenticationBuilder AddCookie(this AuthenticationBuilder builder)
=> builder.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, null, null);
public static AuthenticationBuilder AddCookie(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action<CookieAuthenticationOptions> configureOptions)
{
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<CookieAuthenticationOptions>, PostConfigureCookieAuthenticationOptions>());
return builder.AddScheme<CookieAuthenticationOptions, CookieAuthenticationHandler>(authenticationScheme, displayName, configureOptions);
}
其實作非常簡單,首先注冊了Cookie認證的配置項CookieAuthenticationOptions,而authenticationScheme參數用來指定目前認證的唯一的辨別,不能重複。通常,使用預設的CookieAuthenticationDefaults.AuthenticationScheme就可以了,但是當我們同時使用多個Cookie認證方式時,需要手動為他們指定不同的Scheme。
最後,直接調用上一章中介紹的AddScheme,完成對CookieAuthenticationHandler的注冊。
CookieAuthenticationOptions
CookieAuthenticationOptions是針對Cookie認證的各種配置,如重定向位址,認證階段事件的注冊,Cookie名,過期時間等等,首先看一下它的定義:
public class CookieAuthenticationOptions : AuthenticationSchemeOptions
{
private CookieBuilder _cookieBuilder = new RequestPathBaseCookieBuilder
{
SameSite = SameSiteMode.Lax,
HttpOnly = true,
SecurePolicy = CookieSecurePolicy.SameAsRequest,
};
public CookieAuthenticationOptions()
{
ExpireTimeSpan = TimeSpan.FromDays(14);
ReturnUrlParameter = CookieAuthenticationDefaults.ReturnUrlParameter;
SlidingExpiration = true;
Events = new CookieAuthenticationEvents();
}
public CookieBuilder Cookie
{
get => _cookieBuilder;
set => _cookieBuilder = value ?? throw new ArgumentNullException(nameof(value));
}
public new CookieAuthenticationEvents Events
{
get => (CookieAuthenticationEvents)base.Events;
set => base.Events = value;
}
public ITicketStore SessionStore { get; set; }
// 當使用者未登入時,重定向到該路徑,預設:/Account/Login
public PathString LoginPath { get; set; }
// 指定登出的路徑,預設:/Account/Logout
public PathString LogoutPath { get; set; }
// 當使用者無權通路時,重定向到該路徑,預設:/Account/AccessDenied
public PathString AccessDeniedPath { get; set; }
// 傳回位址參數名,預設:ReturnUrl
public string ReturnUrlParameter { get; set; }
// 指定Cookie的過期時間
public TimeSpan ExpireTimeSpan { get; set; }
// 當Cookie過期時間已達一半時,是否重置為ExpireTimeSpan
public bool SlidingExpiration { get; set; }
// 用來将Cookie寫入到浏覽器或删除
public ICookieManager CookieManager { get; set; }
public IDataProtectionProvider DataProtectionProvider { get; set; }
public ISecureDataFormat<AuthenticationTicket> TicketDataFormat { get; set; }
}
CookieBuilder
在 ASP.NET Core 2.0 中對針對Cookie的配置集中放到CookieBuilder類型當中,相比之前更加清晰:
public class CookieBuilder : object
{
public virtual string Name { get; set; }
public virtual string Path { get; set; }
public virtual string Domain { get; set; }
public virtual bool HttpOnly { get; set; }
public virtual SameSiteMode SameSite { get; set; }
public virtual CookieSecurePolicy SecurePolicy { get; set; }
public virtual TimeSpan? Expiration { get; set; }
public virtual TimeSpan? MaxAge { get; set; }
public CookieOptions Build(HttpContext context);
}
都是一些針對Cookie配置的标準用法,無需多說。
CookieAuthenticationEvents
CookieAuthenticationEvents為我們提供了在Cookie認證的各個階段(如,登入前後,退出前後,重定向等)注冊事件的機會,以便我們攔截一些預設行為,來自定義處理邏輯。
public class CookieAuthenticationEvents
{
public virtual Task ValidatePrincipal(CookieValidatePrincipalContext context) => OnValidatePrincipal(context);
public virtual Task SigningIn(CookieSigningInContext context) => OnSigningIn(context);
public virtual Task SignedIn(CookieSignedInContext context) => OnSignedIn(context);
public virtual Task SigningOut(CookieSigningOutContext context) => OnSigningOut(context);
public virtual Task RedirectToLogout(RedirectContext<CookieAuthenticationOptions> context) => OnRedirectToLogout(context);
public virtual Task RedirectToLogin(RedirectContext<CookieAuthenticationOptions> context) => OnRedirectToLogin(context);
public virtual Task RedirectToReturnUrl(RedirectContext<CookieAuthenticationOptions> context) => OnRedirectToReturnUrl(context);
public virtual Task RedirectToAccessDenied(RedirectContext<CookieAuthenticationOptions> context) => OnRedirectToAccessDenied(context);
}
每一個事件都有它的預設實作,這裡就不再多說,我們可以根據實際情況進行注冊。
CookieAuthenticationHandler
CookieAuthenticationHandler便是Cookie認證的具體實作:
public class CookieAuthenticationHandler : AuthenticationHandler<CookieAuthenticationOptions>, IAuthenticationSignInHandler, IAuthenticationSignOutHandler
{
...
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var result = await EnsureCookieTicket();
if (!result.Succeeded)
{
return result;
}
var context = new CookieValidatePrincipalContext(Context, Scheme, Options, result.Ticket);
// 執行前而介紹的服務端驗證
await Events.ValidatePrincipal(context);
if (context.ShouldRenew)
{
// 重新生成Cookie
RequestRefresh(result.Ticket);
}
return AuthenticateResult.Success(new AuthenticationTicket(context.Principal, context.Properties, Scheme.Name));
}
public async virtual Task SignInAsync(ClaimsPrincipal user, AuthenticationProperties properties)
{
...
var ticket = new AuthenticationTicket(signInContext.Principal, signInContext.Properties, signInContext.Scheme.Name);
....
var cookieValue = Options.TicketDataFormat.Protect(ticket, GetTlsTokenBinding());
Options.CookieManager.AppendResponseCookie(Context, Options.Cookie.Name, cookieValue, signInContext.CookieOptions);
var signedInContext = new CookieSignedInContext(Context, Scheme, signInContext.Principal, signInContext.Properties, Options);
await Events.SignedIn(signedInContext);
var shouldRedirect = Options.LoginPath.HasValue && OriginalPath == Options.LoginPath;
await ApplyHeaders(shouldRedirect, signedInContext.Properties);
Logger.SignedIn(Scheme.Name);
}
}
其核心方法HandleAuthenticateAsync會檢查請求Cookie,查找與CookieBuilder.Name對應的Cookie值,解密反序列化成AuthenticationTicket對象,最後在上一章介紹的AuthenticationMiddleware中間件中将Principal賦予給HttpContext。
而CookieAuthenticationHandler還實作了IAuthenticationSignInHandler和IAuthenticationSignOutHandler,這也是ASP.NET Core中内置的唯一支援登入和退出的認證方式。在SignInAsync方法中使用ClaimsPrincipal來建立一個AuthenticationTicket對象,然後将其加密,寫入到Cookie中,便完成了登入(身份令牌的發放),而SignOutAsync方法則隻是簡單的删除Cookie。
篇幅有限,就不再多說,感興趣的可以去看一下完整代碼:CookieAuthenticationHandler。
總結
Cookie認證是一種本地認證方式,也是最為簡單,最為常用的認證方式。其認證邏輯也很簡單,總結一下就是擷取請求中指定的Cookie,解密成功後,反序列生成 AuthenticationTicket 對象,并進行一系列的驗證,而登入方法與之對應:根據使用者資訊建立 AuthenticationTicket 對象,并加密後序列化,寫入到Cookie中。在下一章中,就來介紹一下最為流行的遠端認證方式:OAuth 和 OpenID Connect。
最後附上本文中的示例代碼:https://github.com/RainingNight/AspNetCoreSample/tree/master/src/Functional/Authentication/CookieSample。