【原創申明:文章為原創,歡迎非盈利性轉載,但轉載必須注明來源】
之前寫過一篇文章,介紹單點登入的基本原理。這篇文章重點介紹開源單點登入系統cas的登入和登出的實作方法。并結合實際工作中碰到的問題,探讨在叢集環境中應用單點登入可能會面臨的問題。這篇文章在上一篇的基礎上,增加了第四部分,最終的解決方案。
為了描述友善,假設有如下一個單點登入系統。一套casserver,兩套cas client系統。為了描述的友善,省略cas server調用使用者系統完成登入,以及casclient從使用者系統讀取使用者詳細資訊的過程。
假定有兩個cas client應用,一個cas server。應用的部署,可能在不同的伺服器,也可能有不同的通路ip或域名,即使是同一個浏覽器,在各個應用中的session資訊也是不相同的。
浏覽器中,每個應用有一個獨立的jsessionidcookie。某一個應用,不可能讀取到浏覽器在其他應用中的cookie資訊。
假定使用者首先通路cas client 01,系統提醒使用者進行一次登入;然後使用者通路cas client2,不會再提示登入而是直接登入成功。
使用者打開浏覽器後第一次通路,重定向到單點登入後,會提示使用者輸入賬号密碼登入。登入成功之後,再跳轉回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-name>cas validation filter</filter-name>
<filter-class>*.cas10ticketvalidationfilter</filter-class>
<filter-name>cas httpservletrequest wrapperfilter</filter-name>
<filter-class>*.httpservletrequestwrapperfilter</filter-class>
<filter-mapping>
<url-pattern>/*</url-pattern>
</filter-mapping>
<filter-name>cas httpservletrequest wrapperfilter</filter-name>
<url-pattern>/*</url-pattern>
仔細看一下配置過濾器可以發現,三個過濾器正好對應流程圖中三次通路cas client。
authentication filter:負責将未登入使用者跳轉到登入界面
authentication filter:負責驗證service ticket
httpservletrequest wrapperfilter:負責将使用者資訊封裝到request和session中。
當使用者通路系統後從系統登出,如何能夠從每個應用中都登出?注意前面1.4部分的描述,如果使用者登出時,并沒有登出casclient 02中的會話資訊,如果使用者在浏覽器中直接通路這個應用,因為session存在,并不會提醒使用者重新登入。
這會帶來兩個潛在的隐患:
1、 使用者登出user1後換賬号user2重新登入,進入cas client 02之後,目前身份其實還是user1,并沒有如使用者預期一樣使用user2身份。
2、 使用者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-name>cas single sign outfilter</filter-name>
<filter-class>*.singlesignoutfilter</filter-class>
<url-pattern>/*</url-pattern>
<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。如果這個通路過程失敗,就會導緻統一登出失敗。列了幾種情況,不詳述。
1、開發調試階段,使用localhost通路cas client。
2、cas server部署在外網,cas client部署在内網。
3、網絡安全設定,不允許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-name>springsessionrepositoryfilter</filter-name>
<filter-class>org.springframework.web.filter.delegatingfilterproxy</filter-class>
<dispatcher>request</dispatcher>
<dispatcher>error</dispatcher>
內建spring session後,經過初步測試,能夠達到預想效果。(感謝同僚瑞钊的實際測試并提供截圖)
使用者登入後檢視redis中的資料,可以看到這些session資訊。
使用者登入後繼續通路系統,不會切換到cas登入頁面。
如果手工删掉redis中的session,重新通路,可以看到需要重新做一個cas認證的過程。
後續需要部署一套生産環境的叢集環境,驗證統一登出的效果。