從零搭建一個IdentityServer——資源與訪問控制

  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資訊,具體參考文檔://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims):
  
  從圖中可以看到這個自定義資源設置了名稱、展示名稱及一個ClaimTypes列表,簡單來說就是這個用戶資源包含了用戶名、郵箱和狀態,當客戶端(RP)擁有這個資源的訪問許可權後,它就可以通過授權伺服器獲得用戶的相關資訊。更多IdentityResource定義參考文檔://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的完全訪問和只讀訪問兩個許可權:
  

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,它的定義可以參考文檔://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的一個拓展類庫,具體參考: //github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet/,而IdentityModel這個類庫是一個.net 基金會的開源項目,具體參考://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、電話號碼等相關資訊:
  更多細節可直接查看相關源碼:
  最後就是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(詳見://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規範(//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方法中添加基於聲明的身份驗證策略(參考://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資源定義參考://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功能將默認的授權變為用戶主動授權,這樣做更利於資源的控制和用戶隱私的保護。
 
參考: