如果大夥伴們以前寫過 ASP 或 PHP 之類的,相信各位對基於 Session 的身份驗證很熟悉(其實在瀏覽器端是結合 Cookie 來處理的)。這種驗證方式是比較早期的,操作起來也不復雜。
a、使用者開啟(或自動跳轉到)登入頁,輸入你的大名和密碼,登入。
b、提交到伺服器,比較一下使用者名稱和密碼是否正確。
c、若驗證成功,往 Session 裡寫入一個標識。實際上往Session裡面寫啥都行,能作為使用者登入標識就行。畢竟嘛,對於每個連線來說,Session是唯一的,所以,在頁面“頭部”驗證時,許多時候壓根不用關心Session裡存了啥,只要有登入標識就OK。
當然,你會說,我 K,ao,這樣驗證是不是問題多多?確實,跨域驗證就出問題,而且單點登入也不好控制。所以現在才會衍生出許多驗證方式。甚至弄得很複雜,於是我們們就知道只要涉及到驗證和授權的內容就看得人頭暈。很真實,是TM挺複雜的。
不過,你同時也會發現,現在很多 Web 應用還是會使用 Session 來驗證的。為啥呢?因為我的專案很小,小到可能就只有五六個人登入,我用得著搞那麼複雜嗎?
老周不才,沒做過什麼大專案,小專案倒是坑了不少人。用小專案來忽悠客戶一向是老周的核心競爭力,一直被模仿卻從未被超越過。你不妨想想,你開了個小店,平時只賣幾張不知道正不正版的有顏色的DVD,店裡的員工可能就幾個,做個管理系統就那麼幾個操作員。你說這身份驗證你會選那些複雜到跳樓的方案嗎。
------------------------------- 銀河分界線 ------------------------------------
以前,我們在ASP中使用 Session 還是很簡單的。ASP 檔案中有一種類似C標頭檔案的東西(inc檔案),可以在其他ASP檔案中包含。那麼,這個 inc 檔案裡寫幾行程式碼——檢查一下 Session 裡是否包含登入標識。若沒有,跳轉到登入頁。然後,需要作驗證的頁面就 include 這個 inc 檔案。這樣就以很簡單但很混亂的方式實現了驗證功能。
在 ASP.NET Core 裡其實你也可以這樣用,在服務容器中啟用 Session 功能,然後寫個中介軟體,插入到 HTTP 管道的頭部,檢查 Session 中的登入標識,如果沒有那就 Redirect 到登入 URL。
這樣做確實可行的,但又出新問題了——所有進來的請求都會進行驗證了,這會導致客戶端訪問啥都要驗證了。當然,你會想到,Map When 就行了唄,讓中介軟體有了條件限制。
------------------------------ M77星雲分界線 ----------------------------------
以上做法並不符合 ASP.NET Core 設計模型。ASP.NET Core 中為驗證和授權提供了獨立的功能實現的。好了,前文扯了幾噸的廢話,正片現在開始。
驗證與授權是兩個不同的過程,但它們又經常一起使用。所以很多大夥伴經常分不清,關鍵是這兩貨的單詞也長得很像,不信你看:
1、驗證——authentication
2、授權——authorization
怎麼樣?像吧,也不知道那些洋鬼子們怎麼想的,把它倆弄得那麼像。
老周試著用一個故事來區別這兩個過程——假如你去你朋友家裡玩。首先,你朋友家裡得有人,而且你按門鈴後他會開門讓你進去(驗證);之後,你進去了,但是朋友家裡有很多個房間,一般大客廳你肯定可以站在那裡的,但是,朋友的臥室就不見得會允許你進去(授權),除非你們特別熟。
驗證是你能不能進別人家的門,授權是進了門後你被允許做什麼。
------------------------- 小龍蝦星人分界線 ------------------------
下面分別說說這兩個過程的一些要素。
A、驗證
現在的網站我們們都知道,身份驗證方式很多。你可以使用者名稱/密碼登入,你可以用QQ、微博、微信等帳號登入,你可以用簡訊驗證碼登入。像QQ、微信這些是第三方授權的,為了省去每去訪問都要授權的麻煩,提供驗證的伺服器會發給你一個 Token,下次訪問你用這個 Token 就行了。當然,這個 Token 也是有時間限制的,過期了就不能用。
這種方法不會暴露使用者資訊,但也不是真的很安全的,別人可以不知道你是誰,他只要盜走你的 Token 也能用來登入。好比一些平臺會開放給開發者 API,比如微博開放平臺,會分配給你一個 App Key 和一個金鑰,然後你呼叫 API 時要傳遞這些東西。如果我知道你的 App Key 和金鑰,那我照樣可以以你的身份去呼叫 API。
正因為驗證的方式那麼多,所以,應用程式必須要有個東東來標識它們,這就跟我們在學校有學號一樣道理。於是就出了個名詞叫 Authentication Scheme。驗證架構,但翻譯為驗證方案更好聽。說白了,就是你給你這種驗證方式取個名字罷了。比如,郵件驗證碼登入的叫“Email-Auth”。像我們們常聽說的什麼 OAuth 2.0,也是一種驗證方案。
光有了驗證方案名稱可不行,你得讓程式知道咋去驗證,這就需要為每個方案配套一個 Handler 了,這個 Handler 是一個類,但它要求你實現 IAuthenticationHandler 介面。這樣便有了統一的呼叫標準,當你選擇某方案完成驗證時,就會呼叫與這個方案對應的 Handler 來處理。例如:
方案 | Handler | 說明 |
Email-Auth | EmailAuthenHandler | 郵件驗證 |
Pwd-Auth | UserPasswordHandler | 使用者名稱/密碼驗證 |
大概微軟也知道在 .NET 庫中整合太多驗證方案太笨重,所以現在新版本的 ASP.NET Core 的預設庫中只保留一些基本的驗證方案——如 Cookie,這個方案是內建的,我們不需要自己寫程式碼(在 Microsoft.AspNetCore.Authentication.Cookies 名稱空間中)。
在 Microsoft.AspNetCore.Authentication 名稱空間下有個抽象類 AuthenticationHandler<TOptions>,它實現了一點基本功能,我們如果想自己寫驗證方案,可以從這個類派生。但,老周這次要用的方案只是對 Session 的簡單檢查,所以,就不需要從這個抽象類派生,而是直接實現 IAuthenticationHandler 介面。
在實現驗證邏輯前,我們們寫個類,作為一些可設定引數的選項。
public class TestAuthenticationOptions { /// <summary> /// 登入入口路徑 /// </summary> public string LoginPath { get; set; } = "/Home/Login"; /// <summary> /// 存入Session的鍵名 /// </summary> public string SessionKeyName { get; set; } = "uid"; /// <summary> /// 返回URL引數名 /// </summary> public string ReturnUrlKey { set; get; } = "return"; }
這裡老周只按照專案需求設定了三個選項,想新增選項的話得看你的實際需求了。
LoginPath:登入入口,這個屬性指定一個URL(一般是相對URL),表示使用者輸入名稱和密碼登入的頁面(可以是MVC,可以是 RazorPages,這個無所謂,由URL路由和你的程式碼決定)。
SessionKeyName:這個屬性設定 Session 裡面存放登入標識時的 Key 名。其實 Session 和字典物件類似,裡面每個項都有唯一的 Key。
ReturnUrlKey:指定一個欄位名,這個欄位名一般附加在URL的引數中,表示要跳轉回去的路徑。比如,設定為“return”,那麼,假如我們要訪問 https://localhost/admin/op,但這個路徑(或頁面)必須要驗證,否則不能訪問(其實包含授權過程),於是會自動跳轉到 https://localhost/Home/login,讓使用者登入。但使用者登入成功後要返回 /admin/op,所以,在 Login 後加個引數:
https://localhost/Home/Login?return=/admin/op
當登入並驗證成功後,根據這個 return 查詢欄位跳轉回去。如果你把 ReturnUrlKey 屬性設定為“back”,那麼登入的URL就是:
https://localhost/Home/Login?back=/admin/op
在實現 IAuthenticationHandler 介面時,可以同時實現 IAuthenticationSignInHandler 介面。而 IAuthenticationSignInHandler 介面是包含 IAuthenticationHandler 和 IAuthenticationSignOutHandler 介面的。這就等於,你只實現 IAuthenticationSignInHandler 介面就行,它包含三個介面的方法成員。
InitializeAsync 方法:初始化時用,一般可以從這裡獲取當前請求關聯的 HttpContext ,以及正在被使用的驗證方案資訊。
AuthenticateAsync 方法:驗證過程,此處老周的做法僅僅看看 Session 中有沒有需要的Key就行了。
ChallengeAsync 方法:一旦驗證失敗,就會呼叫這個方法,向客戶端索要驗證資訊。這裡需要的驗證資訊是輸入使用者名稱和密碼。所以,老周在些方法中 Redirect 到登入頁面。
ForbidAsync 方法:禁止訪問時用,可以直接呼叫 HttpContext 的 ForbidAsync 方法。
SignInAsync 方法:登入時呼叫,這裡老周只是把使用者名稱放入 Session 就完事了。
SignOutAsync 方法:登出時呼叫,這裡只是把 Session 中的使用者名稱刪除即可。
這些方法都可以由 ASP.NET Core 內部自動呼叫,也可以通過 HttpContext 的擴充套件方法手動觸發,如SignInAsync、AuthenticateAsync、ChallengeAsync等。
public class TestAuthenticationHandler : IAuthenticationSignInHandler { /// <summary> /// 驗證方案的名稱,可以自行按需取名 /// </summary> public const string TEST_SCHEM_NAME = "some_authen"; /// <summary> /// 依賴注入獲取的選項 /// </summary> public TestAuthenticationOptions Options { get; private set; } public TestAuthenticationHandler(IOptions<TestAuthenticationOptions> opt) { Options = opt.Value; } public HttpContext HttpContext { get; private set; } public AuthenticationScheme Scheme { get; private set; } public Task<AuthenticateResult> AuthenticateAsync() { // 先要看看驗證方案是否與當前方案匹配 if(Scheme.Name != TEST_SCHEM_NAME) { return Task.FromResult(AuthenticateResult.Fail("驗證方案不匹配")); } // 再看Session if(!HttpContext.Session.Keys.Contains(Options.SessionKeyName)) { return Task.FromResult(AuthenticateResult.Fail("會話無效")); } // 驗證通過 string un = HttpContext.Session.GetString(Options.SessionKeyName)??string.Empty; ClaimsIdentity id = new(TEST_SCHEM_NAME); id.AddClaim(new(ClaimTypes.Name, un)); ClaimsPrincipal prcp = new(id); AuthenticationTicket ticket = new(prcp, TEST_SCHEM_NAME); return Task.FromResult(AuthenticateResult.Success(ticket)); } public Task ChallengeAsync(AuthenticationProperties? properties) { // 跳轉到登入入口 HttpContext.Response.Redirect($"{Options.LoginPath}?{Options.ReturnUrlKey}={HttpContext.Request.Path}"); return Task.CompletedTask; } public async Task ForbidAsync(AuthenticationProperties? properties) { await HttpContext.ForbidAsync(Scheme.Name); } public Task InitializeAsync(AuthenticationScheme scheme, HttpContext context) { // 獲取一些必備物件的引用 HttpContext = context; Scheme = scheme; return Task.CompletedTask; } public Task SignInAsync(ClaimsPrincipal user, AuthenticationProperties? properties) { // 獲取使用者名稱 string uname = user.Identity?.Name ?? string.Empty; if(!string.IsNullOrEmpty(uname)) { HttpContext.Session.SetString(Options.SessionKeyName, uname); } return Task.CompletedTask; } public Task SignOutAsync(AuthenticationProperties? properties) { if(HttpContext.Session.Keys.Contains(Options.SessionKeyName)) { HttpContext.Session.Remove(Options.SessionKeyName); } return Task.CompletedTask; } }
在 AuthenticateAsync 方法中,先要檢查一下,當前所使用用的驗證方案是否與 TEST_SCHEM_NAME 所表示的方案名稱相同。這是為了防止把 TestAuthenticationHandler 與錯誤的驗證方案進行註冊繫結。例如我這個是實現用Session來驗證的,要是把它與“Email-Auth”方案繫結,就會出現邏輯錯誤,畢竟此類不是用電子郵件來驗證的。
不管是實現驗證方法AuthenticateAsync 還是登入方法SignInAsync,都不要去檢查使用者名稱和密碼,而應該把使用者名稱和密碼驗證放到登入的頁面或 Controller 中處理。因為這個自定義的 TestAuthenticationHandler 在許多需要驗證的請求中都要呼叫,如果你在這裡去檢查使用者名稱和密碼,豈不是每次都要跳轉到登入頁讓使用者去輸入?
B、授權
一旦驗證完成,就到了授權過程。
驗證過程通過驗證方案名稱來標識,同樣,授權過程也可包含多個策略。
比如,可以基於使用者的角色進行授權,管理員的許可權多一些,非管理員的少一些;
可以基於使用者的年齡進行授權,哪些遊戲 15 歲以下的不能玩;
或者,基於使用者的信用分來授權,信用差的不能貸款;信用好的允許你貸款
……
授權過程處理是通過收集一系列的宣告(Claim)來評估一下使用者具有哪些許可權。比如
你是管理員嗎?
你幾歲了?
你過去三年的信用值是多少?
你是不是VIP使用者?
你的購物積分多少?
你過去一年在我店買過幾次東西?
……
這些宣告來源很多,可以在過去使用者購買東西時存入資料庫並彙總出來,也可能使用者在登入驗證時從資料庫中查詢到。處理程式碼要根據這些宣告來綜合評定一下,你是否達到授權的【要求】。
這些【要求】就可以用 IAuthorizationRequirement 介面來表示。好玩的是,這個介面沒有規定任何方法成員,你只需要有個類來實現這個介面就行。比如使用者積分,寫個類叫 UserPoints,實現這個介面,再加個屬性叫 PointValue,表示積分數。
然後,你把這個 UserPoints 類新增到某授權策略的 Requirements 集合中,在處理授權評估時,再通過程式碼檢查一下里面的各種實現了 IAuthorizationRequirement 介面的物件,看看符不符合條件。
而自定義的授權策略處理是實現 IAuthorizationHandler 介面。你看看,是不是原理差不多,剛才驗證的時候會實現自定義的 Handler,現在授權時又可以實現 Handler。
在 Session 驗證這個方案中,我們不需要寫自定義的授權 Handler,只需要呼叫現有API開啟授權功能,並註冊一個有效的策略名稱即可。而 IAuthorizationRequirement 我們也不用實現,直接用擴充套件方法 RequireAuthenticatedUser 就行。意思是說只要有已登入的使用者名稱就行,畢竟我們們前面在驗證時,已經提供了一個有效的使用者登入名,還記得 AuthenticateAsync 方法中的這幾行嗎?
// 驗證通過 string un = HttpContext.Session.GetString(Options.SessionKeyName)??string.Empty; ClaimsIdentity id = new(TEST_SCHEM_NAME); id.AddClaim(new(ClaimTypes.Name, un)); ClaimsPrincipal prcp = new(id); AuthenticationTicket ticket = new(prcp, TEST_SCHEM_NAME); return Task.FromResult(AuthenticateResult.Success(ticket));
其實我們已經新增了一個宣告——Name,以使用者名稱為標識,在授權策略中,程式要查詢的就是這個宣告。只要找到,就能授權;否則拒絕訪問。
----------------------------------- 第三宇宙分界線 -----------------------------------
在 Program.cs 檔案中,我們要註冊這些服務類。
var builder = WebApplication.CreateBuilder(args); // 啟用Session功能 builder.Services.AddSession(o => { // 把時間縮短一些,好測試 o.IdleTimeout = TimeSpan.FromSeconds(5); }); // 這個用來檢查使用者名稱和密碼是否正確 builder.Services.AddSingleton<UserChecker>(); // 使用MVC功能 builder.Services.AddControllersWithViews(); // 註冊剛剛定義的選項類,可以依賴注入 // 不要忘了,不然出大事 builder.Services.AddOptions<TestAuthenticationOptions>(); // 新增驗證功能 builder.Services.AddAuthentication(opt => { // 新增我們自定義的驗證方案名 opt.AddScheme<TestAuthenticationHandler>(TestAuthenticationHandler.TEST_SCHEM_NAME, null); }); // 新增授權功能 builder.Services.AddAuthorization(opt => { // 註冊授權策略,名為“demo2” opt.AddPolicy("demo2", c => { // 與我們前面定義的驗證方案繫結 // 授權過程跟隨該驗證後發生 c.AddAuthenticationSchemes(TestAuthenticationHandler.TEST_SCHEM_NAME); // 要求存在已登入使用者的標識 c.RequireAuthenticatedUser(); }); }); var app = builder.Build();
把Session中的過期進間設為5秒,是為了好測試。
上面程式碼還註冊了一個單例項模式的 UserChecker,這只是個測試,老周不使用資料庫了,就用一個寫“死”了的類來檢查使用者名稱和密碼是否正確。
public class UserChecker { private class UserInfo { public string Name { get; init; } public string Password { get; init; } } // 簡單粗暴的使用者資訊,只為測試而生 static readonly IEnumerable<UserInfo> _Users = new UserInfo[] { new(){Name = "lucy", Password="123456"}, new(){Name= "tom", Password="abcd"}, new() {Name="jim", Password="xyz321"} }; /// <summary> /// 驗證使用者名稱和密碼是否有效 /// </summary> /// <param name="name">使用者名稱</param> /// <param name="pwd">使用者密碼</param> /// <returns></returns> public bool CheckLogin(string name, string pwd) => _Users.Any(u => u.Name == name.ToLower() && u.Password == pwd); }
在 App 物件 build 了之後,記得插入這些中介軟體到HTTP管道。
app.UseSession(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllerRoute("main", "{controller=Home}/{action=Index}");
注意順序,授權在驗證之後,驗證和授權要在 Map MVC的處理之前。
測試專案中我用到了兩個 Controller。第一個是 Home,可以隨便訪問,故不需要考慮驗證和授權的問題;第二個是 Admin,只有已正確登入的使用者才可以訪問。
Admin 控制器很簡單,只返回對應的檢視。
[Authorize("demo2")] public class AdminController : Controller { public IActionResult MainLoad() { return View(); } }
注意在此控制器上應用了 Authorize 特性,並且指定了使用的授權策略是“demo2”。表明這個控制器裡面的所有 Action 都不能匿名訪問,要訪問得先登入。
MainLoad 檢視如下:
<h2> 這是管理後臺 </h2>
--------------------------- L78分界線 ----------------------------
Home 控制器允許匿名訪問,其中包含了使用者登入入口 Login。
public class HomeController : Controller { TestAuthenticationOptions _options; public HomeController(IOptions<TestAuthenticationOptions> o) { _options = o.Value; } public IActionResult Index() => View(); public IActionResult Login() { // 獲取返回的URL if (!HttpContext.Request.Query.TryGetValue(_options.ReturnUrlKey, out var url)) { url = string.Empty; } // 用模型來傳遞URL return View((object)url.ToString()); } public async Task<IActionResult> PostLogin( string name, //使用者名稱 string pwd, //密碼 string _url, //要跳回的URL [FromServices]UserChecker usrchecker //用來驗證使用者名稱和密碼 ) { if(string.IsNullOrEmpty(name) || string.IsNullOrEmpty(pwd)) { return View("Login", _url); } // 如果密碼不正確 if (!usrchecker.CheckLogin(name, pwd)) return View("Login", _url); // 準備登入用的材料 // 1、宣告 Claim cname = new(ClaimTypes.Name, name); // 2、標識 ClaimsIdentity id = new(TestAuthenticationHandler.TEST_SCHEM_NAME); id.AddClaim(cname); // 3、主體 ClaimsPrincipal principal = new(id); // 登入 await HttpContext.SignInAsync(TestAuthenticationHandler.TEST_SCHEM_NAME, principal); if(!string.IsNullOrEmpty(_url)) { // 重定向回到之前的URL return Redirect(_url); } return View("Login", _url); } }
Home 控制器中只用到兩個檢視,一個是Index,預設主頁;另一個是 Login,用於顯示登入UI。
Login 檢視如下:
@inject Microsoft.Extensions.Options.IOptions<DemoApp5.TestAuthenticationOptions> _opt @model string <form method="post" asp-controller="Home" asp-action="PostLogin"> <p> 使用者名稱: <input name="name" type="text"/> </p> <p> 密 碼: <input name="pwd" type="password"/> </p> <button type="submit">確 定</button> <input type="hidden" name="_url" value="@Model" /> </form>
這個檢視中繫結的 Model 型別為string,實際上就是 Challenge 方法重定向到此URL時傳遞的回撥URL引數(/Home/Login?return=/Admin/XXX)。在Login方法中,通過View方法把這個URL傳給檢視中的 Model 屬性。
之所以要使用模型繫結,是因為HTTP兩次請求間是無狀態的:
第一次,GET 方式訪問 /Home/Login,並用 return 引數傳遞了回撥URL;
第二次,輸入完使用者名稱和密碼,POST 方式提交時呼叫的是 PostLogin 方法,這時候,Login?return=xxxxx 傳遞的URL已經丟失了,無法再獲取。只能繫結到 Model 上,再從 Model 中取值繫結到 hidden 元素上。
<input type="hidden" name="_url" value="@Model" />
POST的時候就會連同這個 hidden 一起發回給伺服器,這樣在 PostLogin 方法中還能夠獲取到這個回撥URL。
----------------------------------------------------------------------------------------------------
執行示例後,先是開啟預設的 Index 檢視。
點選“管理頁入口”連結,進入 Admin/MainLoad,此時候因為沒有登入,就會跳轉到 /Home/Login 。輸入一個正確的使用者名稱和密碼,登入。
成功後就跳回到管理後臺。
5 秒鐘後就會過期,要訪問就得重新登入。當然這個主要為了測試方便。實際運用可以設定 15 -20 分鐘。
儲存 Session 標識的 Cookie 由執行庫自動完成,通過瀏覽器的開發人員工具能夠看到生成的 Cookie。
預設的 Cookie 使用了名稱 AspNetCore.Session,如果你覺得這個名字不夠高大上,可以自己改。在 AddSession 時設定。
builder.Services.AddSession(o => { // 把時間縮短一些,好測試 o.IdleTimeout = TimeSpan.FromSeconds(5); o.Cookie.Name = "dyn_ssi"; });
然後,生成的用來儲存Session標識的 Cookie 就會變成: