【原創(chuàng)申明:文章為原創(chuàng),歡迎非盈利性轉(zhuǎn)載,但轉(zhuǎn)載必須注明來源】
之前寫過一篇文章,介紹單點登錄的基本原理。這篇文章重點介紹開源單點登錄系統(tǒng)CAS的登錄和注銷的實現(xiàn)方法。并結(jié)合實際工作中碰到的問題,探討在集群環(huán)境中應(yīng)用單點登錄可能會面臨的問題。這篇文章在上一篇的基礎(chǔ)上,增加了第四部分,最終的解決方案。
為了描述方便,假設(shè)有如下一個單點登錄系統(tǒng)。一套CASServer,兩套CAS Client系統(tǒng)。為了描述的方便,省略CAS Server調(diào)用用戶系統(tǒng)完成登錄,以及CASClient從用戶系統(tǒng)讀取用戶詳細信息的過程。
假定有兩個CAS Client應(yīng)用,一個CAS Server。應(yīng)用的部署,可能在不同的服務(wù)器,也可能有不同的訪問IP或域名,即使是同一個瀏覽器,在各個應(yīng)用中的Session信息也是不相同的。
瀏覽器中,每個應(yīng)用有一個獨立的JSESSIONIDCookie。某一個應(yīng)用,不可能讀取到瀏覽器在其他應(yīng)用中的Cookie信息。
假定用戶首先訪問CAS Client 01,系統(tǒng)提醒用戶進行一次登錄;然后用戶訪問CAS Client2,不會再提示登錄而是直接登錄成功。
用戶打開瀏覽器后第一次訪問,重定向到單點登錄后,會提示用戶輸入賬號密碼登錄。登錄成功之后,再跳轉(zhuǎn)回CAS Client。
當(dāng)用戶瀏覽器已經(jīng)登錄系統(tǒng),切換到另一個CASClient時,跟第一次訪問有所不同,因為已經(jīng)登錄成功,就不會再提醒輸入賬號密碼登錄了。
當(dāng)用戶已經(jīng)訪問過CAS Client后,當(dāng)用戶再次訪問,系統(tǒng)不會再跳轉(zhuǎn)到CAS Server做認證。
為了實現(xiàn)前述的單點登錄過程,以Java WEB項目為例,需要在 web.xml 中進行相應(yīng)的配置。(為了排版,沒有填寫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>
仔細看一下配置過濾器可以發(fā)現(xiàn),三個過濾器正好對應(yīng)流程圖中三次訪問CAS Client。
Authentication Filter:負責(zé)將未登錄用戶跳轉(zhuǎn)到登錄界面
Authentication Filter:負責(zé)驗證Service Ticket
HttpServletRequest WrapperFilter:負責(zé)將用戶信息封裝到request和session中。
當(dāng)用戶訪問系統(tǒng)后從系統(tǒng)注銷,如何能夠從每個應(yīng)用中都注銷?注意前面1.4部分的描述,如果用戶注銷時,并沒有注銷CASClient 02中的會話信息,如果用戶在瀏覽器中直接訪問這個應(yīng)用,因為Session存在,并不會提醒用戶重新登錄。
這會帶來兩個潛在的隱患:
1、 用戶注銷user1后換賬號user2重新登錄,進入CAS Client 02之后,當(dāng)前身份其實還是user1,并沒有如用戶預(yù)期一樣使用user2身份。
2、 用戶user1點擊注銷后離開,沒有關(guān)閉瀏覽器。這時候其他用戶直接打開CAS Client 02,能夠直接盜用user1的身份進行操作。
CAS已經(jīng)考慮到統(tǒng)一注銷的問題。
這里有三個重要的概念TGT、ST和Service,需要著重介紹一下,因為它們同后續(xù)統(tǒng)一注銷的方案息息相關(guān)。
這是用戶第一次訪問CAS Client的URL。假設(shè)一個CAS Client應(yīng)用部署在域名oa.company.com,使用HTTP協(xié)議,應(yīng)用首頁是index.htm。當(dāng)用戶第一次訪問這個應(yīng)用時,對應(yīng)的URL地址是 http://oa.company.com/index.htm 。這個URL,對CAS Server來說,就是一個service。
當(dāng)用戶第一次跳轉(zhuǎn)到CAS Server的時候,可以看到傳了一個參數(shù)service,就是這個值。當(dāng)CASServer生成Ticket重定向到CAS Client的時候,實際就是在這個service 中添加了一個參數(shù) ticket 。
TGT是CAS Server為每一個登錄用戶創(chuàng)建的登錄令牌。在CASServer上擁有了TGT,用戶就可以證明自己在CASServer成功登錄過。TGT封裝了SessionCookie值以及此Cookie值對應(yīng)的用戶信息。當(dāng)HTTP請求到來時,CAS以此Cookie值為key查詢緩存中有無TGT ,如果有的話,則相信用戶已登錄過。
ST是CAS Server為用戶簽發(fā)的訪問某一service的認證令牌。用戶訪問service時,service發(fā)現(xiàn)用戶沒有ST,瀏覽器會跳轉(zhuǎn)到CASServer去獲取ST。CAS Server發(fā)現(xiàn)用戶有TGT,則簽發(fā)一個ST,返回給用戶。用戶使用ST作為ticket參數(shù)去訪問service,service拿ST去CAS Server驗證,驗證通過后,得到當(dāng)前登錄用戶的登錄名。
注意TGT和ST,是一對多的關(guān)系。一個TGT會維護一個 services 列表,每當(dāng)為用戶創(chuàng)建一個ST并認證通過后,會將這個ST添加到TGT的services列表中。這樣,在CASServer端,這個services列表實際維護了一個用戶登錄過的所有CASClient。這就為實現(xiàn)統(tǒng)一注銷打下了基礎(chǔ)。
CAS Client,為了實現(xiàn)統(tǒng)一注銷,除了第一張介紹的三個登錄過程的過濾器之外,還需要添加一個統(tǒng)一注銷過濾器。
<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,并檢查當(dāng)前用戶登錄過的所有service,并依次發(fā)送注銷請求。
CAS Client的注銷,核心代碼是SingleSignOutFilter,它的關(guān)鍵代碼
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完整的登錄和注銷過程就完成。
統(tǒng)一注銷的實現(xiàn),需要CAS Server通過HttpClient訪問CAS Client的service。如果這個訪問過程失敗,就會導(dǎo)致統(tǒng)一注銷失敗。列了幾種情況,不詳述。
1、開發(fā)調(diào)試階段,使用localhost訪問CAS Client。
2、CAS Server部署在外網(wǎng),CAS Client部署在內(nèi)網(wǎng)。
3、網(wǎng)絡(luò)安全設(shè)置,不允許CASServer訪問CAS Client。
前面的論述,一直假定所有的CAS Client都是單點部署,沒有集群。如果集群,會有什么影響,應(yīng)該如何來解決?
假設(shè)使用nginx做集群前端,后面部署兩臺CAS Client 01的實例。我們看看對登錄過程會有什么影響。
為了描述方便,CAS Client登錄過程會有三次請求(對應(yīng)三個過濾器),我們依次命名為Authentication Request / Validation Request / Wrapper Request。
Nginx缺省的分發(fā)規(guī)則,并不是sticky模式,同一個瀏覽器的請求,會按照nginx自身某種規(guī)則進行分發(fā)。我們曾經(jīng)測試過,在雙點集群環(huán)境下,Authentication Request和ValidationRequest會恰好被分發(fā)到兩臺服務(wù)器,這就會導(dǎo)致登錄過程死循環(huán)。
出現(xiàn)登錄死循環(huán)的原因,主要在于nginx分發(fā)時,沒有使用sticky策略,也就是同一個瀏覽器的請求,永遠分發(fā)給同一臺CAS Client實例。缺省nginx的分發(fā)策略,可以根據(jù)用戶IP分發(fā),實現(xiàn)的是同一個IP永遠分發(fā)到同一臺Client,這樣就能解決死循環(huán)的問題。
當(dāng)nginx實現(xiàn)了sitcky轉(zhuǎn)發(fā),同一個瀏覽器的訪問會分發(fā)到同一個Client1實例,該用戶的會話信息也一直保存在Client1實例中。
當(dāng)用戶統(tǒng)一注銷時,由CAS Server向Client發(fā)送注銷請求,這時候nginx無法確保按當(dāng)前用戶進行分發(fā),因此可能會被分發(fā)到Client2。這時候,實際效果是注銷失敗。
這個問題,在我們當(dāng)前的環(huán)境中真實存在,還沒有合理的解決方法。初步分析,大概有幾個修改方向。
問題存在的原因,是因為nginx在分發(fā)注銷策略時,不能準(zhǔn)確分發(fā)。如果能在這個環(huán)節(jié)進行修改,系統(tǒng)代碼和環(huán)境,基本不用做任何修改。
這里有兩種分發(fā)方法:
l CAS Server發(fā)送的注銷請求,分發(fā)給對應(yīng)的后臺服務(wù)器。
l CAS Server發(fā)送的注銷請求,廣播到所有的后臺服務(wù)器。
初步結(jié)論:同架構(gòu)組進行了溝通,這兩種方案都很難實現(xiàn),特別是廣播的方案,沒在網(wǎng)絡(luò)上找到類似成功的案例。
如果能實現(xiàn)集群Session的同步:同步創(chuàng)建、同步注銷,主要在一個Client上實現(xiàn)了注銷,其他Client也就同步注銷。
這個會對Tomcat性能有影響。
即使是多個節(jié)點,它們的會話信息只有一份。一旦失效,則所有節(jié)點都失效。這只是一個設(shè)想,沒有做技術(shù)調(diào)研,不知能夠?qū)崿F(xiàn)。
這有兩種修改方法:
l 修改Tomcat的配置文件,使用redis保存Tomcat的會話信息。
l 修改代碼而不是Tomcat,使用redis保存會話信息。
初步結(jié)論:架構(gòu)組不允許修改生產(chǎn)環(huán)境的Tomcat,否定了第一種方法。我們只能嘗試修改代碼并利用redis保存會話。
首先,在CAS Server中實現(xiàn)一個接口,用于判斷某一個ST對應(yīng)的TGT是否還有效。
在SingleSignOutFilter中,每次訪問都調(diào)用CAS Server的這個新接口,判斷用戶是否已經(jīng)注銷。如果已經(jīng)注銷,則立刻注銷本實例中的會話信息。
這個方法是比較安全的解決辦法,但每次請求都會調(diào)用CASServer接口,會對性能造成巨大影響。完全不建議用這種方案。
對前面提到的幾種方案做了初步調(diào)研之后:
l 技術(shù)實現(xiàn)困難,否定了方案1
l 性能考慮以及架構(gòu)組的策略,否定方案2
l 架構(gòu)組的策略,否定方案3中的第一種做法。
l 性能考慮,否定方案4。
因此,可能的做法是修改代碼,使用redis保存會話信息。
四 使用redis保存會話
在目前的生產(chǎn)環(huán)境的限制下,我們只能采用修改代碼來實現(xiàn)redis保存會話的實現(xiàn)方案。
在Tomcat缺省的實現(xiàn)中,Session信息都是保存在JVM中,所以不能跨JVM共享。
要想將所有的session都保存到redis中,一種能想到的簡單辦法是自己寫一個CustomSession,將會話信息保存到這個自定義的Session中,并且利用redis等進行保存。但這樣做,會帶來很大的代碼改動,所有涉及到session讀寫操作的地方可能都需要修改。
我們希望找到更優(yōu)雅的解決方案,能夠修改更少的代碼。
Request 和Session什么時候創(chuàng)建?如何傳遞?
Filter的調(diào)用入口函數(shù)是doFilter,傳入的主要參數(shù)是request和response。在此之前,Tomcat已經(jīng)創(chuàng)建好request。通常情況下,業(yè)務(wù)代碼不需要關(guān)心request和session等對象如何創(chuàng)建的問題,只需要使用即可。每個過濾器的實現(xiàn),當(dāng)需要繼續(xù)流程的時候,只需要將得到的request和response傳遞給下一個filter就行。
但這僅僅是缺省做法,并不表示我們不能修改或重寫一個request對象。我們想修改Session的保存位置,如果能在所有的Filter之前插入一個自定義過濾器,定義一個新的Request傳遞給后面的Filter,并且讓后面的Filter和Servlet感受不到變化,就可以實現(xiàn)這個目標(biāo)。
在所有的Filter之前,插入一個新的Filter。
HttpServletRequest可以重寫嗎?
在Session重寫一個RedisSessionRequest,繼承自HttpServletRequestWrapper,并包含原request(RequestFacade)的引用。但需要讀取Form參數(shù)時,直接調(diào)用oriRequest取值。當(dāng)需要拿到Session對象進行會話信息訪問時,調(diào)用重載后的函數(shù)。
這樣就實現(xiàn)了request的封裝,在后續(xù)的filter和servlet中通過request獲取到的session,都是放在redis中的會話數(shù)據(jù),不再是缺省保存在JVM中的數(shù)據(jù)。
當(dāng)nginx將同一個瀏覽器的請求分發(fā)給不同的Tomcat時,都會根據(jù)SessionId從redis中讀取Session。因為同一個瀏覽器發(fā)送請求的SessionID相同,所以在不同的Tomcat實例中,會讀取到同一個Session對象。
根據(jù)前面的分析,在項目中自定義Request,就可以實現(xiàn)需求。Spring Session已經(jīng)是一個成熟的開源實現(xiàn),并且后端實現(xiàn)了將會話保存在redis、mongodb、jdbc等多種實現(xiàn),我們沒必要自己發(fā)明輪子。
Spring提供的例子代碼很簡潔,跟我們已經(jīng)實現(xiàn)的業(yè)務(wù)系統(tǒng)稍微有點不同。在現(xiàn)有系統(tǒng)中,已經(jīng)定義了bean jedisConnectionFactory,可以直接使用。
在pom.xml文件中,添加代碼
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
<version>1.2.0.RELEASE</version>
</dependency>
在項目中已經(jīng)有redis配置文件spring-redis.xml,在其中添加內(nèi)容
<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后,經(jīng)過初步測試,能夠達到預(yù)想效果。(感謝同事瑞釗的實際測試并提供截圖)
用戶登錄后查看redis中的數(shù)據(jù),可以看到這些Session信息。
用戶登錄后繼續(xù)訪問系統(tǒng),不會切換到CAS登錄頁面。
如果手工刪掉redis中的session,重新訪問,可以看到需要重新做一個CAS認證的過程。
后續(xù)需要部署一套生產(chǎn)環(huán)境的集群環(huán)境,驗證統(tǒng)一注銷的效果。經(jīng)過前面兩步測試驗證,理論上說注銷已經(jīng)不是問題。