cas+shiro单点登出的坑

情景

cas server:cas
client server: C1
client server: C2

当用户在C1和C2都登录之后,获取到改用户在两个系统内各自需要的权限之后,在C1做登出操作,按照网上大部分的配置方法(web.xml中增加SingleSignOutFilter和SingleSignOutHttpSessionListener),可以在效果上看起来是登出了,但是并没有完全登出。

即:
C1和C2的JSESSIONID对应在服务器的session被销毁,浏览器两个JSESSIONID失效(看起来登出了)
cas的cookie(TGT)失效
C1服务器上,对应的用户权限清除(C1是完全退出了)
C2服务器上,对应的用户权限没有清除(没完全退出)

原理分析

Browser->C1: logout request(1) C1->Browser: C1 subject.logout(), redirect to cas (2) Browser->cas: cas logout path(3) cas->C2: notify C2 the user had logout(4)

1,2,3都很正常,问题出在第四步。

第四步仅仅是被SingleSignOutFilter拦截,根据service-ticket销毁掉改用户对应的session,而并没有调用shiro的subject.logout, 显然,subject.logout是做了销毁权限缓存等操作的

这样就会导致最终C2上的用户权限没有被清除,若在此时用户权限被修改,就会导致即使登出,C2上的权限也没有刷新

解决方案

方案一

权限缓存是可以设置过期时间的,那么简单点,只要给权限缓存加上过期时间即可,这样如果权限被修改,即使用户不登出,在过期之后,权限也会被刷新

方案二

http://howiefh.github.io/2015/05/19/shiro-cas-single-sign-on/ 有一个很详细的说明,但是没仔细看,简单的说就是使用ServletContainerSessionManager,即shiro自己的session管理,似乎可以解决问题,但是未验证

方案三

思路很简单,重写SingleSignOutFilter, 在登出的时候,调用subject.logout 即可。

奈何太年轻,这种方案有很多坑

坑一

问题:

Subject是由session中存放的一个key生成的,但是时序图中第四步是有cas发起的请求,而不是用户浏览器,即这个session中没有Subject信息,shiro无法获取到具体信息。

解决:
SingleSignOutFilter中有存储一份 service-ticket与session的映射关系,那么只要在第四步中 利用 service-ticket取到session,再从session中取到SimplePrincipalCollection信息放入subject即可

坑二

问题:

subject不提供设置principal接口,service-ticket session映射关系未提供get接口

解决:
反射搞定,但是总觉得不靠谱呢。。

坑三

问题:

SingleSignOutFilter是在ShiroFilter chain之前,也就是说,如果重写SingleSignOutFilter,在里边连一个不包含Principal的Subject都获取不到,但是如果把这个SimplePrincipalCollection放到 shrioFilter之后,登录的时候又会有问题
这是一个鸡生蛋和蛋生鸡的问题啊。。。

解决:
问题总是能解决的,放在前边后边都不行,那么放一起吧。对,把SingleSignOutFilter放到ShiroFilter之中, 原以为ShiroFilter会对符合过滤规则的做一个filter chain,结果并不是。

shiro会针对配置的filter规则,取第一个匹配的作为最终的filter,而后边符合规则的就会被忽略掉

所以这里,要把SingleSignOutFilter和Shiro自己提供的CasFilter合并起来,放在一起作为一个filter

方案三代码

经过这么一折腾,于是就有了下面的代码了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
public void doFilterInternal(final ServletRequest servletRequest, final ServletResponse servletResponse, final FilterChain filterChain) throws IOException, ServletException {
final HttpServletRequest request = (HttpServletRequest) servletRequest;

if (handler.isTokenRequest(request)) {
handler.recordSession(request); // 登录,记录session SingleSignOutFilter做的事情
super.doFilterInternal(servletRequest, servletResponse, filterChain); // 记录完了之后,就调用CasFilter自己的doFilterInternal
return;
} else if (handler.isLogoutRequest(request)) { // 如果是登出

// 一堆的代码,就是为了获取SimplePrincipalCollection,设置到Subject里边去,并在最后调用subject.logout()
final String logoutMessage = CommonUtils.safeGetParameter(request, "logoutRequest");
final String token = XmlUtils.getTextForElement(logoutMessage, "SessionIndex");
if (CommonUtils.isNotBlank(token)) {
HttpSession session = null;
try {
Field msField = handler.getSessionMappingStorage().getClass().getDeclaredField("MANAGED_SESSIONS");
msField.setAccessible(true);
Map<String,HttpSession> MANAGED_SESSIONS = (Map)msField.get(handler.getSessionMappingStorage());
session = MANAGED_SESSIONS.get(token);
} catch (Exception e) {
}

if (session != null) {
Subject subject = getSubject(servletRequest, servletResponse);
ShiroUser shiroUser = (ShiroUser)(((SimplePrincipalCollection)(session.getAttribute("org.apache.shiro.subject.support.DefaultSubjectContext_PRINCIPALS_SESSION_KEY"))).getPrimaryPrincipal());
SimplePrincipalCollection pc = new SimplePrincipalCollection(shiroUser, shiroUser.getName());
try {
Field principalsField = subject.getClass().getSuperclass().getDeclaredField("principals");
principalsField.setAccessible(true);
principalsField.set(subject, pc);
} catch (Exception e) {
}


try {
subject.logout();
} catch (SessionException ise) {
}

}
}

// logout之后,还要销毁session SingleSignOutFilter做的事情
handler.destroySession(request);
return;
} else {
log.trace("Ignoring URI " + request.getRequestURI());
}

filterChain.doFilter(servletRequest, servletResponse);
}

代码逻辑很简单,主要是要找到这么个解决方案,得一点点的调试和摸索,也是蛮有意思。
另外web.xml中的SingleSignOutFilter需要去掉,因为我们已经移到Shiro里边了,但是Listener需要保留,并且需要自己重写(里边有调用SingleSignOutFilter的方法,需要改掉), 代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<bean id="casFilter" class="com.simpletour.sso.shiro.STSingleSignOutFilter">
<property name="failureUrl" value="${sso.cas.client}${sso.cas.client.home}"/>
</bean>

<bean id="shiroFilter" class="com.simpletour.sso.shiro.STShiroFilterFactoryBean" init-method="init">
<property name="securityManager" ref="securityManager" />
<property name="loginUrl" value="${sso.cas.server}?service=${sso.cas.client}/cas/login" />
<property name="successUrl" value="${sso.cas.client.home}" />
<property name="filters">
<map>
<entry key="cas" value-ref="casFilter"/>
<entry key="logout" value-ref="logoutFilter"/>
</map>
</property>
<property name="filterChainDefinitions">
<value>
/static/** = anon
/config_* = anon
/cas/* = cas <!--这里,cas/login, cas/logout 都走我们刚刚写的filter-->
/logout = logout
/** = user
</value>
</property>
</bean>

最后

准备找个时间写个cas的faq,毕竟在开发过程中,遇到的很多常见问题,很是烦躁。

文章目录
  1. 1. 情景
  2. 2. 原理分析
  3. 3. 解决方案
    1. 3.0.1. 方案一
    2. 3.0.2. 方案二
    3. 3.0.3. 方案三
      1. 3.0.3.1. 坑一
      2. 3.0.3.2. 坑二
      3. 3.0.3.3. 坑三
    4. 3.0.4. 方案三代码
  • 4. 最后
  • ,