CAS 4.1.x 单点登出(退出登录)的原理解析

Wesley13
• 阅读 1457

    我们在项目中使用了cas作为单点登录的解决方案,当在集成shiro做统一权限控制的时候,发现单点退出登录有坑,所以啃了一下CAS的单点登出的源码,在此分享一下。

1、回顾单点登录中一些关键事件

    在解析CAS单点登出的原理之前,我们先回顾一下在单点登录过程中,CAS服务器和CAS客户端都做了一些什么事,这些事在后面解析单点登出时有助于理解。

    一般情况下,在项目中使用cas client提供的几个过滤器实现WEB APP的单点登录、退出功能,配置如下:

    <listener>
        <listener-class>org.jasig.cas.client.session.SingleSignOutHttpSessionListener
        </listener-class>
    </listener>

    <filter>
        <filter-name>CAS Single Sign Out Filter</filter-name>
        <filter-class>org.jasig.cas.client.session.SingleSignOutFilter</filter-class>
        <init-param>
            <param-name>casServerUrlPrefix</param-name>
            <param-value>http://passport.edu:18080</param-value>
        </init-param>
    </filter>

    <filter-mapping>
        <filter-name>CAS Single Sign Out Filter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>


    <filter>
        <filter-name>CAS Authentication Filter</filter-name>
        <filter-class>org.jasig.cas.client.authentication.AuthenticationFilter</filter-class>
        <init-param>
            <param-name>casServerLoginUrl</param-name>
            <param-value>http://passport.edu:18080/login</param-value>
        </init-param>
        <init-param>
            <param-name>serverName</param-name>
            <param-value>http://jd.edu:9443</param-value>
        </init-param>
    </filter>
    <filter-mapping>
        <filter-name>CAS Authentication Filter</filter-name>
        <url-pattern>/groupon/*</url-pattern>
    </filter-mapping>

    <filter>
        <filter-name>CAS Validation Filter</filter-name>
        <filter-class>org.jasig.cas.client.validation.Cas30ProxyReceivingTicketValidationFilter
        </filter-class>
        <init-param>
            <param-name>casServerUrlPrefix</param-name>
            <param-value>http://passport.edu:18080</param-value>
        </init-param>
        <init-param>
            <param-name>serverName</param-name>
            <param-value>http://jd.edu:9443</param-value>
        </init-param>

        <init-param>
            <param-name>redirectAfterValidation</param-name>
            <param-value>true</param-value>
        </init-param>

    </filter>
    <filter-mapping>
        <filter-name>CAS Validation Filter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

    <filter>
        <filter-name>CAS HttpServletRequest Wrapper Filter</filter-name>
        <filter-class>org.jasig.cas.client.util.HttpServletRequestWrapperFilter</filter-class>
    </filter>
    <filter-mapping>
        <filter-name>CAS HttpServletRequest Wrapper Filter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

    (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 4.1.x 单点登出(退出登录)的原理解析

    另外,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了。

CAS 4.1.x 单点登出(退出登录)的原理解析

CAS 4.1.x 单点登出(退出登录)的原理解析

 (2)CAS客户端在SingleSignOutFilter过滤器中,获取CAS服务器返回Service Ticket,将为ST与session建立映射关系,该映射关系将会在单点登出的时候使用。

    具体的登录流程,请参考《单点登录CAS登录流程》

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<LogoutRequest> destroyTicketGrantingTicket(@NotNull final String ticketGrantingTicketId) {
    try {
        // 根据tgt ID从ticketRegistry注册中心中获取TGT
        final TicketGrantingTicket ticket = getTicket(ticketGrantingTicketId, TicketGrantingTicket.class);
        
        // 备注(1):由LogoutManager 完成注销 
        final List<LogoutRequest> 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<LogoutRequest> performLogout(final TicketGrantingTicket ticket) {
    final Map<String, Service> services = ticket.getServices(); // 获取注册在tgt下的service
    final List<LogoutRequest> logoutRequests = new ArrayList<>();
    
    if (!this.singleLogoutCallbacksDisabled) {
        // 遍历所有的service
        for (final Map.Entry<String, Service> 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
  }
}

CAS 4.1.x 单点登出(退出登录)的原理解析

这里的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;
}

报文内容如下:

<samlp:LogoutRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="LR-1-VM1PfgJD6VEDtCc4NnIWaVLqFs0PktY6Ej9" Version="2.0" IssueInstant="2017-07-20T10:45:39Z">
    <saml:NameID xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
        @NOT_USED@
    </saml:NameID>
    <samlp:SessionIndex>ST-2-HtrBiWrgRD9DFgL25GI9-passport.edu</samlp:SessionIndex>
</samlp:LogoutRequest>

报文是CAS的协议格式,表示现在发的是logout请求,包含了该service的ST。

至此,CAS服务器遍历了所有的sercie,给service发出了退出登录的报文。然后它自己注销删除了TGT。

=========+=======我是分割线,下面是应用服务器端分析=======================

应用服务器通过一个监听器和一个过滤器完成登出功能。

    <listener>
        <listener-class>org.jasig.cas.client.session.SingleSignOutHttpSessionListener
        </listener-class>
    </listener>

    <filter>
        <filter-name>CAS Single Sign Out Filter</filter-name>
        <filter-class>org.jasig.cas.client.session.SingleSignOutFilter</filter-class>
        <init-param>
            <param-name>casServerUrlPrefix</param-name>
            <param-value>http://passport.edu:18080</param-value>
        </init-param>
    </filter>

    <filter-mapping>
        <filter-name>CAS Single Sign Out Filter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

先看一下 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也已经销毁了。

点赞
收藏
评论区
推荐文章
【实践篇】基于CAS的单点登录实践之路
上个月我负责的系统SSO升级,对接京东ERP系统,这也让我想起了之前我做过一个单点登录的项目。想来单点登录有很多实现方案,不过最主流的还是基于CAS的方案,所以我也就分享一下我的CAS实践之路。
Stella981 Stella981
2年前
CAS 实现站内单点登录及实现第三方 OAuth、OpenId 登录(一)
一、CAS介绍    CAS是Yale大学发起的一个开源项目,旨在为Web应用系统提供一种可靠的单点登录方法,CAS在2004年12月正式成为JASIG的一个项目。CAS具有以下特点:开源的企业级单点登录解决方案CASServer为需要独立部署的Web应用CASClient支持非
Easter79 Easter79
2年前
SpringMVC对接CAS客户端实现单点登录SSO
业务场景:之前写过CAS服务端的例子,也对接过基于SpringBoot的CAS,不过最近项目要对接第三方的CAS实现单点登录,而我们项目是基于SpringMVC的,所以就摸索了一下对接方案,其它博客可以参考我之前专栏:CAS单点登录系列博客(https://www.oschina.net/action/GoToLink?urlhttps%3A%2F%2
Easter79 Easter79
2年前
SSO单点登录基于CAS架构封装 Memcached 实例
SSO认证中心是CAS整个应用架构的一个极其重要的关键点,必须满足如下两点要求:1.高可用,不允许程序发生故障。如果认证中心发生故障,整个应用群将无法登录,导致所有服务瘫痪。2.高并发,因为所有用户的登录请求都需要经过它处理,其承担的处理量往往是相当巨大的。其中memcached的CAS源码MemCacheTicketRegistry.java类
Stella981 Stella981
2年前
Spring+ Spring cloud + SSO单点登录应用认证
之前的文章中有介绍springcloudsso集成的方案,也做过springjwtredis的解决方案,不同系统的无缝隙集成,统一的sso单点登录界面的管理、每个应用集成的权限认证,白名单等都是我们需要考虑的,现在针对于以上的问题我们做了sso单点登录应用认证平台,设计如下:1\.数据库设计:Java代码!复制代码(http
Wesley13 Wesley13
2年前
CAS单点登录(一):单点登录与CAS理论介绍
一、什么是单点登录(SSO)  单点登录主要用于多系统集成,即在多个系统中,用户只需要到一个中央服务器登录一次即可访问这些系统中的任何一个,无须多次登录。  单点登录(SingleSignOn),简称为SSO,是目前比较流行的企业业务整合的解决方案之一。SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所
Easter79 Easter79
2年前
Springboot+CAS单点登录
一:安装CAS下载cas:https://github.com/apereo/cas(https://www.oschina.net/action/GoToLink?urlhttps%3A%2F%2Fgithub.com%2Fapereo%2Fcas)1.1 将cas并打成war包。放入一个干净的tomcat中,启动tomcat
Easter79 Easter79
2年前
SpringBoot项目集成cas单点登录
添加依赖添加casclient依赖<dependency<groupIdnet.unicon.cas</groupId<artifactIdcasclientautoconfigsupport</artifactId
Stella981 Stella981
2年前
SpringBoot项目集成cas单点登录
添加依赖添加casclient依赖<dependency<groupIdnet.unicon.cas</groupId<artifactIdcasclientautoconfigsupport</artifactId
Stella981 Stella981
2年前
SSO单点登录基于CAS架构封装 Memcached 实例
SSO认证中心是CAS整个应用架构的一个极其重要的关键点,必须满足如下两点要求:1.高可用,不允许程序发生故障。如果认证中心发生故障,整个应用群将无法登录,导致所有服务瘫痪。2.高并发,因为所有用户的登录请求都需要经过它处理,其承担的处理量往往是相当巨大的。其中memcached的CAS源码MemCacheTicketRegistry.java类