IdentityServer作為授權服務器它的最終目的是用於對資源進行管控,這里所說的資源有兩種,其一是API資源,實際上也就是OIDC協議中客戶端(RP)所需要訪問的一系列受保護的資源(API),授權服務器通過對終端用戶完成身份驗證后發放相應Token,然后可以使用Token來完成受保護資源的訪問。
另外就是對用戶資源進行管控,簡單來說就是授權服務器存儲了用戶相關信息,客戶端應用無需也無權來管理,如有需要可以通過授權服務器獲取,這樣的好處就是將用戶信息統一管理,可以保證用戶數據一致性、安全性也可以減少客戶端程序的開發量。
隨着軟件或者信息化的不斷發展,現在一個常見的軟件使用場景就是,很多軟件都可以支持第三方賬號登錄,登陸時首先會有一個授權登錄XXX應用的提示,當用戶同意且登錄成功后軟件可以獲取到第三方賬號的相關信息,如頭像、昵稱等,甚至還可以申請並獲取賬號的手機號碼等隱私信息,最常見的例子就是微信公眾號/小程序。
本文的主題就是如何通過IdentityServer4來對資源進行管控,最后實現訪問第三方應用程序(客戶端,RP)時授權提示及用戶信息申請的過程。
本文內容有:
Resource定義
借用IdentityServer4官方文檔的一句話“OpenID Connect或OAuth Token服務的最終目的就是控制資源的訪問”,而這里的資源類別有兩種,其一就是API資源,可以把它看成一系列受保護的可遠程調用的內容,甚至可以直接狹義的理解為基於Http協議的Web API。另外就是用戶信息資源,如用戶昵稱、頭像、手機號碼等等。
在IdentityServer4中,使用IdentityResource來定義一個用戶資源,一個用戶資源除了有名稱、展示名稱等屬性外還包含一系列的屬性,將這一系列的用戶屬性統稱為ClaimType,舉個例子官網文檔自定義profile資源的例子(注:默認的profile資源包含了name, family_name, given_name, middle_name, nickname等ClaimType信息,具體參考文檔:
https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims):


從圖中可以看到這個自定義資源設置了名稱、展示名稱及一個ClaimTypes列表,簡單來說就是這個用戶資源包含了用戶名、郵箱和狀態,當客戶端(RP)擁有這個資源的訪問權限后,它就可以通過授權服務器獲得用戶的相關信息。更多IdentityResource定義參考文檔:
https://identityserver4.readthedocs.io/en/release/reference/identity_resource.html
在IdentityServer4中,使用ApiResource來定義一個API資源,它的基礎結構與用戶資源類似,也是包含名稱、展示名稱,只是不同的是它擁有一個scope列表,一個scope可以按照字面意思理解,就是這個資源的范圍,這個范圍由人來定義,可大可小,並且scope可以獨立於資源單獨存在,一個應用程序可以只有一個scope,換句話說就是當用戶擁有這個scope的權限,那么就可以訪問這個應用程序的所有內容,也可以細粒度的一個Api就對應一個Api資源,一個Api資源中包含多個scope,如將這個api的每一個子功能或權限都定義為一個scope。
下圖為一個ApiResource定義的基本結構,它是針對Api級別定義的,這個資源下面有兩個scope分別對應這個api的完全訪問和只讀訪問兩個權限:


更多ApiResource定義參考文檔:
https://identityserver4.readthedocs.io/en/release/reference/api_resource.html
Client定義
Client就是代表之前文章中提到的客戶端(RP)應用程序,那么定義Client實際上就是應用程序的一些特性及應用程序的功能。
下圖為一個Client的定義信息,它包含了Client的Id、名稱、授權方式等,但本文主要關注資源控制,所以主要關注的是Client的AllowedScopes屬性,它包含了所允許訪問的用戶資源和Api資源信息,下圖Client的Scope定義中我們可以看出,該應用程序可以訪問用戶的id(OpenId)、用戶基本信息(Profile)及郵箱,同時定義了該應用程序有api1、api2.read_only兩個api資源:


Identity Resource與Asp.net Core Identity
前面了解了Identity Resource包含了用戶的基本信息,而在我們常用的asp.net core應用程序中,用戶信息都通過Asp.net core Identity進行管理,包括本系列文章也是通過Identity來完成用戶信息管理的,但是一般情況下Asp.net core Identity通過UserManager等類型來完成用戶信息管理(主要是指獲取),而現在情況比較特殊IdentityServer4的UserInfo EndPoint是用來獲取用戶信息的,關鍵問題是用戶信息存儲仍然通過Asp.net core Identity實現,從而引出一個它們之間如何互相關聯工作的問題。
關於Identity Resource與Identity組件的關聯主要有以下兩方面內容:
- Profile Service
- ClaimTypes
- IdentityServer4與Asp.net Core Identity的集成
Profile Service
Profile Service是IdentityServer4中用於提供用戶信息的服務,在IdentityServer4核心類庫中它定義了一個IProfileService的接口,這個接口定義了兩個無返回值的方法,分別用於獲取用戶信息和判斷賬戶是否可用,接口定義如下圖所示。


