參考資料:
書籍《ASP.NET Core IN ACTION SECOND EDITION》ch14、ch15
- 0. 照例吐槽
- 1. 什么是認證 Authentication 和授權 Authorization
- 2. ASP.NET Core 中的 users 和 claims
- 3. 認證和授權的抽象過程
- 4. 如何處理未經授權的請求
- 更復雜健壯的 policy
- 總結
0. 照例吐槽
這里我本來寫了一堆吐槽公司的話,但還是不放出來了,過於負能量。
但話說回來,我們當仆人的當然要服侍好我們的資本家主人們。就這樣,為了更好的被奴役,被榨取剩余價值,我今天要來了解一下 ASP.NET Core 的認證與授權。
1. 什么是認證 Authentication 和授權 Authorization
用外行都能聽懂的話來說:
- 認證 Authentication —— 為你的應用創建用戶並讓他們能夠登錄你的應用,即確認用戶是誰
- 授權 Authorization —— 控制當前登錄的用戶在的應用里可以做什么,不能做什么
用內行能聽懂的話來說:
- 認證 Authentication —— 確定是誰發出的請求
- 授權 Authorization —— 確定請求的行為是否被允許
2. ASP.NET Core 中的 users 和 claims
可能大家都看過官方文檔的默認教程,或者用 VS 創建過包含用戶系統的 Web 應用,他們都用了一個叫 Identity 的官方庫,在官方文檔中被翻譯為“標識”,笑死,根本看不懂,不如直接叫 Identity。
要明確一下,這個 Identity 中的 User 和 ASP.NET Core 的 user 不是同一個東西,他們是可以分開使用的。例如,有的公司只有一個用戶中心,所有系統都從這一個用戶中心取用戶信息,你當然可以在你的某個應用里用 Identity 再搭建維護一套用戶信息,但更多時候是從用戶中心取了直接用,沒有必要再在某個小應用里搞一套用戶信息了,也就沒有必要用 Identity 的 User 了。但你這個應用的認證和授權還是要進行,所以 ASP.NET Core 中的 user 和 claim 這時候就可以不依賴 Identity 而獨立地起作用了。
下面我們再來了解一下幾個東西:
Principal
不知道這玩意怎么翻譯,就不翻譯了。
可以直接把 Principal 當成你應用的用戶。
每一個對你應用的請求都會有一個 HttpContext,當前的 Principal 就會變成你 HttpContext 的 User 屬性。當你的應用需要知道當前的用戶是誰,他可以做什么,的時候,可以這樣訪問:HttpContext.User.Claims
或者HttpContext.User.HasClaim(xxx)
,我也還沒試過,不確定是不是這個屬性,等學完理論去實戰的時候試試。
在 ASP.NET Core 中,Principals 的實現是 ClaimsPrincipals,它是一組跟它關聯的 claims 的集合。
截圖來自參考資料《ASP.NET Core IN ACTION SECOND EDITION》
就算我看不懂上圖里的字,我也能看懂這個圖。這顯然是說一個 ClaimsPrincipals 里包含了一組信息,包括用戶的 Email,家庭電話,FirstName,LastName,以及一個 HasAdminAccess 表示了該用戶擁有管理員權限。
這個圖一看我們就能明白,顯然 ClaimsPrincipals 里面放哪些項,是我們可以自定義的,畢竟上面連家庭電話這種項都出來了,現在誰家還用這玩意,還是不是90后啊?因此,我們可以定義一堆用戶信息和用戶權限存到數據庫里,與每個用戶在數據庫里的賬號對應起來,每次用戶登錄的時候就把這一堆東西從數據庫查出來,放進 ClaimsPrincipals 去。后面用戶的每一個請求都會把這個東西再以某種形式(cookie,header 等都行)帶回來,我們會在中間件管道中把這些東西添加到HttpContext.User
,可能是HttpContext.User.Claims
這個屬性,供我們的應用后面取用。
Claims
另外再提一嘴,上面圖里,把 ClaimsPrincipals 中的一項一項稱為 Claims,它包含一個類型(type)和一個值(value)。Claims 描述了這個 Principal 的屬性。所以我們可以把 Claims 理解成當前用戶的屬性。
上面的 Claims 里既有用戶信息如 Email,又有用戶的權限如 HasAdminAccess。由此我們確定權限和認證都跟這些 Claims 直接相關。
3. 認證和授權的抽象過程
看到這里,不管是用 ASP.NET Core 還是用什么別的語言別的框架,我們都能大體上知道怎么實現一個認證和授權的流程了。
- 用戶在客戶端輸入用戶名密碼登錄
- 服務端校驗用戶名和密碼,沒啥問題的話就從數據庫里取出用戶信息和權限,放到 HttpContext 里,隨時能取到。我們把這套東西抽象成 principal
- 這次請求結束時,我們把用戶的 principal 序列化,作為加密的 cookie 返回給瀏覽器。或者把這些信息加密放到一個 token 里,如 JWT 那種方式。然后返回給前端,前端想辦法存下來
- 前端再次發起請求時,就把這套信息繼續傳過來,我們解析,並放到 HttpContext 里隨時取用。
信息太長,或者擔心信息泄露怎么辦?我覺得可以直接存緩存里,比如 redis 里,隨便生成個 GUID 當 key,value 就是這套用戶信息和權限,當然要加密一下再存。把 key 返回給前端,前端以后每次請求都把這個 key 放 header 里帶過來,我們再從緩存里取出用戶信息和權限,這樣搞。緩存過期時間就是用戶這次登錄的失效時間。如果確定 key 被其它惡意用戶拿到,然后用它來冒充合法用戶,我們還能直接把他緩存清了,踢他下線。
Authentication 中間件和 Authorization 中間件在這個過程中發揮的作用
上面的 2、3、4 點應該是發生在 Authentication 中間件中,所以它后面的中間件能用到用戶信息。這就是負責授權的 Authorization 中間件要放在 Authentication 中間件后面的原因。放前面它壓根拿不到用戶信息和權限,拿什么去判斷用戶有沒有權限?
另外需要注意的是,Authentication 中間件不負責將未經身份驗證的請求重定向到登錄頁面,或者拒絕未經授權的請求。這兩個操作是由 Authorization 中間件處理的。
4. 如何處理未經授權的請求
未經授權的請求大概分為兩種:
-
未登錄的用戶發送的請求。這種請求沒有攜帶 Principal,說明用戶沒有登錄
-
用戶已經登錄,但沒有權限進行這項操作,或者調用這個 Web API。這種情況就值得討論一下了。可能要返回 401?或者其它的信息?
現在我們討論一下,可能有兩種響應(response):
- Challenge(質詢?考驗?挑戰?我也不知道怎么翻譯)—— 此響應表示用戶未被授權執行該操作,因為他們尚未登錄
- Forbid(禁止,阻止)—— 此響應表示用戶已登錄但不符合執行操作的要求。比如他們沒有執行這項操作需要的 claim
注意:在 ASP.NET Core 中,大家可能或多或少都知道可以用一個 Attribute 來給 Controller 或者 Action 加授權,這個 Attribute 就是[Authorize]
。如果我們只用[Authorize]
來修飾一個 Controller 或 Action,那么它只會校驗用戶是否登錄,只要登錄了的用戶,都可以執行操作。如何針對權限校驗?這個就更實戰一點了,本文主要是說理論和邏輯,這個后面會稍微提一嘴,剩下的等到實戰的文章里再說。
再看一下上面的兩種情況的處理方式。一般在 Web 應用中,觸發了 Challenge,用戶會被重定向到登錄頁面。而觸發了 Forbid,用戶可能要被重定向到應用定義的“禁止”或“訪問被拒絕”的頁面。
而在客戶端網頁應用,如用前后端分離的方法開發的基於 React 的前端 SPA,或者 Andriod、IOS 的移動端 APP,它們需要調用后端的 Web API 。這種情況下如果觸發了 Challenge 或者 Forbid,一般會被重定向到一個第三方的應用,這個應用能給 SPA 或者 APP 發 Token。例如你用某“小而美”的應用的賬號登錄某個網站,會跳轉回這個“小而美”的應用里,讓你點一下授權按鈕。但重定向到其他頁面不是由后端的 Web API 來做,Web API 可以對 Challenge 返回 401 Unauthorized,對 Forbid 返回 403 Forbidden。然后由客戶端來決定該怎么做。
什么是基於聲明(claims)的策略(policies)授權
認證結束后,我們開始考慮授權。我們現在可以想到兩種授權方式,一種是判斷用戶的 Principal 有沒有某個 Claim,另一種是判斷用戶的 Principal 中的某個 Claim 的值是不是某個特定的值。
在 ASP.NET Core 中,定義用戶是否獲得授權的規則被封裝在 policy 中。policy 定義了請求獲得授權必須滿足的要求。
而 policy 在 ASP.NET Core 中可以直接被加到一個 Controller 上或者一個 Action 上,還是用 [Authorize]
這個 Attribute。差不多就像下面這樣:
[Authorize("CanHelloWorld")]
public class HelloWorldController : ControllerBase
{...}
如何添加 policy
上面那個CanHelloWorld
的 policy 肯定不是憑空變出來的。他是在 Startup.cs 中的 ConfigureServices 方法中注冊的。
截圖來自參考資料《ASP.NET Core IN ACTION SECOND EDITION》
可以看到AddPolicy
第一個參數是 policy 的名字,第二個委托的RequireClaim
參數是這個 Policy 的規則規定了它需要哪些 claim。上面示例里它似乎只定義了一個 claim 的名字,claim 還支持鍵值對的定義方式。當然一個 policy 肯定能設置多個 claim。就是感覺用字符串比較不優雅,不知道有沒有更好的辦法。這個后面再看。
policyBuilder 支持的方法:
截圖來自參考資料《ASP.NET Core IN ACTION SECOND EDITION》
不知道全不全,書上就這些,感覺這些已經足夠定義一個簡單的權限系統了。
大體上翻譯一下,第一個方法表示只要登錄的用戶都滿足該 policy;第二個是給 policy 設置 claim,支持鍵值對的方式;第三個是需要 username 為方法參數里指定的 username 的用戶才滿足該 policy;第四個支持你用參數傳一個返回值為 bool 類型的委托進去,來定義更健壯的 policy,下面我們簡單講一下。
更復雜健壯的 policy
我們可以使用RequireAssertion
方法定義更健壯的 policy。
結合時事和參考書里的例子,我們現在需要定義一個規則為用戶年滿 18 周歲的 policy。我們就可以傳一個委托進RequireAssertion
方法,該委托判斷今天的日期減去用戶的 claim——DateOfBirth
的 value 大於等於 18,就 return true,否則 return false, 即可實現這個稍微復雜的 policy 的規則。
寫到這里我發現,就看看這些理論,基本沒什么用,還得實戰一波。等我有空實戰一波再搞一篇博客。
Requirements 和 Handlers
我們再引入兩個概念,Requirements 和 Handlers。
每一個 policy 都包含一個或多個 requirenments,每一個 requirements 可以有一個或多個 handlers。參考書里講了一個例子。
如果你在飛機上,你想去洗手間。我們給洗手間定義一個 policy :("CanAccessLounge")
,還有兩個 requirements : (MinimumAgeRequirement 和 AllowedInLoungeRequirement)
,還有幾個 handlers:
截圖來自參考資料《ASP.NET Core IN ACTION SECOND EDITION》
("CanAccessLounge")
(能夠進入洗手間)這個 policy 有兩個 requirements,其中AllowedInLoungeRequirement
(被允許進入洗手間)有三個 handlers,FrequentFlyerHandler
需要你是乘機頻率很高的乘客,類似於航空公司的熟客;IsAirlineEmployeeHandler
需要你是航空公司的員工;BannedFromLoungeHandler
表示你是否被禁止使用飛機上的洗手間。而MinimumAgeRequirement
(最小年齡限制)有一個 handler MinimumAgeRequirement
,表示洗手間有最小的年齡限制。
所以,CanAccessLounge
(能夠進入洗手間)必須AllowedInLoungeRequirement
(被允許進入洗手間),而且滿足MinimumAgeRequirement
(最小年齡限制)。
而AllowedInLoungeRequirement
(被允許進入洗手間),需要你滿足FrequentFlyerHandler
(高頻率乘客)或IsAirlineEmployeeHandler
(航空公司員工)。你不需要全部滿足這兩個 handlers,你只需要滿足其中一項。BannedFromLoungeHandler
(被禁止使用洗手間)這個我們先不管。
現在,我們可以用 OR 來組合每個 requirement 的 handler,即只要滿足任何一個 handler,就滿足這個 requirement。我們也可以用 AND 來組合 requirenment,必須滿足所有的 requirement 才滿足這個 policy。而在 ASP.NET Core 中,我們可以給 Controller 或者 Action 設置多個 policy,就像這樣: [Authorize ("Policy1"), Authorize("Policy2")]
,必須滿足所有 policy 才能被授權。
所以現在的邏輯就如下圖:
截圖來自參考資料《ASP.NET Core IN ACTION SECOND EDITION》
簡單放幾段書里的代碼,看一下怎么創建這些 requirements 和 handlers:
-
創建
AllowedInLoungeRequirement
,注意要實現IAuthorizationRequirement
接口。 -
創建
MinimumAgeRequirement
,這個 requirement 稍微特殊一點,它還帶有參數: -
注冊包含這兩個 requirement 的 policy:
-
創建 handler 來滿足 requirement,可以看到需要繼承
AuthorizationHandler<對應的 requirement>
,覆寫它的HandleRequirementAsync
方法:
上面兩個 handler 中調用context.User.HasClaim()
來取 claim 時是稍有不同的,注意區別。
注意:可以編寫可用於多個 requirement 的通用的 handler,但最好每個 handler 只處理單個 requirement。如果需要提取一些通用功能,應該從兩個 requirement 調用它。
- 上面都是判斷 claims 有沒有滿足需要的條件,如果滿足了就
context.Succeed(requirement);
,但在開發過程中可能會有判斷 claims 滿足了某個條件反而不能通過授權的情況,那樣就要用到context.Fail();
:
-
這些 handler 也是需要注冊的:
注意:上面這幾個 handler 沒有什么構造函數依賴項,所以注冊的生命周期全是 Singleton,如果我們的 handler 有什么生命周期為 scoped 或者 transient 的構造函數依賴項,比如依賴了 EFCore 的 DbContext,可能要考慮用 scoped 生命周期來注冊 handler
5. 總結
懶得總結了,就這樣吧。