CAS Client集羣環境的Session問題及解決方案

之前寫過一篇文章,介紹單點登錄的基本原理。這篇文章重點介紹開源單點登錄系統CAS的登錄和註銷的實現方法。並結合實際工作中碰到的問題,探討在集羣環境中應用單點登錄可能會面臨的問題。這篇文章在上一篇的基礎上,增加了第四部分,最終的解決方案。

1 單點登錄的過程

爲了描述方便,假設有如下一個單點登錄系統。一套CASServer,兩套CAS Client系統。爲了描述的方便,省略CAS Server調用用戶系統完成登錄,以及CASClient從用戶系統讀取用戶詳細信息的過程。

1.1 多應用情況下Session信息

假定有兩個CAS Client應用,一個CAS Server。應用的部署,可能在不同的服務器,也可能有不同的訪問IP或域名,即使是同一個瀏覽器,在各個應用中的Session信息也是不相同的。

瀏覽器中,每個應用有一個獨立的JSESSIONIDCookie。某一個應用,不可能讀取到瀏覽器在其他應用中的Cookie信息。

假定用戶首先訪問CAS Client 01,系統提醒用戶進行一次登錄;然後用戶訪問CAS Client2,不會再提示登錄而是直接登錄成功。

1.2 第一次訪問CAS Client 01

用戶打開瀏覽器後第一次訪問,重定向到單點登錄後,會提示用戶輸入賬號密碼登錄。登錄成功之後,再跳轉回CAS Client。

1.3 第一次訪問CAS Client 02

當用戶瀏覽器已經登錄系統,切換到另一個CASClient時,跟第一次訪問有所不同,因爲已經登錄成功,就不會再提醒輸入賬號密碼登錄了。

1.4 再次訪問CAS Clients

當用戶已經訪問過CAS Client後,當用戶再次訪問,系統不會再跳轉到CAS Server做認證。

1.5 CASClient配置

爲了實現前述的單點登錄過程,以Java WEB項目爲例,需要在 web.xml 中進行相應的配置。(爲了排版,沒有填寫Filter的完整class名,請自行查閱補充。)

<filter>

  <filter-name>CAS AuthenticationFilter</filter-name>

  <filter-class>*.AuthenticationFilter</filter-class>

</filter>

<filter>

  <filter-name>CAS Validation Filter</filter-name>

  <filter-class>*.Cas10TicketValidationFilter</filter-class>

</filter>

<filter>

  <filter-name>CAS HttpServletRequest WrapperFilter</filter-name>

  <filter-class>*.HttpServletRequestWrapperFilter</filter-class>

</filter>

