在上一篇文章中我們介紹了單頁應用是如何使用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仍然有效)后,來判斷該用戶是否已經后端登出,如果已經登出則主動拒絕訪問。
關於會話Id(補充)
關於IdentityServer的會話管理,在文章前面我們就說過只要登錄到授權服務器之后就會有一個名為“idsrv.session”的cookie,它代表用戶與授權服務器此次會話的id,這里有兩個問題,第一就是為什么它的名稱是“idsrv.session”,是因為IdentityServer4中定義了一個默認的常量,如下圖所示:

如果想要修改可以在IdentityServer的服務配置中,通過Authentication.CheckSessionCookieName來修改。

第二個問題,這個cookie是如何出現的呢?為什么登錄了就有?登出了就被刪除?是因為IdentityServer4實現了或者說包裝了asp.net core自有的身份驗證服務,實現了自己登錄、登出邏輯,舉個登錄例子,它先創建了會話Id然后又調用原有的登錄邏輯:

創建的會話Id是通過IdentityModel里面的CryptoRandom類型生成的一個16位的唯一id,然后將這個id寫到cookie中:

那么這個會話id(sid)出現在什么地方?又有什么作用呢?從以前的文章中,我們可以看到Id_token、Access_token以及基於oidc身份驗證的用戶信息、js單頁應用的客戶端都能看見會話id:
Id_token:

Access_token:

asp.net core 應用程序用戶信息:

單頁應用中的用戶數據:

雖然會話id存在的地方很多,但是它實際都是由首次登錄的時候生成的,它們的使用過程如下:
登錄(生成會話id)→頒發ID Token/Access Token(包含會話id)→解析/驗證Token(獲得會話id)。解析驗證Token主要是對Id_Token進行驗證解析,從id_token中獲取相關信息,這也是為什么asp.net core應用及單頁應用中的用戶數據都包含會話id的原因。
會話Id的作用也就是字面的意思,標記了相關內容是某次會話產生的。
目前發現會話Id的應用有以下幾方面:
1. 單頁應用的會話檢查:單頁應用的會話檢查主要是對session_state(由授權請求生成的包含sid加鹽哈希值和鹽的字符串)進行驗證,如果會話id發生變化(在同一會話下多次進行授權請求會話id不會變,但是鹽會發生變化,所以導致session_state也不一致,但由於會話檢查時是獲取實時的鹽,所以鹽的變化不會觸發會話變化事件),那么就會觸發會話變化事件。
2. 后端登出時也使用了會話id進行標記,以確保授權服務器知道要登出哪一次會話,而客戶端被知道哪一個會話已經被登出。
除此之外我們還可以在數據庫的persistedgrants(授權持久化)表中看到會話id的身影,標明了某一次Token的頒發情況:

更多關於會話id的用法在后續內容中會持續關注。
PS. 這篇文章寫的時間跨度有點大,文章內容相對較多,並且有大量的文件和代碼修改,但文中代碼均已圖片形式展現,本系列文章完結后會上傳相關代碼文件,如有問題可隨時聯系作者。
參考:
https://github.com/IdentityServer/IdentityServer4/tree/main/samples/Clients/src/MvcHybridBackChannel