從零搭建一個IdentityServer——會話管理與登出

  在上一篇文章中咱們介紹了單頁應用是如何使用IdentityServer完成身份驗證的,而且在講到靜默登陸以及會話監聽的時候都提到會話(Session)這一律念,會話指的是用戶與系統之間交互過程,反過來講就是用戶與系統之間交互的狀態就保存在會話(Session)中,對於HTTP協議來講,因爲它自己是無狀態的,因此爲了可以記錄用戶訪問系統的狀態,通常使用Cookie來存放會話信息。可是如今咱們須要保存的是與IdentityServer之間的會話,對於單頁應用來講它通常會存在跨域問題,那IdentityServer是如何處理跨域來完成會話管理的呢?同時IdentityServer4又提供了哪些與登陸登出相關的特性?本文就從會話管理開始來一一介紹。
  本文內容有:

會話管理

  首先會話自己有兩個主體,即服務器和客戶端,服務端就是identityServer自己,它是一個asp.net core應用程序,那麼實際上它的會話機制就和普通的asp.net core應用程序是一致的,經過cookie來保存相應會話的id或信息。
  下圖爲登陸IdentityServer後瀏覽器端存儲的會話信息和身份信息:
  而對於客戶端來講,咱們知道IdentityServer4其實是OpenIDConnect(OIDC)協議的一個實現,而OIDC協議自己是沒有會話管理這一特性的,它的出現其實是在一個補充協議中: https://openid.net/specs/openid-connect-session-1_0.html,該協議約定了客戶端如何對服務端的會話信息進行管理,而協議的主要內容是如下幾個點:
  • 協議定義:如何持續監控終端用戶在OpenID Provider(OP,Identity Server)上提供的會話信息,以便於終端用戶登出OpenID Provider(OP,IdentityServer)時可以同時登出客戶端(Relying Party)。
  關於OP(IdentityServer)和RP(client)見下圖:
  
  簡單來講就是上一篇文章演示的「會話監控」內容,當用戶直接從IdentityServer直接登出時,客戶端自己可以感知到並做出相應動做(客戶端登出)。
  • iframe:一個HTML的標籤,它表明一個內嵌的HTML文檔,若是在HTML使用iframe那就是文檔中包含另外一個文檔,iframe能夠經過src屬性來設置包含文檔的url地址。當iframe設置的url與主文檔的url不一樣域時,可使用iframe的postmessage方法實現跨域通訊。
  關於iframe及postmessage可參考: http://www.javashuo.com/article/p-zdezhxqr-ed.html
  • RP iframe:位於客戶端(Relying Party, RP)中的一個iframe,這個iframe的做用是用於向OP iframe發送及接收信息,發送的信息是用於告知OP iframe進行會話檢查,接收的信息是OP iframe完成會話檢查後的結果。
  下圖是oidc-client.js中用於建立RP iframe的代碼:
  
  下圖爲使用RP iframe向OP iframe發送信息的代碼:
  
  下圖爲接收到OP iframe會話驗證結果消息後的處理代碼:
  
  • OP iframe:一個由OpenID Provider(OP,IdentityServer)提供的,位於客戶端(Relying Party, RP)中的一個iframe,它的做用是與IdentityServer同域,保存於IdentityServer的會話信息,並提供檢查接口(基於postmessage)的iframe。
  當用戶身份驗證成功後,oidc-client會根據配置信息來訪問獲取OP iframe:
  
  OP iframe請求:
  
  下圖爲OP iframe中監聽RP iframe會話檢查消息,完成檢查並返回消息結果的代碼:
  
  會話檢查是對用戶數據中包含的會話狀態(session_state)信息進行覈對,會話狀態(session_state)信息分爲兩個部分,它們用「.」分隔,前部分是客戶端id、客戶端域名、會話id加鹽計算出來的哈希值,後部分是哈希計算使用的鹽(salt)。
  
  下圖爲會話檢查的具體邏輯,獲取當前的會話id並進行哈希計算後與用戶信息中的哈希值進行覈對,若是不一致那麼認爲會話發生變化。
  
  發生變化後oidc-client會自動發起受權請求來確認新會話的信息,這個也就是上一篇文章登出後發起的請求返回須要登陸的緣由:
  從以上內容看來oidc協議的會話管理主要是經過iframe完成的。
  下圖爲單頁應用完成登陸後發起靜默登陸時候的頁面信息:
  
  圖中存在兩個iframe,第一個是OP iframe包含了會話檢查相關內容,第二個是發起靜默登陸時,建立的一個指向受權終結點的iframe,經過跨域完成登陸,須要注意的是因爲RP iframe是經過js代碼建立的,因此沒法在頁面代碼中找到。
  到此爲止咱們瞭解到的僅僅是會話管理在單頁應用中實現的登陸與登出功能,經過會話管理它能夠將瀏覽器與客戶端(RP)及受權服務器(OP)之間的關係聯繫起來,簡單來講就是當瀏覽器與受權服務器(OP)會話中斷時客戶端(RP)程序可以知道(會話信息改變),同時若是瀏覽器與客戶端(RP)會話中斷時受權服務器(OP)也能知道(先清除客戶端身份信息,而後跳轉到受權服務器登出界面)。
其次還有一個特色就是因爲OIDC的會話管理協議是使用iframe來完成跨域會話檢查,雖然默認檢查頻率是2秒一次,可是它不須要向受權服務器發送任何請求便可完成檢查,因此能夠節省大量的網絡資源和服務器資源。
  但最後看來這個會話管理協議只適用於單頁應用來完成相關功能,可是對於web應用來講,使用單頁方式實現的僅僅是一部分,其它方式是如何處理客戶端(RP)與受權服務器(OP)之間的登陸聯繫的呢?

前端登出

  OIDC前端登出協議(OpenID Connect Front-Channel Logout),這個協議提供了一種登出的機制,該機制是經過瀏覽器的前端技術來與被登出的客戶端(RP)/服務器(OP)創建通訊,再也不須要iframe就能夠實現相關登出功能,具體協議內容參見: https://openid.net/specs/openid-connect-frontchannel-1_0.html
  接下來咱們就經過asp.net core應用程序來演示一下這個協議是如何完成前端登出的。

受權服務器(OP)登出聯動客戶端(RP)

  1. API項目中添加一個登出頁面
  API項目實際上就是咱們的客戶端(RP),當前的例子就是經過在該應用上添加一個登出頁面來完成受權服務器登出後通知客戶端登出的功能。
  注:asp.net core api項目其實是不包含頁面的,此處僅爲了方便經過api項目中添加Razor頁面來完成演示。
  首先添加一個Razor頁面的佈局:
  完成後得到相關的目錄結構和必要文件:
  
  添加一個登出頁面:
  
  後端代碼,代碼很是簡單,就是經過get方法訪問該頁面時就直接進行登出操做:
  
  最後在Startup文件中添加Razor Page的服務和路由:
  

   

  而後運行程序便可訪問到代碼了:
  
  2. 受權服務器中建立一個前端登出頁面,同時對Identity登出頁面改造:
  在本系列文章前面咱們經過IdentityServer4集成asp.net core identity實現了用戶的登陸登出功能,而且在使用中也暫時沒發現任何問題,能夠知足基礎的受權服務器的登陸和登出,可是若是要實現登出聯動,那麼就須要進行一些改造。
  主要改造有下面幾個步驟:
  1)添加一個前端登出頁面:
  
  2)對前端登出的Razor Page的後端Model中添加三個字段,而且用特性標明它們從Query中獲取:
  
  3)在前端登出的Razor Page的前端代碼中添加如下代碼:
  
  4)修改Identity登出頁面的後端Post請求處理方法:
  
  3. 修改客戶端數據,添加uri(客戶端新增的登出地址):
  
  4. 驗證登出聯動:
  首先經過IdentityServer完成身份驗證,並可訪問受保護資源:
  
  而後開啓新的選項卡訪問IdentityServer的登出頁面,此時由於客戶端程序是經過客戶端完成了受權服務器的身份驗證,在瀏覽器會話信息保存期間,它默認是登陸狀態:
  最後咱們點擊登出連接,程序將攜帶相關參數跳轉到咱們添加的前端登出頁面:
  如今咱們再去刷新受保護資源時獲得如下結果,它跳轉到受權服務器的登陸頁面了,這意味着咱們在受權服務器(OP)登出的時候,客戶端(RP)同時也完成了登出:

