cas5.3.9单点登录-总结遇到的坑

cas5.3.9单点登录-总结遇到的坑

  • 前言
  • cas简介
    • 术语解释
      • Ticket Grangting Ticket(TGT)
      • Ticket Granting Cookie(TGC)
      • Service Ticket(ST)
    • 图解
      • TGC与TGT
      • Server与Client交互过程
  • 项目搭建
    • 项目文档
    • 项目二次开发
    • 项目二次开发遇到的问题
      • SSL证书申请
      • cas ticket过期策略
      • cas client单机-单点退出
      • cas client集群

前言

最近因公司需求,将多个应用系统登录模块整合,选用了cas做单点登录。cas server和cas client集成的时候遇到了一些问题,也Google了很多资料还是没有得到解决,最终和老师Dean一起大胆猜测,小心求证,静下心来看了看其中的源码,一起解决了遇到了问题,其中过程很有意思,并且通过这些问题,对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

cas5.3.9单点登录-总结遇到的坑_第1张图片

Server与Client交互过程

cas5.3.9单点登录-总结遇到的坑_第2张图片

项目搭建

项目文档

项目是对 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版本

5.3.9
1.5.18.RELEASE

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证书,果断成功,咱也是有证书的人了哈哈。
cas5.3.9单点登录-总结遇到的坑_第3张图片
cas5.3.9单点登录-总结遇到的坑_第4张图片

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() {
        ServletListenerRegistrationBean 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好像没有生效,我们来看下面的就明白了。
cas5.3.9单点登录-总结遇到的坑_第5张图片
上图的意思是 在有效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 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();
    }

你可能感兴趣的:(cas5.3.9单点登录-总结遇到的坑)