<filter-mapping>

  <filter-name>CAS Validation Filter</filter-name>

  <url-pattern>/*</url-pattern>

</filter-mapping>

<filter-mapping>

  <filter-name>CAS AuthenticationFilter</filter-name>

  <url-pattern>/*</url-pattern>

</filter-mapping>

<filter-mapping>

         <filter-name>CAS HttpServletRequest WrapperFilter</filter-name>

         <url-pattern>/*</url-pattern>

</filter-mapping>

仔細看一下配置過濾器可以發現,三個過濾器正好對應流程圖中三次訪問CAS Client。

  • Authentication Filter:負責將未登錄用戶跳轉到登錄界面

  • Authentication Filter:負責驗證Service Ticket

  • HttpServletRequest WrapperFilter:負責將用戶信息封裝到request和session中。

 

 

2 統一註銷的過程

2.1 不能實現統一註銷會有什麼問題

當用戶訪問系統後從系統註銷,如何能夠從每個應用中都註銷?注意前面1.4部分的描述,如果用戶註銷時,並沒有註銷CASClient 02中的會話信息,如果用戶在瀏覽器中直接訪問這個應用,因爲Session存在,並不會提醒用戶重新登錄。

這會帶來兩個潛在的隱患:

1、  用戶註銷user1後換賬號user2重新登錄,進入CAS Client 02之後,當前身份其實還是user1,並沒有如用戶預期一樣使用user2身份。

2、  用戶user1點擊註銷後離開,沒有關閉瀏覽器。這時候其他用戶直接打開CAS Client 02,能夠直接盜用user1的身份進行操作。

2.2基本概念:Service、TGT和ST

CAS已經考慮到統一註銷的問題。

這裏有三個重要的概念TGT、ST和Service,需要着重介紹一下,因爲它們同後續統一註銷的方案息息相關。

2.2.1  Service

這是用戶第一次訪問CAS Client的URL。假設一個CAS Client應用部署在域名oa.company.com,使用HTTP協議,應用首頁是index.htm。當用戶第一次訪問這個應用時,對應的URL地址是 http://oa.company.com/index.htm 。這個URL,對CAS Server來說,就是一個service。

當用戶第一次跳轉到CAS Server的時候,可以看到傳了一個參數service,就是這個值。當CASServer生成Ticket重定向到CAS Client的時候,實際就是在這個service 中添加了一個參數 ticket 。

2.2.2   TGT:Ticket Grangting Ticket

TGT是CAS Server爲每一個登錄用戶創建的登錄令牌。在CASServer上擁有了TGT,用戶就可以證明自己在CASServer成功登錄過。TGT封裝了SessionCookie值以及此Cookie值對應的用戶信息。當HTTP請求到來時,CAS以此Cookie值爲key查詢緩存中有無TGT ,如果有的話,則相信用戶已登錄過。

2.2.3  ST:Service Ticket

ST是CAS Server爲用戶簽發的訪問某一service的認證令牌。用戶訪問service時,service發現用戶沒有ST,瀏覽器會跳轉到CASServer去獲取ST。CAS Server發現用戶有TGT,則簽發一個ST,返回給用戶。用戶使用ST作爲ticket參數去訪問service,service拿ST去CAS Server驗證,驗證通過後,得到當前登錄用戶的登錄名。

注意TGT和ST,是一對多的關係。一個TGT會維護一個 services 列表,每當爲用戶創建一個ST並認證通過後,會將這個ST添加到TGT的services列表中。這樣,在CASServer端,這個services列表實際維護了一個用戶登錄過的所有CASClient。這就爲實現統一註銷打下了基礎。

2.3 CAS Client的統一註銷配置

CAS Client,爲了實現統一註銷,除了第一張介紹的三個登錄過程的過濾器之外,還需要添加一個統一註銷過濾器。

<filter>

 <filter-name>CAS Single Sign OutFilter</filter-name>

 <filter-class>*.SingleSignOutFilter</filter-class>

</filter>

<filter-mapping>

 <filter-name>CAS Single Sign OutFilter</filter-name>

 <url-pattern>/*</url-pattern>

</filter-mapping>

<listener>

 <listener-class>*.SingleSignOutHttpSessionListener</listener-class>

</listener>

2.4 CAS Server註銷過程

用戶在瀏覽器中點擊“註銷”鏈接,實際瀏覽器會訪問CASServer的註銷頁面。收到註銷請求後,CAS Server會讀取到TGT,並檢查當前用戶登錄過的所有service,並依次發送註銷請求。

2.5 CAS Client註銷過程

CAS Client的註銷,核心代碼是SingleSignOutFilter,它的關鍵代碼

public voiddoFilter(servletRequest, servletResponse, filterChain){

         HttpServletRequest request =(HttpServletRequest)servletRequest;

         if (handler.isTokenRequest(request)) {

                   handler.recordSession(request);

         } else if (handler.isLogoutRequest(request)) {

                   handler.destroySession(request);

                   return;

         }

         filterChain.doFilter(servletRequest, servletResponse);

}

其中handler是SingleSignOutHandler的實例,這個對象完成用戶在CASClient端登錄信息的維護和註銷工作。

至此,CAS完整的登錄和註銷過程就完成。

2.6 思考:什麼情況統一註銷會失敗

統一註銷的實現,需要CAS Server通過HttpClient訪問CAS Client的service。如果這個訪問過程失敗,就會導致統一註銷失敗。列了幾種情況,不詳述。

1、開發調試階段,使用localhost訪問CAS Client。

2、CAS Server部署在外網,CAS Client部署在內網。

3、網絡安全設置,不允許CASServer訪問CAS Client。

 

3 CAS Client集羣的影響

前面的論述,一直假定所有的CAS Client都是單點部署,沒有集羣。如果集羣,會有什麼影響,應該如何來解決?

3.1 Client集羣對登錄的影響

假設使用nginx做集羣前端,後面部署兩臺CAS Client 01的實例。我們看看對登錄過程會有什麼影響。

爲了描述方便,CAS Client登錄過程會有三次請求(對應三個過濾器),我們依次命名爲Authentication Request / Validation Request / Wrapper Request。

Nginx缺省的分發規則,並不是sticky模式,同一個瀏覽器的請求,會按照nginx自身某種規則進行分發。我們曾經測試過,在雙點集羣環境下,Authentication Request和ValidationRequest會恰好被分發到兩臺服務器,這就會導致登錄過程死循環。

出現登錄死循環的原因,主要在於nginx分發時,沒有使用sticky策略,也就是同一個瀏覽器的請求,永遠分發給同一臺CAS Client實例。缺省nginx的分發策略,可以根據用戶IP分發,實現的是同一個IP永遠分發到同一臺Client,這樣就能解決死循環的問題。

3.2 Client集羣對註銷的影響

當nginx實現了sitcky轉發,同一個瀏覽器的訪問會分發到同一個Client1實例,該用戶的會話信息也一直保存在Client1實例中。

當用戶統一註銷時,由CAS Server向Client發送註銷請求,這時候nginx無法確保按當前用戶進行分發,因此可能會被分發到Client2。這時候,實際效果是註銷失敗。

這個問題,在我們當前的環境中真實存在,還沒有合理的解決方法。初步分析,大概有幾個修改方向。

3.2.1  修改nginx分發策略

問題存在的原因,是因爲nginx在分發註銷策略時,不能準確分發。如果能在這個環節進行修改,系統代碼和環境,基本不用做任何修改。

 

這裏有兩種分發方法:

l CAS Server發送的註銷請求,分發給對應的後臺服務器。

l CAS Server發送的註銷請求,廣播到所有的後臺服務器。

初步結論:同架構組進行了溝通,這兩種方案都很難實現,特別是廣播的方案,沒在網絡上找到類似成功的案例。

3.2.2  集羣的節點實現Session同步

如果能實現集羣Session的同步:同步創建、同步註銷,主要在一個Client上實現了註銷,其他Client也就同步註銷。

這個會對Tomcat性能有影響。

3.2.3  集羣節點使用redis保存會話信息

即使是多個節點,它們的會話信息只有一份。一旦失效,則所有節點都失效。這只是一個設想,沒有做技術調研,不知能夠實現。

 

這有兩種修改方法:

l 修改Tomcat的配置文件,使用redis保存Tomcat的會話信息。

l 修改代碼而不是Tomcat,使用redis保存會話信息。

初步結論:架構組不允許修改生產環境的Tomcat,否定了第一種方法。我們只能嘗試修改代碼並利用redis保存會話。

3.2.4  每次請求驗證用戶是否註銷

首先,在CAS Server中實現一個接口,用於判斷某一個ST對應的TGT是否還有效。

在SingleSignOutFilter中,每次訪問都調用CAS Server的這個新接口,判斷用戶是否已經註銷。如果已經註銷,則立刻註銷本實例中的會話信息。

這個方法是比較安全的解決辦法,但每次請求都會調用CASServer接口,會對性能造成巨大影響。完全不建議用這種方案。

 

3.2.5  幾種策略的初步調研

對前面提到的幾種方案做了初步調研之後:

l 技術實現困難,否定了方案1

l 性能考慮以及架構組的策略,否定方案2

l 架構組的策略,否定方案3中的第一種做法。

l 性能考慮,否定方案4。

因此,可能的做法是修改代碼,使用redis保存會話信息。

四  使用redis保存會話

在目前的生產環境的限制下,我們只能採用修改代碼來實現redis保存會話的實現方案。

4.1 Request和Session缺省怎麼實現

在Tomcat缺省的實現中,Session信息都是保存在JVM中,所以不能跨JVM共享。

要想將所有的session都保存到redis中,一種能想到的簡單辦法是自己寫一個CustomSession,將會話信息保存到這個自定義的Session中,並且利用redis等進行保存。但這樣做,會帶來很大的代碼改動,所有涉及到session讀寫操作的地方可能都需要修改。

我們希望找到更優雅的解決方案,能夠修改更少的代碼。

4.2 WEB請求的執行過程

Request 和Session什麼時候創建?如何傳遞?

Filter的調用入口函數是doFilter,傳入的主要參數是request和response。在此之前,Tomcat已經創建好request。通常情況下,業務代碼不需要關心request和session等對象如何創建的問題,只需要使用即可。每個過濾器的實現,當需要繼續流程的時候,只需要將得到的request和response傳遞給下一個filter就行。

但這僅僅是缺省做法,並不表示我們不能修改或重寫一個request對象。我們想修改Session的保存位置,如果能在所有的Filter之前插入一個自定義過濾器,定義一個新的Request傳遞給後面的Filter,並且讓後面的Filter和Servlet感受不到變化,就可以實現這個目標。

4.3 如何定製Request

4.3.1  增加過濾器

在所有的Filter之前,插入一個新的Filter。

HttpServletRequest可以重寫嗎?

4.3.2  Tomcat的Request實現

4.3.3  改寫之後的Request實現

在Session重寫一個RedisSessionRequest,繼承自HttpServletRequestWrapper,幷包含原request(RequestFacade)的引用。但需要讀取Form參數時,直接調用oriRequest取值。當需要拿到Session對象進行會話信息訪問時,調用重載後的函數。

這樣就實現了request的封裝,在後續的filter和servlet中通過request獲取到的session,都是放在redis中的會話數據,不再是缺省保存在JVM中的數據。

4.3.4  集羣環境的session讀寫

當nginx將同一個瀏覽器的請求分發給不同的Tomcat時,都會根據SessionId從redis中讀取Session。因爲同一個瀏覽器發送請求的SessionID相同,所以在不同的Tomcat實例中,會讀取到同一個Session對象。

4.4 使用Spring Session實現

根據前面的分析,在項目中自定義Request,就可以實現需求。Spring Session已經是一個成熟的開源實現,並且後端實現了將會話保存在redis、mongodb、jdbc等多種實現,我們沒必要自己發明輪子。

Spring提供的例子代碼很簡潔,跟我們已經實現的業務系統稍微有點不同。在現有系統中,已經定義了bean jedisConnectionFactory,可以直接使用。

4.4.1  修改pom.xml

在pom.xml文件中,添加代碼

<dependency>

         <groupId>org.springframework.session</groupId>

         <artifactId>spring-session-data-redis</artifactId>

         <version>1.2.0.RELEASE</version>

</dependency>

4.4.2  修改redis配置文件

在項目中已經有redis配置文件spring-redis.xml,在其中添加內容

<context:annotation-config/>

<beans:beanclass="org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration"/>

4.4.3  修改web.xml

在所有的過濾器前面添加一個新的過濾器

<filter>

 <filter-name>springSessionRepositoryFilter</filter-name>

 <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>

</filter>

<filter-mapping>

 <filter-name>springSessionRepositoryFilter</filter-name>

  <url-pattern>/*</url-pattern>

  <dispatcher>REQUEST</dispatcher>

  <dispatcher>ERROR</dispatcher>

</filter-mapping>

4.5 測試實現效果

集成Spring Session後,經過初步測試,能夠達到預想效果。(感謝同事瑞釗的實際測試並提供截圖)

4.5.1  Session信息已經保存到redis中

用戶登錄後查看redis中的數據,可以看到這些Session信息。

4.5.2  刪除redis中會話的影響

用戶登錄後繼續訪問系統,不會切換到CAS登錄頁面。

如果手工刪掉redis中的session,重新訪問,可以看到需要重新做一個CAS認證的過程。

4.5.3  統一用戶註銷的測試

後續需要部署一套生產環境的集羣環境,驗證統一註銷的效果。經過前面兩步測試驗證,理論上說註銷已經不是問題。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章