案例SSO支持负载均衡引发的A5单点登录实现问题
Posted 华宇研发
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了案例SSO支持负载均衡引发的A5单点登录实现问题相关的知识,希望对你有一定的参考价值。
SSO 支持负载均衡引发的 A5 单点登录实现问题
在将单点登录 ticket 的缓存存储从 jboss 存储改为 redis 后,在程序运行一段时间后,业务系统就无法登录了,页面报如下错误:
[ERROR] redis.clients.jedis.exceptions.JedisConnectionException: Could not get a resource from the pool
查看代码,发现每一次获取 redis 连接后,都会返回给连接池,问题应该不是出在代码实现。于是去服务器上找 redis 的日志,发现日志中有如下错误:
39220:M 31 Jul 10:27:16.662 # Can't save in background: fork: Cannot allocate memory
上网大概查了一下,应该是 redis 之前申请的内存不够用了,redis 向操作系统申请内存,操作系统因为剩余内存不够了,不给 redis。
于是查了一下操作系统剩余内存,发现是不多了。在和赵帅商量后决定将现在服务器上的部分服务移到到新申请的服务器上,这样就能多腾出 2g
的空间。
但是在腾出空间后,还是出现了之前的问题,通过 redis 工具发现,在出现问题后 redis 占用了 2.5g
的内存空间,此时存在 redis 中有 8900+ 个 ticket。what?啥?8900+ 个 ticket 占用这么大空间,平均下来一个 ticket
占了有 294KB
,这也太夸张了吧。自行测试一遍,序列化一个 ticket grant ticket
为 byte
后,也就 2700
左右的字节,一个 service ticket
也就 2800
左右的字节数。而且性能测试那边的数据是 20000+
数量的 ticket
占用的内存在 100M
左右,这数据完全对不上!
解决办法一:在 reids 中给 ticket 设置生存时间(ttl)
既然用了 redis ,能不能将 service ticket
的 ttl 调的比较短,将 ticket grant ticket
的时间调的长一点呢?因为 service ticket
只在登录的时候进行一次校验就行了。于是就在往 redis 中存的时候,设置了 service ticket
的超时时间为 5s
, ticket grant ticket
的超时时间为 1800s
。
但是这么做了就能解决问题吗?是不行的。在这样改动之后,uim 在上传照片的时候立马就出现问题了,在上传照片的时候会报错,但是报错之后刷新页面后立马再次点击上传就能成功,为什么呢?
这和我们之前的 sso-client
的实现方式有关,之前的 sso-client
为了保证能单点登出,在成功登录之后,会将单点登录认证成功后回传的 service ticket
存起来,以后每次一访问被保护的资源都会调用单点登录的 validate
接口查看 service ticket
是否过期,这样就保证了单点登出。而我上面的解决办法正好在单点服务端将 service ticket
给杀掉了,所以客户端向单点验证 service ticket
失败了,客户端就重定向到了单点的登录页面,但是登录页面此时 cookie 中的 CASTGC 还在,单点服务端判断 ticket grant ticket
还活着,认为登录了,于是又生成了 service ticket
给客户端,客户端又重新登录了,这个时候(5s之内)点击长传文件就没有问题了。
这是老的 sso-client
为了保证单点登出所以这么做,其实单点服务在登出的时候会回调所有使用同一个 ticket grant ticket
的服务(不过在负载均衡的情况下不好使)。
而且,在 Cas 5.x.x
版本后,官方提供了关于 ticket
的 redis 缓存实现示例,其中也说希望使用 redis 进行 ticket
缓存的时候,应该保证 ticket
的存活时间够长,让 Cas
的内部清理机制进行 ticket
的清理,所以,这种做法对我们来说行不通,官方也不推荐,所有就按照官方的解决办法来解决吧。
解决办法二:使用 CAS
的 DefaultTicketRegistryCleaner
既然不能使用 redis 的 ttl 来进行清理的话,那就只能实现 TicketRegistry
中的 getTickets
方法,让 CAS
的 DefaultTicketRegistryCleaner
进行票据的清理了(jboss 实现了这个接口,但是在用 redis 实现的时候,我觉得 redis 既然有 ttl,就没有必要多一个定时任务来干这个清理工作了)。于是一顿改,实现了接口,然后悄悄的给应用平台环境的 sso 换成负载均衡的。
第二天早上来上班发现 redis 中的 ticket
数量正常,占用内存为 1m
。情况好像很好,于是开开心心的开会去了,中午开完会回来一看。 ticket
数量为 4000+
左右,正常范围,很开心,然后看一眼内存,内存竟然占用了 1.2g
!what?what?为什么又这么多,和性能测试数据不吻合啊。而且这样的话 DefaultTicketRegistryCleaner
是无法正常工作的啊,负载之后一个单点登录就分配了 512m
的内存空间,这没法将这么多的 ticket
放进内存啊。于是查看一下日志,发现日志中真有 OutOfMemory
的错误。
到了这里,肯定是序列化后的 ticket
存在问题,看样子像是 ticket
变大了,为什么会变大呢?于是写个小程序将 redis
中的所有 ticket
都读出来,发现了下面这样的情况:
CAS
中的 ticket grant ticket
的默认实现 TicketGrantingTicketImpl
中有如下一个属性:
public final class TicketGrantingTicketImpl extends AbstractTicket implements
TicketGrantingTicket {
//.....
@Lob
@Column(name="SERVICES_GRANTED_ACCESS_TO", nullable=false)
private final HashMap<String,Service> services = new HashMap<String, Service>();
//.....
}
这个属性是用来干嘛的呢?这个属性记录了所有通过这个 ticket grant ticket
登录的服务,在其销毁的时候(即单点登出)会通过这里的信息回调对应的服务,实现单点登出。然后我们在看上图,上图中对应有2218个服务使用了这个 ticket grant ticket
!!
其实可怕的不是这个 ticket grant ticket
的大小,可怕的是每一个 service ticket
会有一个属性记录生成自己的 ticket grant ticket
的引用。
因为这不是一个规范的 JavaBean
,无法序列化成json,在实现的时候序列化是通过 Serializable
进行序列化的,这就是内存在使用一段时间后持续增长的原因。
导致 redis 中 ticket 无限增长的原因
在 artery5
的示例工程中的 artery-login.xml
文件中,有这样一个配置:
<bean id="casFilter" class="org.apache.shiro.cas.CasFilter">
<property name="failureUrl" value="/login"/>
</bean>
这个 casFilter
做了什么事情呢?看 UIMRealm
中能知道他会去单点服务验证单点服务给我们传递的 ticket
。这个中有一个这样的方法:
public class UIMRealm extends CasRealm {
//...
protected IUser getUser(Map<String, Object> attributes) {
User user = new User();
user.setName((String) attributes.get("name"));
user.setId((String) attributes.get("guid"));
user.setLoginId((String) attributes.get("uid"));
user.setDeptId((String) attributes.get("dept"));
user.setCorpId((String) attributes.get("fy"));
return user;
}
}
这个方法是 artery5
中的默认是实现,是将单点服务在调用 http://172.16.32.39/sjzljc/cas
是报文中携带的一些信息拿出来 Map<String, Object>attributes
来构建一个 User
。在项目中,单点回调携带的信息显然是不够用的,一般而言集成的业务系统会继承这个类重写这个方法,使用 IUser user =ArteryOrganUtil.getUserById(id);
的方法来获取自己缓存中的用户。
最终导致了 redis 中无用的 ticket
越来越多,继而导致内存不够用。
最终解决办法
建议所有使用 artery5
的项目将登录失败的页面修改为提示页面(无需登录)。提示用户因为什么登录失败了。
<!-- loginFailure.html 页面需要自行实现 -->
<bean id="casFilter" class="org.apache.shiro.cas.CasFilter">
<property name="failureUrl" value="/loginFailure.html"/>
</bean>
<!-- 同时,还需要在 shiroFilter 这个 bean 的 filterChainDefinitions 属性中加入一下信息-->
<bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean">
<!-- ..... -->
<property name="filterChainDefinitions">
<value>
......
/loginFailure.html = anon
......
</value>
</property>
<!-- ..... -->
</bean>
如果因为数据不一致导致无法登录能重定向到一个页面,那么如果当用户无法从 uim 中获取权限时,或者说用户没有任何权限时,都跳转到同一个页面呢?告诉用户无权限访问系统资源。是可以得,修改一下就行了。
修改 artery-mvc.xml
文件
<!-- 注释原有的 exceptionResolver -->
<!--<beans:bean id="exceptionResolver" class="com.thunisoft.artery.support.exception.ArteryExceptionResolver">-->
<!--</beans:bean>-->
<!-- 使用 spring 自带的 -->
<beans:bean id="exceptionResolver" class="org.springframework.web.servlet.handler.SimpleMappingExceptionResolver">
<beans:property name="exceptionMappings">
<beans:props>
<!-- 访问未授权资,下面中的 403 页面需要自行实现 -->
<beans:prop key="org.apache.shiro.authz.UnauthorizedException">/403</beans:prop>
<!-- artery 处理其余异常 -->
<beans:prop key="java.lang.Throwable">/artery/pub/jsp/error</beans:prop>
</beans:props>
</beans:property>
<!-- ArteryExceptionResolver 中关于使用的 ArteryConstants.ERROR_KEY,用来兼容 artery。 -->
<beans:property name="exceptionAttribute" value="_error_"/>
</beans:bean>
修改了配置如何使用呢?在写业务代码中,我们可以这样使用。 UnauthorizedException
继承自 RuntimeException
public class BusinessCode {
public void business {
// right judge
if (hasNoRight) {
throw new UnauthorizedException("您无权访问此系统资源!");
}
// business
}
}
最后,建议所有集成 artery5 的系统都进行修改,避免类似的情况发生,如果生产环境真存在组织机构数据不一致的情况,会对单点服务造成很大的压力,有可能导致单点瘫痪!
以上是关于案例SSO支持负载均衡引发的A5单点登录实现问题的主要内容,如果未能解决你的问题,请参考以下文章