我们在项目中使用了cas作为单点登录的解决方案,当在集成shiro做统一权限控制的时候,发现单点退出登录有坑,所以啃了一下CAS的单点登出的源码,在此分享一下。
1、回顾单点登录中一些关键事件
在解析CAS单点登出的原理之前,我们先回顾一下在单点登录过程中,CAS服务器和CAS客户端都做了一些什么事,这些事在后面解析单点登出时有助于理解。
一般情况下,在项目中使用cas client提供的几个过滤器实现WEB APP的单点登录、退出功能,配置如下:
org.jasig.cas.client.session.SingleSignOutHttpSessionListener
CAS Single Sign Out Filter
org.jasig.cas.client.session.SingleSignOutFilter
casServerUrlPrefix
http://passport.edu:18080
CAS Single Sign Out Filter
/*
CAS Authentication Filter
org.jasig.cas.client.authentication.AuthenticationFilter
casServerLoginUrl
http://passport.edu:18080/login
serverName
http://jd.edu:9443
CAS Authentication Filter
/groupon/*
CAS Validation Filter
org.jasig.cas.client.validation.Cas30ProxyReceivingTicketValidationFilter
casServerUrlPrefix
http://passport.edu:18080
serverName
http://jd.edu:9443
redirectAfterValidation
true
CAS Validation Filter
/*
CAS HttpServletRequest Wrapper Filter
org.jasig.cas.client.util.HttpServletRequestWrapperFilter
CAS HttpServletRequest Wrapper Filter
/*
(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建立映射关系,该映射关系将会在单点登出的时候使用。
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()方法。
public List destroyTicketGrantingTicket(@NotNull final String ticketGrantingTicketId) {
try {
// 根据tgt ID从ticketRegistry注册中心中获取TGT
final TicketGrantingTicket ticket = getTicket(ticketGrantingTicketId, TicketGrantingTicket.class);
// 备注(1):由LogoutManager 完成注销
final List 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 performLogout(final TicketGrantingTicket ticket) {
final Map services = ticket.getServices(); // 获取注册在tgt下的service
final List logoutRequests = new ArrayList<>();
if (!this.singleLogoutCallbacksDisabled) {
// 遍历所有的service
for (final Map.Entry 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;
}
报文内容如下:
@NOT_USED@
ST-2-HtrBiWrgRD9DFgL25GI9-passport.edu
报文是CAS的协议格式,表示现在发的是logout请求,包含了该service的ST。
至此,CAS服务器遍历了所有的sercie,给service发出了退出登录的报文。然后它自己注销删除了TGT。
=========+=======我是分割线,下面是应用服务器端分析=======================
应用服务器通过一个监听器和一个过滤器完成登出功能。
org.jasig.cas.client.session.SingleSignOutHttpSessionListener
CAS Single Sign Out Filter
org.jasig.cas.client.session.SingleSignOutFilter
casServerUrlPrefix
http://passport.edu:18080
CAS Single Sign Out Filter
/*
先看一下 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也已经销毁了。