這里要注意的是因為沒有返回值,所以實際上兩個方法所需返回的數據都是通過填充傳入參數來實現數據傳遞,其中用戶數據請求上下文(ProfileDataRequestContext)通過其它相關參數,如用戶id(Subject Id)、請求的claimTypes(RequestedClaimTypes,這個參數的意義在於這個服務不是每次都將用戶的所有信息都進行返回,而是只返回需要的,如通過UserInfo EndPoint來獲取用戶信息時,這個參數就會攜帶email、profile等claimTypes,而生成Access Token時還會攜帶如api1、api2.read_only之類的api scope),來獲取用戶信息,最終將用戶信息填充到IssuedClaims這個列表中:


簡單來說IdentityServer4用戶信息獲取就依賴於這個接口,想要獲取特定存儲的用戶信息,那么根據情況實現該接口即可,那么我們可以猜測IdentityServer4與Asp.net core Identity的集成實際上是實現了一個基於Asp.net core Identity的ProfileService。
ClaimType
了解了數據的獲取問題之后,還有一個問題就是數據之間的映射,假設現在有兩個系統,系統A和系統B,系統A中存在一個名為身份證號碼的數據,系統B中存在一個Id Card No.的數據,人們可以很容易知道兩個數據雖然名稱不一樣,但是內容是一樣的,但是計算機不行,我們需要在它們之間建立一個映射關系,建立映射關系之前首先得了解它們對數據的命名規則。
無論是asp.net core identity還是OIDC的用戶數據,實際上都是用Claim來表示用戶信息的,這是它們之間的一個共同點,即數據結構一致,簡單來說只要名稱能對上那么就能互相交換數據了,這里需要引出兩個Claim的定義,其一是.Net的ClaimTypes,它位於System.Security.Claims命名空間下,定義了一個用戶常用的claim type,具體信息如下圖所示:


另外一個是Jwt的ClaimTypes,它的定義可以參考文檔:
https://www.iana.org/assignments/jwt/jwt.xhtml,在.Net中可以使用IdentityModel類庫來直接使用相關定義,具體內容如下圖所示:


