cas5.3.9单点登录-总结遇到的坑
前言
最近因公司需求,将多个应用系统登录模块整合,选用了cas做单点登录。cas server和cas client集成的时候遇到了一些问题,也Google了很多资料还是没有得到解决,最后静下心来看了看其中的源码,解决了遇到了问题,其中过程很有意思,并且通过这些问题,对cas框架更加熟悉了。所以总结了遇到的问题,写下这篇文章跟大家一起分享。
cas简介
单点登录(Single Sign On),简称为SSO,是目前比较流行的企业业务整合的解决方案之一。SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。
CAS(Central Authentication Service),中央认证服务。CAS是一款不错的针对 Web应用的单点登录框架。
术语解释
Ticket Grangting Ticket(TGT)
TGT是CAS为用户签发的登录票据,拥有了TGT,用户就可以证明自己在CAS成功登录过。TGT封装了Cookie值以及此Cookie值对应的用户信息。用户在CAS认证成功后,CAS生成cookie(叫TGC),写入浏览器,同时生成一个TGT对象,放入自己的缓存,TGT对象的ID就是cookie的值。当HTTP再次请求到来时,如果传过来的有CAS生成的cookie,则CAS以此cookie值为key查询缓存中有无TGT,如果有的话,则说明用户之前登录过,如果没有,则用户需要重新登录。
Ticket Granting Cookie(TGC)
存放用户身份认证凭证的cookie,在浏览器和CAS Server间通讯时使用,并且只能基于安全通道传输(Https),是CASServer用来明确用户身份的凭证。
Service Ticket(ST)
服务票据,服务的惟一标识码 , 由 CASServer 发出( Http 传送),用户访问Service时,service发现用户没有ST,则要求用户去CAS获取ST.用户向CAS发出获取ST的请求,CAS发现用户有TGT,则签发一个ST,返回给用户。用户拿着ST去访问service,service拿ST去CAS验证,验证通过后,允许用户访问资源。
图解
TGC与TGT
Server与Client交互过程
项目搭建
项目文档
项目是对 Apereo的cas-overlay-template 开源项目二次开发
因为 cas-overlay-template 对使用Maven构建项目只支持到cas5.3.x,而本项目使用的是cas5.3.9是5.3.x中最后一个版本,最新版cas6.0则是用Gradle构建的项目。因为学习成本,我们选择Maven构建项目。
这里注意:cas5.3.9版本对应的springboot 1.5.18.RELEASE版本
<cas.version>5.3.9</cas.version>
<springboot.version>1.5.18.RELEASE</springboot.version>
Apereo Github地址:https://github.com/apereo/cas-overlay-template/tree/5.3
CAS 配置说明:https://apereo.github.io/cas/5.3.x/installation/Configuration-Properties.html
项目二次开发
这里感谢这位博主整理的CAS单点登录系列博文,为我们这些后来者节省了许多开发时间。
作者:这个名字想了很久-博客传送门
按照这位博主整理的博文,进行大体上的二次开发是没有问题的,下面我会提到开发过程中遇到的问题,进行分析讲解。
项目二次开发遇到的问题
SSL证书申请
哈哈,证书的申请也是一波三折。刚开始申请是阿里云的Symantec SSL证书,结果Google Chrome显示不安全的链接。之后问了度娘才知道始末- Google Chrome正式宣布将不再信任赛门铁克所有SSL证书
后面去申请了腾讯云的TrustAsia(亚洲诚信) SSL证书,果断成功,咱也是有证书的人了哈哈。
cas ticket过期策略
1.org.jasig.cas.ticket.support.HardTimeoutExpirationPolicy
2.org.jasig.cas.ticket.support.NeverExpiresExpirationPolicy
3.org.jasig.cas.ticket.support.RememberMeDelegatingExpirationPolicy
4.org.jasig.cas.ticket.support.ThrottledUseAndTimeoutExpirationPolicy
5.org.jasig.cas.ticket.support.TicketGrantingTicketExpirationPolicy
请参考:https://blog.csdn.net/qq_20745827/article/details/52276576
我们使用第一种方式进行测试,因为默认ticket失效时间是120分钟,我们将其设置成1分钟
cas.ticket.tgt.hardTimeout.timeToKillInSeconds=60
cas client单机-单点退出
在测试的过程中,单点登录基本上没问题,主要是单点登出,单机的client单点注销也基本没问题,主要是cas client集群单点注销有问题。我们先来演练一遍cas client单机后,再看看cas client集群下的问题。
下面是cas client单点退出的起作用的filter,将其应用到cas client当中:
import org.jasig.cas.client.authentication.AuthenticationFilter;
import org.jasig.cas.client.session.SingleSignOutFilter;
import org.jasig.cas.client.session.SingleSignOutHttpSessionListener;
import org.jasig.cas.client.util.AssertionThreadLocalFilter;
import org.jasig.cas.client.util.HttpServletRequestWrapperFilter;
import org.jasig.cas.client.validation.Cas30ProxyReceivingTicketValidationFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.boot.web.servlet.ServletListenerRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class CasConfig {
// 是否启用CAS
private static boolean casEnabled = true;
@Autowired
private SpringCasAutoConfig autoConfig;
/**
*用于实现单点登出功能
*/
@Bean
public ServletListenerRegistrationBean<SingleSignOutHttpSessionListener> singleSignOutHttpSessionListener() {
ServletListenerRegistrationBean<SingleSignOutHttpSessionListener> listener = new ServletListenerRegistrationBean<>();
listener.setEnabled(casEnabled);
listener.setListener(new SingleSignOutHttpSessionListener());
listener.setOrder(1);
return listener;
}
/**
* 该过滤器用于实现单点登出功能,单点退出配置,一定要放在其他filter之前
*/
@SuppressWarnings({ "rawtypes", "unchecked" })
@Bean
public FilterRegistrationBean singleSignOutFilter() {
FilterRegistrationBean filterRegistration = new FilterRegistrationBean();
SingleSignOutFilter filter = new SingleSignOutFilter();
filterRegistration.setFilter(filter);
filterRegistration.setEnabled(casEnabled);
if (autoConfig.getSignOutFilters().size() > 0)
filterRegistration.setUrlPatterns(autoConfig.getSignOutFilters());
else
filterRegistration.addUrlPatterns("/*");
filterRegistration.addInitParameter("casServerUrlPrefix", autoConfig.getCasServerUrlPrefix());
filterRegistration.addInitParameter("serverName", autoConfig.getServerName());
filterRegistration.setOrder(1);
return filterRegistration;
}
/**
* 该过滤器负责用户的认证工作
*/
@SuppressWarnings({ "rawtypes", "unchecked" })
@Bean
public FilterRegistrationBean authenticationFilter() {
FilterRegistrationBean filterRegistration = new FilterRegistrationBean();
filterRegistration.setFilter(new AuthenticationFilter());
filterRegistration.setEnabled(casEnabled);
if (autoConfig.getAuthFilters().size() > 0)
filterRegistration.setUrlPatterns(autoConfig.getAuthFilters());
else
filterRegistration.addUrlPatterns("/*");
// casServerLoginUrl:cas服务的登陆url
filterRegistration.addInitParameter("casServerLoginUrl", autoConfig.getCasServerLoginUrl());
// 本项目登录ip+port
filterRegistration.addInitParameter("serverName", autoConfig.getServerName());
filterRegistration.addInitParameter("useSession", autoConfig.isUseSession() ? "true" : "false");
filterRegistration.addInitParameter("redirectAfterValidation", autoConfig.isRedirectAfterValidation() ? "true" : "false");
filterRegistration.addInitParameter("ignorePattern", autoConfig.getIgnorePattern());
filterRegistration.setOrder(2);
return filterRegistration;
}
/**
* 该过滤器负责对Ticket的校验工作
*/
@SuppressWarnings({ "rawtypes", "unchecked" })
@Bean
public FilterRegistrationBean cas30ProxyReceivingTicketValidationFilter() {
FilterRegistrationBean filterRegistration = new FilterRegistrationBean();
Cas30ProxyReceivingTicketValidationFilter cas30ProxyReceivingTicketValidationFilter = new Cas30ProxyReceivingTicketValidationFilter();
cas30ProxyReceivingTicketValidationFilter.setServerName(autoConfig.getServerName());
filterRegistration.setFilter(cas30ProxyReceivingTicketValidationFilter);
filterRegistration.setEnabled(casEnabled);
if (autoConfig.getValidateFilters().size() > 0)
filterRegistration.setUrlPatterns(autoConfig.getValidateFilters());
else
filterRegistration.addUrlPatterns("/*");
filterRegistration.addInitParameter("casServerUrlPrefix", autoConfig.getCasServerUrlPrefix());
filterRegistration.addInitParameter("serverName", autoConfig.getServerName());
filterRegistration.setOrder(3);
return filterRegistration;
}
/**
* 该过滤器对HttpServletRequest请求包装,
* 可通过HttpServletRequest的getRemoteUser()方法获得登录用户的登录名
*/
@SuppressWarnings({ "rawtypes", "unchecked" })
@Bean
public FilterRegistrationBean httpServletRequestWrapperFilter() {
FilterRegistrationBean filterRegistration = new FilterRegistrationBean();
filterRegistration.setFilter(new HttpServletRequestWrapperFilter());
filterRegistration.setEnabled(true);
if (autoConfig.getRequestWrapperFilters().size() > 0)
filterRegistration.setUrlPatterns(autoConfig.getRequestWrapperFilters());
else
filterRegistration.addUrlPatterns("/*");
filterRegistration.setOrder(4);
return filterRegistration;
}
/**
* 该过滤器使得可以通过org.jasig.cas.client.util.AssertionHolder来获取用户的登录名。
* 比如AssertionHolder.getAssertion().getPrincipal().getName()。
* 这个类把Assertion信息放在ThreadLocal变量中,这样应用程序不在web层也能够获取到当前登录信息
*/
@SuppressWarnings({ "rawtypes", "unchecked" })
@Bean
public FilterRegistrationBean assertionThreadLocalFilter() {
FilterRegistrationBean filterRegistration = new FilterRegistrationBean();
filterRegistration.setFilter(new AssertionThreadLocalFilter());
filterRegistration.setEnabled(true);
if (autoConfig.getAssertionFilters().size() > 0)
filterRegistration.setUrlPatterns(autoConfig.getAssertionFilters());
else
filterRegistration.addUrlPatterns("/*");
filterRegistration.setOrder(5);
return filterRegistration;
}
}
在cas server 和 cas client环境搭好后,我们将ticket失效时间设置成1分钟,接下来进行测试:
1.cas server session设置成2分钟 cas client session设置成2分钟 大于ticket失效时间
正常登陆server,刷新client页面,client不需要再次登陆
30秒的时候,刷新server 和 client页面,都不用登陆
90秒的时候,刷新server 和 client页面,都跳转到登录页
2.cas server session设置成40秒 cas client session设置成40秒 小于ticket失效时间
正常登陆server,刷新client页面,client不需要再次登陆
30秒的时候,刷新server 和 client页面,都不用登陆
50秒的时候,刷新server 和 client页面,都不用登陆
90秒的时候,刷新server 和 client页面,都跳转到登录页
由此可以看出cas的server 和 client 的session好像没有生效,我们来看下面的就明白了。
上图的意思是 在有效ticket时间内 client session超时后会再签发一个ST,返回给用户。用户拿着新ST去访问service,service拿ST去CAS验证,验证通过后,允许用户访问资源。
单机的client单点注销也是OK的,cas server点击登出,cas client也会登出。
那么,上面单机的client ticket超时失效和单点注销的结果就是我们预期的结果。我们 cas client集群也应该要达到这个结果才对,然而在cas client集群下,就出BUG了。接下来看看cas client集群要怎么修改才能达到我们要的结果。
cas client集群
我们在Debug的时候看到单点退出的时候会走到SingleSignOutFilter里面,当中有着SingleSignOutHandler SessionMappingStorage有一个实现类 HashMapBackedSessionMappingStorage,这个类的作用,在于存储 tiket 和 sessionId 的映射。注销的时候,cas 服务器会发来一个 st-tiket,退出过滤器需要根据这个 st-ticket 找到对应的 sessionId 来清除 session 而 HashMapBackedSessionMappingStorage 是存储在 Map 里的,也就是内存里的,而不是 session 里。靠谱的方式应该是把这个映射关系也存在 redis 里。也就是自己实现一个 RedisBackedSessionMappingStorage。
import java.util.concurrent.TimeUnit;
import javax.annotation.Resource;
import javax.servlet.http.HttpSession;
import org.jasig.cas.client.session.SessionMappingStorage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.session.SessionRepository;
public final class RedisBackedSessionMappingStorage implements SessionMappingStorage, InitializingBean {
private final Logger log = LoggerFactory.getLogger(getClass());
private static final int TIMEOUT = 60 * 60 * 1;
private static final String NAMESPACE = "CAS_CLIENT";
private static final String SESSION_KEY_TO_ID_MAPPING = NAMESPACE+"::SESSION_KEY::";
private static final String ID_TO_SESSION_KEY_MAPPING = NAMESPACE+"::MAPPING_ID::";
@Autowired
private SessionRepository sessionRepository;
@Resource(name = "stringRedisTemplate")
private StringRedisTemplate redisTemplate;
private ValueOperations<String, String> opsForValue;
public RedisBackedSessionMappingStorage() {}
@Override
public synchronized void addSessionById(String mappingId, HttpSession session) {
try {
opsForValue.set(SESSION_KEY_TO_ID_MAPPING + session.getId(), mappingId);
redisTemplate.expire(SESSION_KEY_TO_ID_MAPPING + session.getId(), TIMEOUT, TimeUnit.SECONDS);
opsForValue.set(ID_TO_SESSION_KEY_MAPPING + mappingId, session.getId());
redisTemplate.expire(ID_TO_SESSION_KEY_MAPPING + mappingId, TIMEOUT, TimeUnit.SECONDS);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
@Override
public synchronized void removeBySessionById(String sessionId) {
if (log.isDebugEnabled()) {
log.debug("Attempting to remove Session=[" + sessionId + "]");
}
try {
final String mappingId = opsForValue.get(SESSION_KEY_TO_ID_MAPPING + sessionId);
if (log.isDebugEnabled()) {
if (mappingId != null) {
log.debug("Found mapping for session. Session Removed.");
} else {
log.debug("No mapping for session found. Ignoring.");
}
}
if (mappingId != null) {
redisTemplate.delete(SESSION_KEY_TO_ID_MAPPING + sessionId);
redisTemplate.delete(ID_TO_SESSION_KEY_MAPPING + mappingId);
//这个是核心代码
sessionRepository.deleteById(sessionId);
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}
@Override
public synchronized HttpSession removeSessionByMappingId(String mappingId) {
String sessionId = null;
try {
sessionId = opsForValue.get(ID_TO_SESSION_KEY_MAPPING + mappingId);
if (log.isDebugEnabled()) {
if (mappingId != null) {
log.debug("Found mapping for session. Session Removed.");
} else {
log.debug("No mapping for session found. Ignoring.");
}
}
} catch (Exception e) {
throw new RuntimeException(e);
}
if (sessionId != null) {
removeBySessionById(sessionId);
}
return null;
}
@Override
public void afterPropertiesSet() throws Exception {
opsForValue = redisTemplate.opsForValue();
redisTemplate.setKeySerializer(new StringRedisSerializer());
}
}
这里主要通过SessionRepository删除Session
修改singleSignOutFilter
/**
* 该过滤器用于实现单点登出功能,单点退出配置,一定要放在其他filter之前
*/
@SuppressWarnings({ "rawtypes", "unchecked" })
@Bean
public FilterRegistrationBean singleSignOutFilter(RedisBackedSessionMappingStorage sessionMappingStorage) {
FilterRegistrationBean filterRegistration = new FilterRegistrationBean();
SingleSignOutFilter filter = new SingleSignOutFilter();
filter.setSessionMappingStorage(sessionMappingStorage);
filterRegistration.setFilter(filter);
filterRegistration.setEnabled(casEnabled);
if (autoConfig.getSignOutFilters().size() > 0)
filterRegistration.setUrlPatterns(autoConfig.getSignOutFilters());
else
filterRegistration.addUrlPatterns("/*");
filterRegistration.addInitParameter("casServerUrlPrefix", autoConfig.getCasServerUrlPrefix());
filterRegistration.addInitParameter("serverName", autoConfig.getServerName());
filterRegistration.setOrder(1);
return filterRegistration;
}
@Bean
public RedisBackedSessionMappingStorage sessionMappingStorage() {
return new RedisBackedSessionMappingStorage();
}
cas server集群
cas server支持主从模式、哨兵sentinel模式、不支持redis cluster模式
#配置redis存储ticket
cas.ticket.registry.redis.host=192.168.xxx.xxx
cas.ticket.registry.redis.port=6379
cas.ticket.registry.redis.database=5
cas.ticket.registry.redis.password=
cas.ticket.registry.redis.timeout=1000
cas.ticket.registry.redis.useSsl=false
cas.ticket.registry.redis.usePool=true
cas.ticket.registry.redis.pool.max-active=20
cas.ticket.registry.redis.pool.maxIdle=8
cas.ticket.registry.redis.pool.minIdle=0
cas.ticket.registry.redis.pool.maxActive=8
cas.ticket.registry.redis.pool.maxWait=-1
cas.ticket.registry.redis.pool.numTestsPerEvictionRun=0
cas.ticket.registry.redis.pool.softMinEvictableIdleTimeMillis=0
cas.ticket.registry.redis.pool.minEvictableIdleTimeMillis=0
cas.ticket.registry.redis.pool.lifo=true
cas.ticket.registry.redis.pool.fairness=false
cas.ticket.registry.redis.pool.testOnCreate=false
cas.ticket.registry.redis.pool.testOnBorrow=false
cas.ticket.registry.redis.pool.testOnReturn=false
cas.ticket.registry.redis.pool.testWhileIdle=false
#cas.ticket.registry.redis.sentinel.master=mymaster
#cas.ticket.registry.redis.sentinel.nodes[0]=localhost:26377
#cas.ticket.registry.redis.sentinel.nodes[1]=localhost:26378
#cas.ticket.registry.redis.sentinel.nodes[2]=localhost:26379
#配置redis存储session
cas.webflow.autoconfigure=true
cas.webflow.alwaysPauseRedirect=false
cas.webflow.refresh=true
cas.webflow.redirectSameState=false
cas.webflow.session.lockTimeout=30
cas.webflow.session.compress=false
cas.webflow.session.maxConversations=5
cas.webflow.session.storage=true
多台 CAS 服务器就可以共用同一个 TicketRegistry。对于前端 web 服务器 (如 nginx),做好负载均衡配置,将认证请求分布转发给后面多台 CAS,实现负载均衡和容错目的。