我们在项目中使用了cas作为单点登录的解决方案,当在集成shiro做统一权限控制的时候,发现单点退出登录有坑,所以啃了一下CAS的单点登出的源码,在此分享一下。
1、回顾单点登录中一些关键事件
在解析CAS单点登出的原理之前,我们先回顾一下在单点登录过程中,CAS服务器和CAS客户端都做了一些什么事,这些事在后面解析单点登出时有助于理解。
一般情况下,在项目中使用cas client提供的几个过滤器实现WEB APP的单点登录、退出功能,配置如下:
1 <listener> 2 <listener-class>org.jasig.cas.client.session.SingleSignOutHttpSessionListener 3 </listener-class> 4 </listener> 5 6 <filter> 7 <filter-name>CAS Single Sign Out Filter</filter-name> 8 <filter-class>org.jasig.cas.client.session.SingleSignOutFilter</filter-class> 9 <init-param> 10 <param-name>casServerUrlPrefix</param-name> 11 <param-value>http://passport.edu:18080</param-value> 12 </init-param> 13 </filter> 14 15 <filter-mapping> 16 <filter-name>CAS Single Sign Out Filter</filter-name> 17 <url-pattern>/*</url-pattern> 18 </filter-mapping> 19 20 21 <filter> 22 <filter-name>CAS Authentication Filter</filter-name> 23 <filter-class>org.jasig.cas.client.authentication.AuthenticationFilter</filter-class> 24 <init-param> 25 <param-name>casServerLoginUrl</param-name> 26 <param-value>http://passport.edu:18080/login</param-value> 27 </init-param> 28 <init-param> 29 <param-name>serverName</param-name> 30 <param-value>http://jd.edu:9443</param-value> 31 </init-param> 32 </filter> 33 <filter-mapping> 34 <filter-name>CAS Authentication Filter</filter-name> 35 <url-pattern>/groupon/*</url-pattern> 36 </filter-mapping> 37 38 <filter> 39 <filter-name>CAS Validation Filter</filter-name> 40 <filter-class>org.jasig.cas.client.validation.Cas30ProxyReceivingTicketValidationFilter 41 </filter-class> 42 <init-param> 43 <param-name>casServerUrlPrefix</param-name> 44 <param-value>http://passport.edu:18080</param-value> 45 </init-param> 46 <init-param> 47 <param-name>serverName</param-name> 48 <param-value>http://jd.edu:9443</param-value> 49 </init-param> 50 51 <init-param> 52 <param-name>redirectAfterValidation</param-name> 53 <param-value>true</param-value> 54 </init-param> 55 56 </filter> 57 <filter-mapping> 58 <filter-name>CAS Validation Filter</filter-name> 59 <url-pattern>/*</url-pattern> 60 </filter-mapping> 61 62 <filter> 63 <filter-name>CAS HttpServletRequest Wrapper Filter</filter-name> 64 <filter-class>org.jasig.cas.client.util.HttpServletRequestWrapperFilter</filter-class> 65 </filter> 66 <filter-mapping> 67 <filter-name>CAS HttpServletRequest Wrapper Filter</filter-name> 68 <url-pattern>/*</url-pattern> 69 </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 。
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了。