在上面兩張圖片中分別用紅框標明了ClaimTypes的NameIdentifier、Name和JwtClaimTypes的Subject、Name,兩個值分別對應了用戶的Id和用戶名,可以看出它們的claim名稱(及相同名稱的值)並不一致。
如果想要實現數據互通,那么只需要將相同意義的Claim進行對應即可。
我們知道OIDC或者說Oauth2.0中涉及的Token基本使用jwt來作為規范,但是從上面System.Security.Claims命名空間下對ClaimType的定義中可以看到它和jwt的Claim定義有很大的區別,那么.Net體系中有沒有針對jwt的實現呢?(注意這里指的是.Net體系中而非基於.Net或者C#代碼的實現)答案是肯定的,因為在.Net體系(甚至可以說微軟體系)中也提供OIDC服務,它同時兼顧了jwt規范以及System.Security.Claims命名空間下對ClaimTypes定義。
下圖為System.IdentityModel.Tokens.Jwt中定義的Jwt中的Claim名稱:


同時該程序集中定義了JwtRegisteredClaimNames與ClaimTypes的映射關系,從圖中可以看出Jwt中的sub和nameid都將與ClaimTypes的NameIdentifier對應:


注:System.IdentiyModel.Token.Jwt是AzureAD(微軟的身份驗證雲服務)對.net core的一個拓展類庫,具體參考: https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet/,而IdentityModel這個類庫是一個.net 基金會的開源項目,具體參考:https://github.com/IdentityModel/IdentityModel。
另外需要注意的是在我們后續的內容中或者說identityServer4與Asp.net core Identity的集成中會用到以上兩個類庫,即會存在三個Claim名稱的相互映射關系。
IdentityServer4與Asp.net Core Identity的集成
經過前面內容的介紹,如果要實現IdentityServer4與Asp.net Core Identity的集成那么只需要實現基於Asp.net core Identity的Profile Service同時完成相關Claim名稱映射即可。
關於前者在IdentityServer4.AspnetIdentity中提供了相應的實現,它依賴Identity的UserManager和一個ClaimsFactory,具體如下圖所示:


其中該類型通過UserManager來獲取用戶信息:


而ClamsFactory它更是於UserManager息息相關,它通過UserManager來獲取用戶、Email、電話號碼等相關信息:


更多細節可直接查看相關源碼:
https://github.com/IdentityServer/IdentityServer4/blob/main/src/AspNetIdentity/src/ProfileService.cs
最后就是Claim的映射問題,在介紹它們的Claim映射之前,我們先通過一個圖來介紹一些相關關系:


上圖中包含兩個主體:基於is4的授權應用和基於OIDC的客戶端應用(紅色框),分別用於發布Token和驗證Token並獲取用戶信息,它們都是Asp.net Core應用程序,分別通過依賴IdentityServer4和Microsoft.AspNetCore.Authentication.OpenIdConnect來實現相應功能。
三個Claim定義(文章前面提到過):IdentityServer4的Token和用戶信息都是基於JwtClaimTypes來生成的,實際上應該說IdentityServer4實現了Jwt、Oauth2.0、OIDC協議。
而Asp.net core應用程序默認使用System.Security.Claims.ClaimTypes。它的定義沒有jwt那么簡潔,比如Jwt中的sub一般代表用戶的Id,而ClaimTypes中使用NameIdentifier表示(一串很長的uri)。
JwtRegisteredClaimNames是微軟身份雲服務的一個實現,它與JwtClaimTypes存在一些差異,同時它為了能夠與Asp.net Core應用集成,自己包含了一個與ClaimTypes的映射關系。
最后還有兩個最重要的產物ID Token、UserInfoEndpoint返回的用戶信息以及.Net Core應用中的User信息,這也是IdentityServer4與Asp.net Core Identity的集成的關鍵,換句話說只要將ID Token及UserInfo“翻譯”為.Net Core應用的User實例就認為它們集成成功了(用戶信息的獲取或者說ID Token及UserInfo生成時用戶數據的來源不一定是asp.net identity,所以它不是集成的關鍵)。
下面來做一個簡單的實驗,首先通過授權碼流程對應用程序進行身份驗證,並獲得相應ID Token以及UserInfo(詳見:
https://www.cnblogs.com/selimsong/p/14355150.html#oidc_code_flow,另外需要注意的是本實驗將客戶端程序oidc身份驗證的GetClaimsFromUserInfoEndpoint配置設為true,這樣才能拿到用戶的name信息):
User信息如下圖所示:


從圖中可以看到Claims列表中包含了用戶名信息(name),但是User中的Name屬性卻為null,實際上從圖中就能看出原因,是因為Claims列表中的用戶名屬性Claim名稱為“name”,而User所需要的是“System.Security.Claims.ClaimTypes.Name”,所以無法正確匹配。這里需要注意的就是ID Token中包含的sub信息卻能正確的被“System.Security.Claims.ClaimTypes.NameIdentifier”匹配。
ID Token中的sub信息:


首先需要明確的一點是IdentityServer4生成ID Token或者UserInforEndPoint獲取的用戶信息均基於jwt規范(
https://www.iana.org/assignments/jwt/jwt.xhtml),而.Net Core中oidc身份驗證組件是基於System.IdentityModel.Tokens.Jwt.ClaimTypeMapping來進行匹配的,從下圖中可以看到sub匹配了NameIdentifier,所以用戶Id能夠被轉換,但是該映射類型中沒有定義用戶名(name)的映射信息,所以導致用戶名無法被正確匹配:


為了能夠正確映射,我們只需要再客戶端程序將oidc Token驗證選項中NameClaimType屬性變更為JwtClaimTypes.Name(name)即可:


再次獲取的用戶信息,數據已經成功匹配上了:


Asp.net core基於Scope的訪問授權
上面內容通過Identity Resources用戶身份信息來引出了Claim的概念,通過Claim來對用戶信息屬性進行映射和管理,對於API Resources來說也是一樣的,仍然是通過Claim來對API資源進行聲明,下面就來演示一下如何通過Claim定義API Resource以及如何使用這些被定義的Claim保護真實的API資源。
首先我們假設有一系列用戶管理功能API資源,包含了用戶信息查看和修改。那么根據API資源的定義,我們將該用戶管理功能定義為一個API資源,同時將用戶信息查看和修改以Claim的方式體現:


資源中Scope的定義:


然后新建一個API項目,在API項目中定義用戶管理的兩個API:


然后在Startup類型的ConfigureServices方法中添加基於聲明的身份驗證策略(參考:
https://docs.microsoft.com/en-us/aspnet/core/security/authorization/claims?view=aspnetcore-5.0):


並把身份驗證策略添加到API的授權特性上:


最后我們將相應的Scope配置到Client信息上,並且Client在發起授權請求時添加相應的Claim信息:


Client的OIDC身份驗證配置添加需要請求的scope,這里需要注意的是代碼中僅添加了user_read這個scope,雖然當前client信息包含user_read和user_edit兩個scope,但是如果不進行主動請求,那么最終獲得的結果中不會包含user_edit聲明信息:


最后在client中添加測試代碼:


嘗試運行通過client來調用被保護的API,獲得以下結果:


為什么修改用戶信息授權被拒絕呢?對access token進行解析,可以看到token中的scope信息僅包含user_read,沒有包含user_edit這是因為在授權請求中沒有請求user_edit的原因:


IdentityServer4啟用Consent
同意(Consent),是最終用戶授予客戶端程序訪問資源權限的應允。舉個簡單的例子來說手機號碼是最終用戶的隱私信息,一般應用程序沒有權限直接獲取,如果需要獲取那么需要征得用戶同意,用戶同意這個過程就是Consent。
本文中上面的內容都是由Client本身來獲取相關資源訪問權限(包括用戶資源和API資源),並沒有用戶的參與,或者說用戶的允許,Consent就是引入用戶來對Client能夠獲取的權限進行授權的功能。
IdentityServer4的Consent是在進行授權請求之前向用戶征求允許的權限,下面就基於IdentityServer4實現一個簡單的Consent功能,實現Consent功能主要有以下幾個步驟:(注:identityServer4模板中有默認的基於MVC的Consent實現,以下內容可以看作一個簡版的Razor Page的實現,主要是僅給出了關鍵代碼,並沒有處理代碼中可能出現的異常,僅作為演示使用)
1. 修改Client信息讓相應Client支持Consent。
2. 為IdentityServer應用添加Consent頁面,頁面主要功能是將當前Client支持的資源列出給用戶選擇並將選擇結果傳遞給后續的授權請求。
3. 對IdentityServer4進行配置,將Consent連接指向我們添加的頁面。
1. 通過修改ClientRequireConsent設為true:


2. 添加Consent頁面:


2.1 獲取當前授權請求上下文,通過上下文獲取當前請求Client所擁有的資源並展示:


這段代碼主要目的是在授權請求過程中(由於設置了需要授權Require Consent)跳轉到同意(Consent)頁面,並展現出當前Client所有可選的Scope(包括IdentityScopes和ApiScopes)供用戶進行選擇並同意當前Client訪問。
2.2 添加頁面用於展示並選擇提交用戶同意的權限或拒絕授權:
首先定義一個用於存放用戶提交內容的模型:


根據模型編寫頁面展示/提交代碼(APIScopes部分展示代碼與IdentityScopes部分類似):


處理提交內容,如果點擊no按鈕直接拒絕授權,如果點擊yes則完成授權,並跳轉完成后續授權請求工作:


3.配置IdentityServer4的Consent頁面路徑:


4. 運行程序進行測試(使用上一章的UserManage功能進行測試):
首先訪問受保護資源UserManage時先跳轉到登錄頁面,完成登錄后就可以看到剛剛創建的Consent頁面:


點擊同意按鈕后得到以下結果,注意修改用戶的狀態碼是200:


如果取消修改用戶信息權限:


那么就會看到修改用戶信息被403拒絕的信息:


5. 添加一個電話號碼的身份資源,並賦予到相應的Client后:
首先定義資源(phone資源定義參考:
https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims):

資源下包含“phone_number”Claim:

將phone這個資源作為Client允許的Scope:

為用戶數據添加電話號碼信息:

運行程序后可以看到Consent頁面已經有“電話號碼”這個用戶信息資源授權:


但是點擊同意后Client中的UserClaims中並沒有電話號碼相關的信息:

是因為數據沒生效嗎?我們知道這里的用戶信息來自於UserInfoEndpoint,它是通過攜帶access token來完成用戶信息請求的,那么首先我們來看看生成的access token包含哪些信息?

已經看見它有權訪問phone這個scope信息了,但是為什么沒有相應數據呢?我們通過這個access token嘗試訪問一次UserEndPoint看看:

能夠看到已經有phone_number這個數據了,所以最終的問題出在UserInfoEndpoint數據與Asp.net Core User對象數據映射的時候,僅需要添加以下配置即可將phone_number映射到User中:

重新登錄后得到以下結果:

注意,由於asp.net core應用程序有一些默認的claim映射和過濾,會導致與真實返回的Token結果不一致,可以通過下面代碼禁用這些映射關系:

禁用這些關系后再次登錄,可以看到claim信息與之前有很大的差異,現在的claim基本與jwt協議的claim定義一致了:

小結
本文介紹了IdentityServer或者說OIDC協議中對資源的定義與訪問控制,對比了基於jwt的Claim定義與.Net體系中Claim定義的區別,了解到OIDC協議或者IdentityServer4與Asp.net core應用集成時關鍵在於Claim的映射。
同時文章最后通過IdentityServer4的Consent功能實現了用戶對Client所需權限的授權。Consent功能將默認的授權變為用戶主動授權,這樣做更利於資源的控制和用戶隱私的保護。
參考: