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

7m魚發表於2021-04-08
  在上一篇文章中我們介紹了單頁應用是如何使用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可參考:https://blog.csdn.net/tang_yi_/article/details/79401280
  • 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.  這篇文章寫的時間跨度有點大,文章內容相對較多,並且有大量的檔案和程式碼修改,但文中程式碼均已圖片形式展現,本系列文章完結後會上傳相關程式碼檔案,如有問題可隨時聯絡作者。
 
參考:
 

相關文章