(2)CAS客户端在SingleSignOutFilter过滤器中,获取CAS服务器返回Service Ticket,将为ST与session建立映射关系,该映射关系将会在单点登出的时候使用。
具体的登录流程,请参考《单点登录CAS登录流程》
2、单点登出的原理
整个注销流程大致可以分为TGT解码和ticket销毁两个步骤。
2.1 TGT解码
整个注销流程起源于浏览器向CAS服务器发起登出请求:http://passport.edu:18080/logout?service=http://jd.edu:9443。
CAS服务接收请求后,获取浏览器的cookie中的tgc信息,对tgc信息进行解密,解密后将获取到tgt的ID,然后由CentralAuthenticationServiceImpl 类的 destroyTicketGrantingTicket()方法注销该TGT。
2.2 ticket销毁
由于CAS服务器和应用服务器都保存了ticket,所以CAS服务器除了自己销毁ticket外,还需要通知应用服务器销毁ticket。下面我们看一下详细流程。
=========+=======我是分割线,下面是CAS服务器端分析=======================
看一下 CentralAuthenticationServiceImpl 类的 destroyTicketGrantingTicket()方法。
1public List<LogoutRequest> destroyTicketGrantingTicket(@NotNull final String ticketGrantingTicketId) { 2 try { 3 // 根据tgt ID从ticketRegistry注册中心中获取TGT 4 final TicketGrantingTicket ticket = getTicket(ticketGrantingTicketId, TicketGrantingTicket.class); 5 6 // 备注(1):由LogoutManager 完成注销 7 final List<LogoutRequest> logoutRequests = logoutManager.performLogout(ticket); 8 9 // 备注(2):注册中心删除该tgt 10 this.ticketRegistry.deleteTicket(ticketGrantingTicketId); 11 12 return logoutRequests; 13 } catch (final InvalidTicketException e) { 14 logger.debug("TicketGrantingTicket [{}] cannot be found in the ticket registry.", ticketGrantingTicketId); 15 } 16 return Collections.emptyList(); 17}
代码中的备注(1)完成客户端的ticket销毁,备注(2)完成CAS服务器的ticket销毁。备注(1)的登出管理器的实现类是 LogoutManagerImpl,看一下它的performLogout方法。
1@Override 2public List<LogoutRequest> performLogout(final TicketGrantingTicket ticket) { 3 final Map<String, Service> services = ticket.getServices(); // 获取注册在tgt下的service 4 final List<LogoutRequest> logoutRequests = new ArrayList<>(); 5 6 if (!this.singleLogoutCallbacksDisabled) { 7 // 遍历所有的service 8 for (final Map.Entry<String, Service> entry : services.entrySet()) { 9 // it's a SingleLogoutService, else ignore 10 final Service service = entry.getValue(); 11 if (service instanceof SingleLogoutService) { 12 // 对service进行登出操作 13 final LogoutRequest logoutRequest = handleLogoutForSloService((SingleLogoutService) service, entry.getKey()); 14 if (logoutRequest != null) { 15 LOGGER.debug("Captured logout request [{}]", logoutRequest); 16 logoutRequests.add(logoutRequest); 17 } 18 } 19 } 20 }
继续看一下handleLogoutForSloService方法
1private LogoutRequest handleLogoutForSloService(final SingleLogoutService singleLogoutService, final String ticketId) { 2 if (!singleLogoutService.isLoggedOutAlready()) { 3 4 // 备注(1):从服务管理器中获取匹配的已注册的服务 5 final RegisteredService registeredService = servicesManager.findServiceBy(singleLogoutService); 6 7 if (serviceSupportsSingleLogout(registeredService)) { 8 9 // 决定使用哪个登出URL,如果registeredService指定了就用它的,不然就用singleLogoutService里的URL 10 // 一般registeredService不会指定 11 final URL logoutUrl = determineLogoutUrl(registeredService, singleLogoutService); 12 13 // 包装登出请求 14 final DefaultLogoutRequest logoutRequest = new DefaultLogoutRequest(ticketId, singleLogoutService, logoutUrl); 15 16 final LogoutType type = registeredService.getLogoutType() == null 17 ? LogoutType.BACK_CHANNEL : registeredService.getLogoutType(); 18 19 switch (type) { 20 21 case BACK_CHANNEL: 22 23 // 通知应用服务器注销ticket 24 if (performBackChannelLogout(logoutRequest)) { 25 logoutRequest.setStatus(LogoutRequestStatus.SUCCESS); 26 } else { 27 logoutRequest.setStatus(LogoutRequestStatus.FAILURE); 28 LOGGER.warn("Logout message not sent to [{}]; Continuing processing...", singleLogoutService.getId()); 29 } 30 break; 31 default: 32 logoutRequest.setStatus(LogoutRequestStatus.NOT_ATTEMPTED); 33 break; 34 } 35 return logoutRequest; 36 } 37 38 } 39 return null; 40}
备注(1)中,servicesManager.findServiceBy( ) 该方法将会遍历在servicesManager注册的服务,并且查看service是否匹配RegisteredService。RegisteredService是什么呢?
RegisteredService是在cas初始化中,加载配置文件后注册在服务管理器中的服务信息,该信息定义了哪些应用服务器可以接入CAS,登出的类型是什么。
大家是否还记得在CAS服务器的搭建时,是不是修改过 HTTPSandIMAPS-10000001.json 的serviceID呢?这个配置文件就是定义了一个RegisteredService。
清单:HTTPSandIMAPS-10000001.json
1{ 2 "@class" : "org.jasig.cas.services.RegexRegisteredService", 3 "serviceId" : "^(https|imaps|http)://.*", 4 "name" : "HTTPS and IMAPS", 5 "id" : 10000001, 6 "description" : "This service definition authorized all application urls that support HTTPS and IMAPS protocols.", 7 "proxyPolicy" : { 8 "@class" : "org.jasig.cas.services.RefuseRegisteredServiceProxyPolicy" 9 }, 10 "evaluationOrder" : 0, 11 "usernameAttributeProvider" : { 12 "@class" : "org.jasig.cas.services.DefaultRegisteredServiceUsernameProvider" 13 }, 14 "logoutType" : "BACK_CHANNEL", 15 "attributeReleasePolicy" : { 16 "@class" : "org.jasig.cas.services.ReturnAllowedAttributeReleasePolicy", 17 "principalAttributesRepository" : { 18 "@class" : "org.jasig.cas.authentication.principal.DefaultPrincipalAttributesRepository" 19 }, 20 "authorizedToReleaseCredentialPassword" : false, 21 "authorizedToReleaseProxyGrantingTicket" : false 22 }, 23 "accessStrategy" : { 24 "@class" : "org.jasig.cas.services.DefaultRegisteredServiceAccessStrategy", 25 "enabled" : true, 26 "ssoEnabled" : true 27 } 28}

这里的RegisteredService实现类是 RegexRegisteredService,它通过正则匹配service的url,模式是HTTPSandIMAPS-10000001.json文件中定义的serviceId。
继续分析它是怎么通知应用服务器销毁ticket的。
1private boolean performBackChannelLogout(final LogoutRequest request) { 2 try { 3 // 构建登出的协议报文 4 final String logoutRequest = this.logoutMessageBuilder.create(request); 5 final SingleLogoutService logoutService = request.getService(); 6 logoutService.setLoggedOutAlready(true); 7 8 // LogoutHttpMessage封装了请求的url和报文,url就是应用服务器的url 9 final LogoutHttpMessage msg = new LogoutHttpMessage(request.getLogoutUrl(), logoutRequest); 10 11 // 调用httpClient,以POST的方式发出报文 12 return this.httpClient.sendMessageToEndPoint(msg); 13 } catch (final Exception e) { 14 LOGGER.error(e.getMessage(), e); 15 } 16 return false; 17}
报文内容如下:
1<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"> 2 <saml:NameID xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"> 3 @NOT_USED@ 4 </saml:NameID> 5 <samlp:SessionIndex>ST-2-HtrBiWrgRD9DFgL25GI9-passport.edu</samlp:SessionIndex> 6</samlp:LogoutRequest>
报文是CAS的协议格式,表示现在发的是logout请求,包含了该service的ST。
至此,CAS服务器遍历了所有的sercie,给service发出了退出登录的报文。然后它自己注销删除了TGT。
=========+=======我是分割线,下面是应用服务器端分析=======================
应用服务器通过一个监听器和一个过滤器完成登出功能。
1 <listener> 2 <listener-class>org.jasig.cas.client.session.SingleSignOutHttpSessionListener 3 </listener-class> 4 </listener> 5 6 <filter> 7 <filter-name>CAS Single Sign Out Filter</filter-name> 8 <filter-class>org.jasig.cas.client.session.SingleSignOutFilter</filter-class> 9 <init-param> 10 <param-name>casServerUrlPrefix</param-name> 11 <param-value>http://passport.edu:18080</param-value> 12 </init-param> 13 </filter> 14 15 <filter-mapping> 16 <filter-name>CAS Single Sign Out Filter</filter-name> 17 <url-pattern>/*</url-pattern> 18 </filter-mapping>
先看一下 SingleSignOutFilter 的doFilter。
1 public void doFilter(final ServletRequest servletRequest, final ServletResponse servletResponse, 2 final FilterChain filterChain) throws IOException, ServletException { 3 final HttpServletRequest request = (HttpServletRequest) servletRequest; 4 final HttpServletResponse response = (HttpServletResponse) servletResponse; 5 6 if (!this.handlerInitialized.getAndSet(true)) { 7 HANDLER.init(); 8 } 9 10 // 由HANDLER处理 11 if (HANDLER.process(request, response)) { 12 filterChain.doFilter(servletRequest, servletResponse); 13 } 14 }
HANDLE的实现类是SingleSignOutHandler。看一下它的process方法
1public boolean process(final HttpServletRequest request, final HttpServletResponse response) { 2 if (isTokenRequest(request)) { 3 logger.trace("Received a token request"); 4 recordSession(request); 5 return true; 6 7 } else if (isBackChannelLogoutRequest(request)) { //这里这里。。。 8 logger.trace("Received a back channel logout request"); 9 destroySession(request); 10 return false; 11 12 } else if (isFrontChannelLogoutRequest(request)) { 13 logger.trace("Received a front channel logout request"); 14 destroySession(request); 15 // redirection url to the CAS server 16 final String redirectionUrl = computeRedirectionToServer(request); 17 if (redirectionUrl != null) { 18 CommonUtils.sendRedirect(response, redirectionUrl); 19 } 20 return false; 21 22 } else { 23 logger.trace("Ignoring URI for logout: {}", request.getRequestURI()); 24 return true; 25 } 26}
process方法将会解析报文,获取该报文是什么类型的,前面已经分析过是请求登出报文,我们进入isBackChannelLogoutRequest(request)分支。这里调用了destroySession(request)。
1private void destroySession(final HttpServletRequest request) { 2 final String logoutMessage; 3 4 if (isFrontChannelLogoutRequest(request)) { 5 // 不要理睬,这里前台登出才做的事 6 logoutMessage = uncompressLogoutMessage(CommonUtils.safeGetParameter(request, 7 this.frontLogoutParameterName)); 8 } else { 9 // 获取报文的内容 10 logoutMessage = CommonUtils.safeGetParameter(request, this.logoutParameterName, this.safeParameters); 11 } 12 13 // 获取ST 14 final String token = XmlUtils.getTextForElement(logoutMessage, "SessionIndex"); 15 16 if (CommonUtils.isNotBlank(token)) { 17 // 缓存中删除ST与sessionId的映射关系,获取session 18 final HttpSession session = this.sessionMappingStorage.removeSessionByMappingId(token); 19 20 if (session != null) { 21 final String sessionID = session.getId(); 22 23 try { 24 session.invalidate(); //销毁session 25 } catch (final IllegalStateException e) { 26 logger.debug("Error invalidating session.", e); 27 } 28 this.logoutStrategy.logout(request); //好像用于强制退出 29 } 30 } 31}
由于前面是向每个已经在CAS登录的应用服务器发送登出报文的,所以每个应用服务器都会走一次销毁ticket的流程。至此,应用服务器也销毁了ticket,并且session也已经销毁了。