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

7m魚發表於2021-07-20
  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的完全訪問和只讀訪問兩個許可權:
  

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、電話號碼等相關資訊:
  更多細節可直接檢視相關原始碼:
  最後就是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功能將預設的授權變為使用者主動授權,這樣做更利於資源的控制和使用者隱私的保護。
 
參考:
 
 

相關文章