咱們在項目中使用了cas做爲單點登陸的解決方案,當在集成shiro作統一權限控制的時候,發現單點退出登陸有坑,因此啃了一下CAS的單點登出的源碼,在此分享一下。java
在解析CAS單點登出的原理以前,咱們先回顧一下在單點登陸過程當中,CAS服務器和CAS客戶端都作了一些什麼事,這些事在後面解析單點登出時有助於理解。web
通常狀況下,在項目中使用cas client提供的幾個過濾器實現WEB APP的單點登陸、退出功能,配置以下:json
<listener> <listener-class>org.jasig.cas.client.session.SingleSignOutHttpSessionListener </listener-class> </listener> <filter> <filter-name>CAS Single Sign Out Filter</filter-name> <filter-class>org.jasig.cas.client.session.SingleSignOutFilter</filter-class> <init-param> <param-name>casServerUrlPrefix</param-name> <param-value>http://passport.edu:18080</param-value> </init-param> </filter> <filter-mapping> <filter-name>CAS Single Sign Out Filter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <filter> <filter-name>CAS Authentication Filter</filter-name> <filter-class>org.jasig.cas.client.authentication.AuthenticationFilter</filter-class> <init-param> <param-name>casServerLoginUrl</param-name> <param-value>http://passport.edu:18080/login</param-value> </init-param> <init-param> <param-name>serverName</param-name> <param-value>http://jd.edu:9443</param-value> </init-param> </filter> <filter-mapping> <filter-name>CAS Authentication Filter</filter-name> <url-pattern>/groupon/*</url-pattern> </filter-mapping> <filter> <filter-name>CAS Validation Filter</filter-name> <filter-class>org.jasig.cas.client.validation.Cas30ProxyReceivingTicketValidationFilter </filter-class> <init-param> <param-name>casServerUrlPrefix</param-name> <param-value>http://passport.edu:18080</param-value> </init-param> <init-param> <param-name>serverName</param-name> <param-value>http://jd.edu:9443</param-value> </init-param> <init-param> <param-name>redirectAfterValidation</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>CAS Validation Filter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <filter> <filter-name>CAS HttpServletRequest Wrapper Filter</filter-name> <filter-class>org.jasig.cas.client.util.HttpServletRequestWrapperFilter</filter-class> </filter> <filter-mapping> <filter-name>CAS HttpServletRequest Wrapper Filter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>
(1)CAS服務器在用戶填入表單登陸成功後,會在用戶瀏覽器的cas 服務器所在域的cookie中存入TGC,即ticket granting cookie,它是加密的,裏面包含TGT的id,以及瀏覽器的信息。瀏覽器
清單:TGC未加密前的信息緩存
TGT-**********************************************aPD6RZNcJg-passport.edu@127.0.0.1@Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.115 Safari/537.36]
清單:TGC加密後的信息服務器
另外,CAS服務器內部會建立一個緩存存放TGT對象。TGT對象的ID就是TGC的ID,它還保存了一個很是重要的一個map:services 。cookie
services ,這個名詞是否是很熟悉?咱們的應用服務器APP對於CAS服務器就是一個service。在cas server的配置文件中能夠限定哪些service能夠訪問CAS服務器,另外,在咱們的重定向到CAS登陸的URL中,也必須告訴CAS當前訪問它的service是誰。扯遠了,解釋一下,當web app應用系統得到登陸認證後,須要在CAS上註冊它已經被受權登陸了,這時應用服務器將獲取被受權登陸的票據ST(service ticket),CAS服務器爲應用服務器建立了Service對象用於保存它的一些信息(最重要的就是ID和認證信息了),並把service保存到services這個map中,該map的key就是ST了。session
(2)CAS客戶端在SingleSignOutFilter過濾器中,獲取CAS服務器返回Service Ticket,將爲ST與session創建映射關係,該映射關係將會在單點登出的時候使用。app
具體的登陸流程,請參考《單點登陸CAS登陸流程》ide
整個註銷流程大體能夠分爲TGT解碼和ticket銷燬兩個步驟。
整個註銷流程起源於瀏覽器向CAS服務器發起登出請求:http://passport.edu:18080/logout?service=http://jd.edu:9443。
CAS服務接收請求後,獲取瀏覽器的cookie中的tgc信息,對tgc信息進行解密,解密後將獲取到tgt的ID,而後由CentralAuthenticationServiceImpl 類的 destroyTicketGrantingTicket()方法註銷該TGT。
因爲CAS服務器和應用服務器都保存了ticket,因此CAS服務器除了本身銷燬ticket外,還須要通知應用服務器銷燬ticket。下面咱們看一下詳細流程。
=========+=======我是分割線,下面是CAS服務器端分析=======================
看一下 CentralAuthenticationServiceImpl 類的 destroyTicketGrantingTicket()方法。
public List<LogoutRequest> destroyTicketGrantingTicket(@NotNull final String ticketGrantingTicketId) { try { // 根據tgt ID從ticketRegistry註冊中心中獲取TGT final TicketGrantingTicket ticket = getTicket(ticketGrantingTicketId, TicketGrantingTicket.class); // 備註(1):由LogoutManager 完成註銷 final List<LogoutRequest> logoutRequests = logoutManager.performLogout(ticket); // 備註(2):註冊中心刪除該tgt this.ticketRegistry.deleteTicket(ticketGrantingTicketId); return logoutRequests; } catch (final InvalidTicketException e) { logger.debug("TicketGrantingTicket [{}] cannot be found in the ticket registry.", ticketGrantingTicketId); } return Collections.emptyList(); }
代碼中的備註(1)完成客戶端的ticket銷燬,備註(2)完成CAS服務器的ticket銷燬。備註(1)的登出管理器的實現類是 LogoutManagerImpl,看一下它的performLogout方法。
@Override public List<LogoutRequest> performLogout(final TicketGrantingTicket ticket) { final Map<String, Service> services = ticket.getServices(); // 獲取註冊在tgt下的service final List<LogoutRequest> logoutRequests = new ArrayList<>(); if (!this.singleLogoutCallbacksDisabled) { // 遍歷全部的service for (final Map.Entry<String, Service> entry : services.entrySet()) { // it's a SingleLogoutService, else ignore final Service service = entry.getValue(); if (service instanceof SingleLogoutService) { // 對service進行登出操做 final LogoutRequest logoutRequest = handleLogoutForSloService((SingleLogoutService) service, entry.getKey()); if (logoutRequest != null) { LOGGER.debug("Captured logout request [{}]", logoutRequest); logoutRequests.add(logoutRequest); } } } }
繼續看一下handleLogoutForSloService方法
private LogoutRequest handleLogoutForSloService(final SingleLogoutService singleLogoutService, final String ticketId) { if (!singleLogoutService.isLoggedOutAlready()) { // 備註(1):從服務管理器中獲取匹配的已註冊的服務 final RegisteredService registeredService = servicesManager.findServiceBy(singleLogoutService); if (serviceSupportsSingleLogout(registeredService)) { // 決定使用哪一個登出URL,若是registeredService指定了就用它的,否則就用singleLogoutService裏的URL // 通常registeredService不會指定 final URL logoutUrl = determineLogoutUrl(registeredService, singleLogoutService); // 包裝登出請求 final DefaultLogoutRequest logoutRequest = new DefaultLogoutRequest(ticketId, singleLogoutService, logoutUrl); final LogoutType type = registeredService.getLogoutType() == null ? LogoutType.BACK_CHANNEL : registeredService.getLogoutType(); switch (type) { case BACK_CHANNEL: // 通知應用服務器註銷ticket if (performBackChannelLogout(logoutRequest)) { logoutRequest.setStatus(LogoutRequestStatus.SUCCESS); } else { logoutRequest.setStatus(LogoutRequestStatus.FAILURE); LOGGER.warn("Logout message not sent to [{}]; Continuing processing...", singleLogoutService.getId()); } break; default: logoutRequest.setStatus(LogoutRequestStatus.NOT_ATTEMPTED); break; } return logoutRequest; } } return null; }
備註(1)中,servicesManager.findServiceBy( ) 該方法將會遍歷在servicesManager註冊的服務,而且查看service是否匹配RegisteredService。RegisteredService是什麼呢?
RegisteredService是在cas初始化中,加載配置文件後註冊在服務管理器中的服務信息,該信息定義了哪些應用服務器能夠接入CAS,登出的類型是什麼。
你們是否還記得在CAS服務器的搭建時,是否是修改過 HTTPSandIMAPS-10000001.json 的serviceID呢?這個配置文件就是定義了一個RegisteredService。
清單:HTTPSandIMAPS-10000001.json
{ "@class" : "org.jasig.cas.services.RegexRegisteredService", "serviceId" : "^(https|imaps|http)://.*", "name" : "HTTPS and IMAPS", "id" : 10000001, "description" : "This service definition authorized all application urls that support HTTPS and IMAPS protocols.", "proxyPolicy" : { "@class" : "org.jasig.cas.services.RefuseRegisteredServiceProxyPolicy" }, "evaluationOrder" : 0, "usernameAttributeProvider" : { "@class" : "org.jasig.cas.services.DefaultRegisteredServiceUsernameProvider" }, "logoutType" : "BACK_CHANNEL", "attributeReleasePolicy" : { "@class" : "org.jasig.cas.services.ReturnAllowedAttributeReleasePolicy", "principalAttributesRepository" : { "@class" : "org.jasig.cas.authentication.principal.DefaultPrincipalAttributesRepository" }, "authorizedToReleaseCredentialPassword" : false, "authorizedToReleaseProxyGrantingTicket" : false }, "accessStrategy" : { "@class" : "org.jasig.cas.services.DefaultRegisteredServiceAccessStrategy", "enabled" : true, "ssoEnabled" : true } }
這裏的RegisteredService實現類是 RegexRegisteredService,它經過正則匹配service的url,模式是HTTPSandIMAPS-10000001.json文件中定義的serviceId。
繼續分析它是怎麼通知應用服務器銷燬ticket的。
private boolean performBackChannelLogout(final LogoutRequest request) { try { // 構建登出的協議報文 final String logoutRequest = this.logoutMessageBuilder.create(request); final SingleLogoutService logoutService = request.getService(); logoutService.setLoggedOutAlready(true); // LogoutHttpMessage封裝了請求的url和報文,url就是應用服務器的url final LogoutHttpMessage msg = new LogoutHttpMessage(request.getLogoutUrl(), logoutRequest); // 調用httpClient,以POST的方式發出報文 return this.httpClient.sendMessageToEndPoint(msg); } catch (final Exception e) { LOGGER.error(e.getMessage(), e); } return false; }
報文內容以下:
<samlp:LogoutRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="LR-1-VM1PfgJD6VEDtCc4NnIWaVLqFs0PktY6Ej9" Version="2.0" IssueInstant="2017-07-20T10:45:39Z"> <saml:NameID xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"> @NOT_USED@ </saml:NameID> <samlp:SessionIndex>ST-2-HtrBiWrgRD9DFgL25GI9-passport.edu</samlp:SessionIndex> </samlp:LogoutRequest>
報文是CAS的協議格式,表示如今發的是logout請求,包含了該service的ST。
至此,CAS服務器遍歷了全部的sercie,給service發出了退出登陸的報文。而後它本身註銷刪除了TGT。
=========+=======我是分割線,下面是應用服務器端分析=======================
應用服務器經過一個監聽器和一個過濾器完成登出功能。
<listener> <listener-class>org.jasig.cas.client.session.SingleSignOutHttpSessionListener </listener-class> </listener> <filter> <filter-name>CAS Single Sign Out Filter</filter-name> <filter-class>org.jasig.cas.client.session.SingleSignOutFilter</filter-class> <init-param> <param-name>casServerUrlPrefix</param-name> <param-value>http://passport.edu:18080</param-value> </init-param> </filter> <filter-mapping> <filter-name>CAS Single Sign Out Filter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>
先看一下 SingleSignOutFilter 的doFilter。
public void doFilter(final ServletRequest servletRequest, final ServletResponse servletResponse, final FilterChain filterChain) throws IOException, ServletException { final HttpServletRequest request = (HttpServletRequest) servletRequest; final HttpServletResponse response = (HttpServletResponse) servletResponse; if (!this.handlerInitialized.getAndSet(true)) { HANDLER.init(); } // 由HANDLER處理 if (HANDLER.process(request, response)) { filterChain.doFilter(servletRequest, servletResponse); } }
HANDLE的實現類是SingleSignOutHandler。看一下它的process方法
public boolean process(final HttpServletRequest request, final HttpServletResponse response) { if (isTokenRequest(request)) { logger.trace("Received a token request"); recordSession(request); return true; } else if (isBackChannelLogoutRequest(request)) { //這裏這裏。。。 logger.trace("Received a back channel logout request"); destroySession(request); return false; } else if (isFrontChannelLogoutRequest(request)) { logger.trace("Received a front channel logout request"); destroySession(request); // redirection url to the CAS server final String redirectionUrl = computeRedirectionToServer(request); if (redirectionUrl != null) { CommonUtils.sendRedirect(response, redirectionUrl); } return false; } else { logger.trace("Ignoring URI for logout: {}", request.getRequestURI()); return true; } }
process方法將會解析報文,獲取該報文是什麼類型的,前面已經分析過是請求登出報文,咱們進入isBackChannelLogoutRequest(request)分支。這裏調用了destroySession(request)。
private void destroySession(final HttpServletRequest request) { final String logoutMessage; if (isFrontChannelLogoutRequest(request)) { // 不要理睬,這裏前臺登出才作的事 logoutMessage = uncompressLogoutMessage(CommonUtils.safeGetParameter(request, this.frontLogoutParameterName)); } else { // 獲取報文的內容 logoutMessage = CommonUtils.safeGetParameter(request, this.logoutParameterName, this.safeParameters); } // 獲取ST final String token = XmlUtils.getTextForElement(logoutMessage, "SessionIndex"); if (CommonUtils.isNotBlank(token)) { // 緩存中刪除ST與sessionId的映射關係,獲取session final HttpSession session = this.sessionMappingStorage.removeSessionByMappingId(token); if (session != null) { final String sessionID = session.getId(); try { session.invalidate(); //銷燬session } catch (final IllegalStateException e) { logger.debug("Error invalidating session.", e); } this.logoutStrategy.logout(request); //好像用於強制退出 } } }
因爲前面是向每一個已經在CAS登陸的應用服務器發送登出報文的,因此每一個應用服務器都會走一次銷燬ticket的流程。至此,應用服務器也銷燬了ticket,而且session也已經銷燬了。