原理簡析

  它們是如何完成聯動登出的呢?咱們首先來分析一下相關主體有哪些:
  • 客戶端(RP)登出頁面:訪問該頁面便可完成客戶端(RP)方面的登出,這個頁面用於受權服務器登出聯動時訪問。
  • 受權服務器(OP)登出頁面:一個基於Asp.net core Identity的登出頁面,用於asp.net core應用程序(這裏特指受權服務器)的登出。
  • 受權服務器(OP)前端登出頁面:一個用於完成OIDC前端登出協議的登出頁面,負責客戶端登出頁面的調用及客戶端應用程序跳轉(該頁面功能有點相似於,咱們在購買火車票付款時,首先跳轉到支付頁面,完成支付後通知系統已支付,而且又跳轉回訂單頁面的過程)。
   其次在整個過程當中咱們還使用了兩個比較重要的組件:
  • IdentityServer4的交互服務(Interaction Service):這個實際上就是identityServer4提供的一組接口,這些接口約定了用戶與IdentityServer4的交互方法,該接口能夠經過依賴注入的方式進行使用。在本例中使用Interaction Service的目的是獲取當前登陸用戶的登出上下文,以便完成後續登出工做(相關信息存儲於Cookie中,相似基於Cookie身份驗證的身份信息載體)。關於接口內容詳見文檔:https://identityserver4.readthedocs.io/en/latest/reference/interactionservice.html
  • 結束會話終結點(End Session Endpoint):就是字面意思,結束會話使用的終結點,在這裏的做用是經過結束會話終結點來終結會話並跳轉到客戶端(RP)的登出頁面完成客戶端(RP)登出。
  它的整個登出流程以下圖所示:
  
  簡單來講就是當用戶訪問受權服務器登出頁面並進行登出操做後,它進行受權服務應用登出後,跳轉到前端登陸頁面,經過登出上下文信息渲染了一個iframe元素,經過iframe完成結束會話終結點的訪問和客戶端登出頁面的訪問,最終呈現給用戶的就是前端登出頁面。
  下圖爲登出操做後的網絡請求詳情:
  整個程序由登出頁面攜帶參數重定向到請求1(前端登陸頁面),而後經過前端登陸頁面的iframe發起請求2(結束會話終結點請求),最後再由結束會話終結點請求中的iframe完成客戶端登出請求3。
下圖爲前端登陸頁面在執行完成以上內容後的結果,從結果中咱們能夠看到兩個iframe分別對應告終束會話終結點請求和客戶端登出頁面請求:
  
  總的來講就是三個要點:
  1. 清除受權服務器的身份信息。
  2. 結束IdentityServer4的會話狀態。
  3. 清除客戶端的身份信息。

