前期准备
cas版本4.1.11
cas client
http://www.eric.cas.client.com:8081/cas/index.do
cas server
http://www.eric.cas.server.com:8080/
单点登录已经成功 如下图
单点登出 执行logout-webflow.xml流程
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to Apereo under one or more contributor license
agreements. See the NOTICE file distributed with this work
for additional information regarding copyright ownership.
Apereo licenses this file to you under the Apache License,
Version 2.0 (the "License"); you may not use this file
except in compliance with the License. You may obtain a
copy of the License at the following location:
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<flow xmlns="http://www.springframework.org/schema/webflow"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/webflow http://www.springframework.org/schema/webflow/spring-webflow.xsd">
<action-state id="terminateSession">
<evaluate expression="terminateSessionAction.terminate(flowRequestContext)" />
<transition to="doLogout" />
</action-state>
<action-state id="doLogout">
<evaluate expression="logoutAction" />
<transition on="finish" to="finishLogout" />
<transition on="front" to="frontLogout" />
</action-state>
<action-state id="frontLogout">
<evaluate expression="frontChannelLogoutAction" />
<transition on="finish" to="finishLogout" />
<transition on="redirectApp" to="redirectToFrontApp" />
</action-state>
<view-state id="redirectToFrontApp" view="externalRedirect:#{currentEvent.attributes.logoutUrl}&RelayState=#{flowExecutionContext.key}">
<transition on="next" to="frontLogout" />
</view-state>
<decision-state id="finishLogout">
<if test="flowScope.logoutRedirectUrl != null" then="redirectView" else="logoutView" />
</decision-state>
<end-state id="redirectView" view="externalRedirect:#{flowScope.logoutRedirectUrl}" />
<end-state id="logoutView" view="casLogoutView" />
</flow>
执行TerminateSessionAction的terminate方法
<action-state id="terminateSession">
<evaluate expression="terminateSessionAction.terminate(flowRequestContext)" />
<transition to="doLogout" />
</action-state>
/**
* Terminates the CAS SSO session by destroying the TGT (if any) and removing cookies related to the SSO session.
*
* @param context Request context.
*
* @return "success"
*/
public Event terminate(final RequestContext context) {
// in login's webflow : we can get the value from context as it has already been stored
String tgtId = WebUtils.getTicketGrantingTicketId(context);
// for logout, we need to get the cookie's value
if (tgtId == null) {
final HttpServletRequest request = WebUtils.getHttpServletRequest(context);
// 从cookie中获取tgtId
tgtId = this.ticketGrantingTicketCookieGenerator.retrieveCookieValue(request);
}
if (tgtId != null) {
// 构造LogoutRequest 并放到FlowScope作用域中
WebUtils.putLogoutRequests(context, this.centralAuthenticationService.destroyTicketGrantingTicket(tgtId));
}
final HttpServletResponse response = WebUtils.getHttpServletResponse(context);
this.ticketGrantingTicketCookieGenerator.removeCookie(response);
this.warnCookieGenerator.removeCookie(response);
return this.eventFactorySupport.success(this);
}
该方法从cookie中获取tgtId,然后根据tgtId处理业务逻辑。
其中WebUtils的putLogoutRequests源码如下
/**
* Put logout requests into flow scope.
*
* @param context the context
* @param requests the requests
*/
public static void putLogoutRequests(final RequestContext context, final List<LogoutRequest> requests) {
context.getFlowScope().put("logoutRequests", requests);
}
putLogoutRequests只是把构造好的LogoutRequest放到FlowScope作用域
其中使用以下方法销毁服务端TGT
this.centralAuthenticationService.destroyTicketGrantingTicket(tgtId)
该方法实际调用实现类CentralAuthenticationServiceImpl的destroyTicketGrantingTicket方法
/**
* {@inheritDoc}
* Destroy a TicketGrantingTicket and perform back channel logout. This has the effect of invalidating any
* Ticket that was derived from the TicketGrantingTicket being destroyed. May throw an
* {@link IllegalArgumentException} if the TicketGrantingTicket ID is null.
*
* @param ticketGrantingTicketId the id of the ticket we want to destroy
* @return the logout requests.
*/
@Audit(
action = "TICKET_GRANTING_TICKET_DESTROYED",
actionResolverName = "DESTROY_TICKET_GRANTING_TICKET_RESOLVER",
resourceResolverName = "DESTROY_TICKET_GRANTING_TICKET_RESOURCE_RESOLVER")
@Timed(name = "DESTROY_TICKET_GRANTING_TICKET_TIMER")
@Metered(name = "DESTROY_TICKET_GRANTING_TICKET_METER")
@Counted(name = "DESTROY_TICKET_GRANTING_TICKET_COUNTER", monotonic = true)
@Override
public List<LogoutRequest> destroyTicketGrantingTicket(@NotNull final String ticketGrantingTicketId) {
try {
logger.debug("Removing ticket [{}] from registry...", ticketGrantingTicketId);
// 根据tgtId查询内存或者缓存中是否存在有效的TGT票据
final TicketGrantingTicket ticket = getTicket(ticketGrantingTicketId, TicketGrantingTicket.class);
logger.debug("Ticket found. Processing logout requests and then deleting the ticket...");
// 构造客户端登出请求
final List<LogoutRequest> logoutRequests = logoutManager.performLogout(ticket);
this.ticketRegistry.deleteTicket(ticketGrantingTicketId);
return logoutRequests;
} catch (final InvalidTicketException e) {
logger.debug("TicketGrantingTicket [{}] cannot be found in the ticket registry.", ticketGrantingTicketId);
}
return Collections.emptyList();
}
从源码来看destroyTicketGrantingTicket方法主要完成了两件事情
- 删除cas server存储的TGT
- 通知cas client删除登录session
继续debug,看看cas server具体是怎么通知cas client处理登出消息的,构造客户端登出请求的方法
logoutManager.performLogout(ticket);
该方法实际执行实现类LogoutManagerImpl的performLogout方法 源码如下
@Override
public List<LogoutRequest> performLogout(final TicketGrantingTicket ticket) {
// 获取登录的service信息 一个TGT包含多个service
final Map<String, Service> services = ticket.getServices();
final List<LogoutRequest> logoutRequests = new ArrayList<>();
// if SLO is not disabled
if (!this.singleLogoutCallbacksDisabled) {
// through all services
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);
}
}
}
}
return logoutRequests;
}
该方法首先获取单点登录的cas client信息 如下
获取到具体的service之后,我们需要根据service来构建具体的客户端登出请求,具体代码如下
/**
* Handle logout for slo service.
*
* @param singleLogoutService the service
* @param ticketId the ticket id
* @return the logout request
*/
private LogoutRequest handleLogoutForSloService(final SingleLogoutService singleLogoutService, final String ticketId) {
if (!singleLogoutService.isLoggedOutAlready()) {
final RegisteredService registeredService = servicesManager.findServiceBy(singleLogoutService);
if (serviceSupportsSingleLogout(registeredService)) {
// 生成logoutUrl
final URL logoutUrl = determineLogoutUrl(registeredService, singleLogoutService);
final DefaultLogoutRequest logoutRequest = new DefaultLogoutRequest(ticketId, singleLogoutService, logoutUrl);
// 判断登出类型 默认为BACK_CHANNEL
final LogoutType type = registeredService.getLogoutType() == null
? LogoutType.BACK_CHANNEL : registeredService.getLogoutType();
switch (type) {
case BACK_CHANNEL:
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;
}
以下是debug时的信息
由于默认登出类型是BACK_CHANNEL,则跳转到代码performBackChannelLogout(logoutRequest)
/**
* Log out of a service through back channel.
*
* @param request the logout request.
* @return if the logout has been performed.
*/
private boolean performBackChannelLogout(final LogoutRequest request) {
try {
final String logoutRequest = this.logoutMessageBuilder.create(request);
final SingleLogoutService logoutService = request.getService();
logoutService.setLoggedOutAlready(true);
LOGGER.debug("Sending logout request for: [{}]", logoutService.getId());
// 生成客户端登出http消息
final LogoutHttpMessage msg = new LogoutHttpMessage(request.getLogoutUrl(), logoutRequest);
LOGGER.debug("Prepared logout message to send is [{}]", msg);
// 发送登出消息到客户端
return this.httpClient.sendMessageToEndPoint(msg);
} catch (final Exception e) {
LOGGER.error(e.getMessage(), e);
}
return false;
}
具体debug信息图如下
客户端登出消息请求发出成功之后,跳转回CentralAuthenticationServiceImpl类的destroyTicketGrantingTicket方法
/**
* {@inheritDoc}
* Destroy a TicketGrantingTicket and perform back channel logout. This has the effect of invalidating any
* Ticket that was derived from the TicketGrantingTicket being destroyed. May throw an
* {@link IllegalArgumentException} if the TicketGrantingTicket ID is null.
*
* @param ticketGrantingTicketId the id of the ticket we want to destroy
* @return the logout requests.
*/
@Audit(
action = "TICKET_GRANTING_TICKET_DESTROYED",
actionResolverName = "DESTROY_TICKET_GRANTING_TICKET_RESOLVER",
resourceResolverName = "DESTROY_TICKET_GRANTING_TICKET_RESOURCE_RESOLVER")
@Timed(name = "DESTROY_TICKET_GRANTING_TICKET_TIMER")
@Metered(name = "DESTROY_TICKET_GRANTING_TICKET_METER")
@Counted(name = "DESTROY_TICKET_GRANTING_TICKET_COUNTER", monotonic = true)
@Override
public List<LogoutRequest> destroyTicketGrantingTicket(@NotNull final String ticketGrantingTicketId) {
try {
logger.debug("Removing ticket [{}] from registry...", ticketGrantingTicketId);
final TicketGrantingTicket ticket = getTicket(ticketGrantingTicketId, TicketGrantingTicket.class);
logger.debug("Ticket found. Processing logout requests and then deleting the ticket...");
final List<LogoutRequest> logoutRequests = logoutManager.performLogout(ticket);
this.ticketRegistry.deleteTicket(ticketGrantingTicketId);
return logoutRequests;
} catch (final InvalidTicketException e) {
logger.debug("TicketGrantingTicket [{}] cannot be found in the ticket registry.", ticketGrantingTicketId);
}
return Collections.emptyList();
}
执行删除TGT操作
this.ticketRegistry.deleteTicket(ticketGrantingTicketId);
将发出的登出请求信息返回给TerminateSessionAction的terminate的方法,然后执行删除TGC等cookie操作,具体代码如下
// 删除TGC cookie 和 warnCookie
final HttpServletResponse response = WebUtils.getHttpServletResponse(context);
this.ticketGrantingTicketCookieGenerator.removeCookie(response);
this.warnCookieGenerator.removeCookie(response);
return this.eventFactorySupport.success(this);
设置事件状态为成功 直接跳转到logout-webflow的下一个流程
<action-state id="doLogout">
<evaluate expression="logoutAction" />
<transition on="finish" to="finishLogout" />
<transition on="front" to="frontLogout" />
</action-state>
执行logoutAction的doInternalExecute方法
@Override
protected Event doInternalExecute(final HttpServletRequest request, final HttpServletResponse response,
final RequestContext context) throws Exception {
boolean needFrontSlo = false;
putLogoutIndex(context, 0);
final List<LogoutRequest> logoutRequests = WebUtils.getLogoutRequests(context);
if (logoutRequests != null) {
for (final LogoutRequest logoutRequest : logoutRequests) {
// if some logout request must still be attempted
if (logoutRequest.getStatus() == LogoutRequestStatus.NOT_ATTEMPTED) {
needFrontSlo = true;
break;
}
}
}
final String service = request.getParameter("service");
if (this.followServiceRedirects && service != null) {
final Service webAppService = new SimpleWebApplicationServiceImpl(service);
final RegisteredService rService = this.servicesManager.findServiceBy(webAppService);
if (rService != null && rService.getAccessStrategy().isServiceAccessAllowed()) {
context.getFlowScope().put("logoutRedirectUrl", service);
}
}
// there are some front services to logout, perform front SLO
if (needFrontSlo) {
return new Event(this, FRONT_EVENT);
} else {
// otherwise, finish the logout process
return new Event(this, FINISH_EVENT);
}
}
此时debug信息如下
该方法主要是判断前面给cas客户端发出的登出消息请求是否成功,同时判断是否需要登出重定向到指定的地址,由于没有设置登出后需要重定向的地址,则service变量为null,直接返回FINISH_EVENT状态
<action-state id="doLogout">
<evaluate expression="logoutAction" />
<transition on="finish" to="finishLogout" />
<transition on="front" to="frontLogout" />
</action-state>
<action-state id="frontLogout">
<evaluate expression="frontChannelLogoutAction" />
<transition on="finish" to="finishLogout" />
<transition on="redirectApp" to="redirectToFrontApp" />
</action-state>
跳转到finishLogout
<decision-state id="finishLogout">
<if test="flowScope.logoutRedirectUrl != null" then="redirectView" else="logoutView" />
</decision-state>
logoutRedirectUrl为null,不重定向,跳转到logoutView
<end-state id="logoutView" view="casLogoutView" />
单点登出成功,返回登出成功页面
上面漏掉了cas server发送LogoutRequest请求到cas client,cas client处理登出请求的过程
cas client处理登出请求的过程
请求到达cas client,首先被过滤器SingleSignOutFilter拦截,执行doFilter方法
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest)servletRequest;
HttpServletResponse response = (HttpServletResponse)servletResponse;
if(!this.handlerInitialized.getAndSet(true)) {
HANDLER.init();
}
// 登出请求处理
if(HANDLER.process(request, response)) {
filterChain.doFilter(servletRequest, servletResponse);
}
}
执行SingleSignOutHandler的process方法
public boolean process(HttpServletRequest request, HttpServletResponse response) {
if(this.isTokenRequest(request)) {
this.logger.trace("Received a token request");
this.recordSession(request);
return true;
} else if(this.isBackChannelLogoutRequest(request)) {
this.logger.trace("Received a back channel logout request");
this.destroySession(request);
return false;
} else if(this.isFrontChannelLogoutRequest(request)) {
this.logger.trace("Received a front channel logout request");
this.destroySession(request);
String redirectionUrl = this.computeRedirectionToServer(request);
if(redirectionUrl != null) {
CommonUtils.sendRedirect(response, redirectionUrl);
}
return false;
} else {
this.logger.trace("Ignoring URI for logout: {}", request.getRequestURI());
return true;
}
}
由于是采用BackChannel的方式登出的请求,则直接销毁客户端保存的session
private void destroySession(HttpServletRequest request) {
String logoutMessage;
if(this.isFrontChannelLogoutRequest(request)) {
logoutMessage = this.uncompressLogoutMessage(CommonUtils.safeGetParameter(request, this.frontLogoutParameterName));
} else {
logoutMessage = CommonUtils.safeGetParameter(request, this.logoutParameterName, this.safeParameters);
}
this.logger.trace("Logout request:\n{}", logoutMessage);
// 登录时验证的ST票据信息
String token = XmlUtils.getTextForElement(logoutMessage, "SessionIndex");
if(CommonUtils.isNotBlank(token)) {
// 根据ST查询session 并删除本地缓存的session 存储类HashMapBackedSessionMappingStorage
HttpSession session = this.sessionMappingStorage.removeSessionByMappingId(token);
if(session != null) {
String sessionID = session.getId();
this.logger.debug("Invalidating session [{}] for token [{}]", sessionID, token);
try {
//清除当前session的所有相关信息
session.invalidate();
} catch (IllegalStateException var7) {
this.logger.debug("Error invalidating session.", var7);
}
this.logoutStrategy.logout(request);
}
}
}
此时debug信息
可以看到cas server端发送的logoutMessage信息,解析logoutMessage信息,可以获取到登录时验证的ST票据信息,根据ST就可以删除客户端保存的session
删除session成功 返回false 不执行下一个filter cas client登出请求处理结束
if(HANDLER.process(request, response)) {
filterChain.doFilter(servletRequest, servletResponse);
}
至此,cas单点登出整个流程完整结束