【原創申明:文章爲原創,歡迎非盈利性轉載,但轉載必須註明來源】前端
以前寫過一篇文章,介紹單點登陸的基本原理。這篇文章重點介紹開源單點登陸系統CAS的登陸和註銷的實現方法。並結合實際工做中碰到的問題,探討在集羣環境中應用單點登陸可能會面臨的問題。這篇文章在上一篇的基礎上,增長了第四部分,最終的解決方案。nginx
爲了描述方便,假設有以下一個單點登陸系統。一套CASServer,兩套CAS Client系統。爲了描述的方便,省略CAS Server調用用戶系統完成登陸,以及CASClient從用戶系統讀取用戶詳細信息的過程。web
假定有兩個CAS Client應用,一個CAS Server。應用的部署,可能在不一樣的服務器,也可能有不一樣的訪問IP或域名,即便是同一個瀏覽器,在各個應用中的Session信息也是不相同的。redis
瀏覽器中,每一個應用有一個獨立的JSESSIONIDCookie。某一個應用,不可能讀取到瀏覽器在其餘應用中的Cookie信息。spring
假定用戶首先訪問CAS Client 01,系統提醒用戶進行一次登陸;而後用戶訪問CAS Client2,不會再提示登陸而是直接登陸成功。mongodb
用戶打開瀏覽器後第一次訪問,重定向到單點登陸後,會提示用戶輸入帳號密碼登陸。登陸成功以後,再跳轉回CAS Client。後端
當用戶瀏覽器已經登陸系統,切換到另外一個CASClient時,跟第一次訪問有所不一樣,由於已經登陸成功,就不會再提醒輸入帳號密碼登陸了。瀏覽器
當用戶已經訪問過CAS Client後,當用戶再次訪問,系統不會再跳轉到CAS Server作認證。緩存
爲了實現前述的單點登陸過程,以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中。
當用戶訪問系統後從系統註銷,如何可以從每一個應用中都註銷?注意前面1.4部分的描述,若是用戶註銷時,並無註銷CASClient 02中的會話信息,若是用戶在瀏覽器中直接訪問這個應用,由於Session存在,並不會提醒用戶從新登陸。
這會帶來兩個潛在的隱患:
一、 用戶註銷user1後換帳號user2從新登陸,進入CAS Client 02以後,當前身份其實仍是user1,並無如用戶預期同樣使用user2身份。
二、 用戶user1點擊註銷後離開,沒有關閉瀏覽器。這時候其餘用戶直接打開CAS Client 02,可以直接盜用user1的身份進行操做。
CAS已經考慮到統一註銷的問題。
這裏有三個重要的概念TGT、ST和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 。
TGT是CAS Server爲每個登陸用戶建立的登陸令牌。在CASServer上擁有了TGT,用戶就能夠證實本身在CASServer成功登陸過。TGT封裝了SessionCookie值以及此Cookie值對應的用戶信息。當HTTP請求到來時,CAS以此Cookie值爲key查詢緩存中有無TGT ,若是有的話,則相信用戶已登陸過。
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。這就爲實現統一註銷打下了基礎。
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>
用戶在瀏覽器中點擊「註銷」連接,實際瀏覽器會訪問CASServer的註銷頁面。收到註銷請求後,CAS Server會讀取到TGT,並檢查當前用戶登陸過的全部service,並依次發送註銷請求。
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完整的登陸和註銷過程就完成。
統一註銷的實現,須要CAS Server經過HttpClient訪問CAS Client的service。若是這個訪問過程失敗,就會致使統一註銷失敗。列了幾種狀況,不詳述。
一、開發調試階段,使用localhost訪問CAS Client。
二、CAS Server部署在外網,CAS Client部署在內網。
三、網絡安全設置,不容許CASServer訪問CAS Client。
前面的論述,一直假定全部的CAS 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,這樣就能解決死循環的問題。
當nginx實現了sitcky轉發,同一個瀏覽器的訪問會分發到同一個Client1實例,該用戶的會話信息也一直保存在Client1實例中。
當用戶統一註銷時,由CAS Server向Client發送註銷請求,這時候nginx沒法確保按當前用戶進行分發,所以可能會被分發到Client2。這時候,實際效果是註銷失敗。
這個問題,在咱們當前的環境中真實存在,尚未合理的解決方法。初步分析,大概有幾個修改方向。
問題存在的緣由,是由於nginx在分發註銷策略時,不能準確分發。若是能在這個環節進行修改,系統代碼和環境,基本不用作任何修改。
這裏有兩種分發方法:
l CAS Server發送的註銷請求,分發給對應的後臺服務器。
l CAS Server發送的註銷請求,廣播到全部的後臺服務器。
初步結論:同架構組進行了溝通,這兩種方案都很難實現,特別是廣播的方案,沒在網絡上找到相似成功的案例。
若是能實現集羣Session的同步:同步建立、同步註銷,主要在一個Client上實現了註銷,其餘Client也就同步註銷。
這個會對Tomcat性能有影響。
即便是多個節點,它們的會話信息只有一份。一旦失效,則全部節點都失效。這只是一個設想,沒有作技術調研,不知可以實現。
這有兩種修改方法:
l 修改Tomcat的配置文件,使用redis保存Tomcat的會話信息。
l 修改代碼而不是Tomcat,使用redis保存會話信息。
初步結論:架構組不容許修改生產環境的Tomcat,否認了第一種方法。咱們只能嘗試修改代碼並利用redis保存會話。
首先,在CAS Server中實現一個接口,用於判斷某一個ST對應的TGT是否還有效。
在SingleSignOutFilter中,每次訪問都調用CAS Server的這個新接口,判斷用戶是否已經註銷。若是已經註銷,則馬上註銷本實例中的會話信息。
這個方法是比較安全的解決辦法,但每次請求都會調用CASServer接口,會對性能形成巨大影響。徹底不建議用這種方案。
對前面提到的幾種方案作了初步調研以後:
l 技術實現困難,否認了方案1
l 性能考慮以及架構組的策略,否認方案2
l 架構組的策略,否認方案3中的第一種作法。
l 性能考慮,否認方案4。
所以,可能的作法是修改代碼,使用redis保存會話信息。
四 使用redis保存會話
在目前的生產環境的限制下,咱們只能採用修改代碼來實現redis保存會話的實現方案。
在Tomcat缺省的實現中,Session信息都是保存在JVM中,因此不能跨JVM共享。
要想將全部的session都保存到redis中,一種能想到的簡單辦法是本身寫一個CustomSession,將會話信息保存到這個自定義的Session中,而且利用redis等進行保存。但這樣作,會帶來很大的代碼改動,全部涉及到session讀寫操做的地方可能都須要修改。
咱們但願找到更優雅的解決方案,可以修改更少的代碼。
Request 和Session何時建立?如何傳遞?
Filter的調用入口函數是doFilter,傳入的主要參數是request和response。在此以前,Tomcat已經建立好request。一般狀況下,業務代碼不須要關心request和session等對象如何建立的問題,只須要使用便可。每一個過濾器的實現,當須要繼續流程的時候,只須要將獲得的request和response傳遞給下一個filter就行。
但這僅僅是缺省作法,並不表示咱們不能修改或重寫一個request對象。咱們想修改Session的保存位置,若是能在全部的Filter以前插入一個自定義過濾器,定義一個新的Request傳遞給後面的Filter,而且讓後面的Filter和Servlet感覺不到變化,就能夠實現這個目標。
在全部的Filter以前,插入一個新的Filter。
HttpServletRequest能夠重寫嗎?
在Session重寫一個RedisSessionRequest,繼承自HttpServletRequestWrapper,幷包含原request(RequestFacade)的引用。但須要讀取Form參數時,直接調用oriRequest取值。當須要拿到Session對象進行會話信息訪問時,調用重載後的函數。
這樣就實現了request的封裝,在後續的filter和servlet中經過request獲取到的session,都是放在redis中的會話數據,再也不是缺省保存在JVM中的數據。
當nginx將同一個瀏覽器的請求分發給不一樣的Tomcat時,都會根據SessionId從redis中讀取Session。由於同一個瀏覽器發送請求的SessionID相同,因此在不一樣的Tomcat實例中,會讀取到同一個Session對象。
根據前面的分析,在項目中自定義Request,就能夠實現需求。Spring Session已是一個成熟的開源實現,而且後端實現了將會話保存在redis、mongodb、jdbc等多種實現,咱們不必本身發明輪子。
Spring提供的例子代碼很簡潔,跟咱們已經實現的業務系統稍微有點不一樣。在現有系統中,已經定義了bean jedisConnectionFactory,能夠直接使用。
在pom.xml文件中,添加代碼
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
<version>1.2.0.RELEASE</version>
</dependency>
在項目中已經有redis配置文件spring-redis.xml,在其中添加內容
<context:annotation-config/>
<beans:beanclass="org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration"/>
在全部的過濾器前面添加一個新的過濾器
<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>
集成Spring Session後,通過初步測試,可以達到預想效果。(感謝同事瑞釗的實際測試並提供截圖)
用戶登陸後查看redis中的數據,能夠看到這些Session信息。
用戶登陸後繼續訪問系統,不會切換到CAS登陸頁面。
若是手工刪掉redis中的session,從新訪問,能夠看到須要從新作一個CAS認證的過程。
後續須要部署一套生產環境的集羣環境,驗證統一註銷的效果。通過前面兩步測試驗證,理論上說註銷已經不是問題。