客戶端(RP)登出聯動受權服務器(OP)

  以上面所提到的三個要點來看如何實現客戶端(RP)與受權服務器(OP)的登出聯動。
  首先咱們在客戶端添(RP)加一個登出頁面:
  
  在頁面後臺代碼中添加如下內容(主要是獲取id token而後拼接受權服務器的結束會話終結點地址,另外就是退出登陸):
  
  如下是頁面前端代碼,主要是經過iframe去訪問結束會話終結點(注:使用iframe的目的是由於訪問受權服務器時可以攜帶相關Cookie,以便進行身份驗證及登出操做):
  
  最後修改一下受權服務器(OP)的登出頁面後臺代碼,當接收到攜帶logoutId的Get請求時,對用戶進行登出操做(注:最後一句對User賦值的代碼,是由於雖然應用程序執行了登出,可是User.Identity.IsAuthenticated仍然爲true,這裏有找到一些資料能夠進行參考: https://stackoverflow.com/questions/10663873/user-identity-isauthenticated-true-after-logout-asp-net-mvc
  
  接下來就開始驗證咱們的聯動登出,首先確保受保護資源可訪問:
  
  而後訪問客戶端的登出頁面( https://localhost:51001/logoutwithop):
  訪問登出頁面時,會觸發受權服務器的登出頁面代碼,從代碼中咱們能夠看到相應的logoutId以及經過IdentityServer4交互服務得到的登出上下文:
  
  經過斷點後,咱們能夠看到整個請求過程(請忽略相關404連接,是由於沒有添加靜態文件處理中間件致使的文件沒法獲取):
  iframe裏面的內容,能夠看到受權服務器已經成功登出:
  
  刷新受保護資源會跳轉到受權服務器進行身份驗證,這證實了客戶端自己已經完成登出:
  
  以上內容就是客戶端(RP)聯動受權服務器(OP)的登出功能,總的來講仍是三個要點:
  1. 清除客戶端的身份信息。
  2. 結束IdentityServer4的會話狀態。
  3. 清除受權服務器的身份信息。
  注:IdentityServer4中實際有兩個會話結束終結點,分別是EndSessionCallbackEndPoint和EndSessionEndPoint,前者用於OP聯動RP的登出,主要功能是渲染一個FrontChannelLogoutUrl的iframe來訪問客戶端的前端登出頁面,後者是用於RP聯動OP時發起的結束會話請求,這個請求identityServer會保存一個登出信息,這個操做是EndSessionCallbackEndPoint不具有的,換句話說若是在OP聯動RP的場景下,客戶端(RP)的登出頁面(本例僅調用的HttpContext的Signout方法登出)還應該調用EndSessionEndPoint來給受權服務器保存登出信息。本文爲了簡化內容複雜性把兩個終結點都稱爲告終束會話終結點。

後端登出

  前面提到的不管是會話管理仍是前端登出,它都有一個共同點就是基於瀏覽器,由於瀏覽器能夠經過Cookie或者H5的存儲功能來保存會話/狀態信息,登出實際上就是把相應的信息刪除,這種狀況下不論是客戶端(RP)仍是受權服務器(OP)它們自己都只是去驗證身份信息的有效性,若是身份信息存在且有效那麼身份驗證經過,可是實際應用中可能會出現這麼一種狀況,假設身份信息過時時間足夠長,那麼只要用戶不主動登出,那麼身份信息將永久保存、永久有效,服務端沒有「任何」一種方法可以主動讓其失效,這是存在問題的,針對這種問題OIDC提出了後端登出這一律念。
  後端登出是什麼呢?它其實是一種受權服務器(OP)與客戶端(RP)之間直接通訊的登出機制,簡單說來就是當經過受權服務器(OP)登出時能夠直接通知到客戶端(RP),不須要瀏覽器的支持,說個具體場景就相似於微信能夠同時在PC以及移動設備上登陸,可是移動設備上能夠直接控制PC登出,或者是當用戶修改密碼後,密碼修改前全部的會話都應被終止。
  後端登出雖然再也不基於瀏覽器的會話信息,可是它畢竟須要明確知道相關登出的會話信息,因此它自己比前端登出要複雜,須要受權服務器(OP)以及客戶端(RP)都支持會話管理。對於受權服務器來講能夠經過訪問 https://localhost:5001/.well-known/openid-configuration來肯定是否支持後端登出:
  
  而客戶端(RP)自己就得本身實現了,在實現客戶端的會話管理以前,還有一個概念須要瞭解一下,那就是登出令牌(Logout Token),它包含兩個比較重要的信息,其一是用戶id(sub),其二是會話id(sid)具體參考文檔: https://openid.net/specs/openid-connect-backchannel-1_0.html#LogoutToken
擁有這兩個信息,或者只有對這兩個信息進行管理,那麼在登出時咱們才能知道究竟是哪個用戶的哪一次會話被結束了,那麼LogoutToken是怎麼來的呢?
  首先咱們在客戶端(RP)添加一個用於接收後端請求的控制器(注:須要Post方法):
  
  而後將這個控制器的地址配置到IdentityServer的Client數據庫中:
  
  運行程序並執行上面介紹過的前端登出(OP聯動RP登出流程),就會觸發後端登出,在相應代碼設置的斷點會被觸發:
  
  在這個請求中咱們發現Form表單中包含了logout_token:
  
  根據格式看來logout_token是一個jwt,以jwt方式解析該token得到結果以下:
  其中包含了用戶id(sub)及這次會話id(sid),在此實驗基礎上,咱們來實現一個簡單的客戶端會話管理。
  添加一個登出會話管理類型,該類型維護一個登出會話列表,它的功能是當接收到後端登出請求時將相應登出信息存儲到列表中,用戶在身份驗證後來判斷用戶及當前會話是否存在於列表,若是存在列表中,那麼證實該用戶的當前會話已經被後端登出,應該被禁止:
  
  修改後端登出控制器代碼(此代碼僅用於測試,並未對任何異常狀況進行處理,另外也未對token進行完整性驗證等,若是須要了解token驗證相關內容,可參考: https://github.com/IdentityServer/IdentityServer4/tree/main/samples/Clients/src/MvcHybridBackChannel):
  
  添加一個Cookie身份驗證事件處理器,當用戶經過身份驗證時去判斷sub及sid是否已經被登出:
  
  應用該事件處理器,先添加到容器,而後配置到Cookie身份驗證中:
  
  爲了保證可以驗證後端登出有效性,咱們把前端登出代碼註釋後,運行程序(仍是按照前端登出OP聯動RP流程,但前端登出代碼已經被註釋而失效了,因此若是登出成功,那就是後端登出的效果):
  
  當程序完成前端登出跳轉後,會自動觸發並進入登出流程:
  
  相應的用戶及會話已經被登出,因此須要拒絕並登出用戶:
  
  再次刷新受保護資源,程序將跳轉到受權服務器登陸頁面,換句話說就是後端登出成功。
  
  以上就是後端登出內容(OP聯動RP進行後端登出),爲何沒有RP聯動OP的後端登出?由於在非瀏覽器環境下客戶端通常不會保存與受權服務器的身份驗證信息(哪怕保存了,那麼本身刪除便可),因此天然就不存在RP登出須要聯動OP的場景。
  另外要注意的是後端登出本來是在非瀏覽器環境下使用的,但上面的例子仍然是經過基於瀏覽器的前端登出來完成的,其目的僅僅是爲了方便演示,其次後端登出請求是由結束會話回調終結點(EndSessionCallback EndPoint)發起的(只要客戶端信息存在BackChannelLogoutUri信息就會自動發起),那麼若是想主動發起該請求咱們須要藉助IBackChannelLogoutService來完成,該服務的SendLogoutNotificationsAsync方法能夠經過用戶id、會話id以及客戶端id來發起相應客戶端的後端登出請求:
  
  關於如何獲取會話信息來經過該服務發起登出會在後續文章中介紹。

小結

  本文主要介紹了IdentityServer4的會話管理以及先後端登出功能。其中會話管理和前端登出都是基於瀏覽器,經過瀏覽器自己的Cookie及存儲功能來保存相關身份、會話數據,同時藉助Iframe來實現跨域請求、跨域會話檢查等等功能。
  對於前端登出來講它主要有受權服務器(OP)與客戶端(RP)互相聯動兩種場景,不管用戶從哪一方進行登出操做都可以將兩方的身份信息刪除。
  對於後端登出來講它要求受權服務器(OP)與客戶端(RP)雙方都具有後端登出功能,IdentityServer4自己支持,而客戶端就須要本身實現了,本文中實現了一個簡單的登出會話管理功能,即當用戶觸發後端登出後,客戶端會記錄登出信息,當用戶再次發起請求時,在身份驗證(驗證Cookie,此時Cookie仍然有效)後,來判斷該用戶是否已經後端登出,若是已經登出則主動拒絕訪問。
 
PS.  這篇文章寫的時間跨度有點大,文章內容相對較多,而且有大量的文件和代碼修改,但文中代碼均已圖片形式展示,本系列文章完結後會上傳相關代碼文件,若有問題可隨時聯繫做者。
 
參考:
 
相關文章
相關標籤/搜索