cas单点登录学习及源码分析

1,500 阅读3分钟

「这是我参与2022首次更文挑战的第20天,活动详情查看:2022首次更文挑战」。

CAS

1. 简介

cas 是一套单点登录的具体实现,耶鲁大学开发, 目前最高版本为6.x (6.x使用gradle编译) 此处使用5.x版本(maven编译)

分为两部分,服务端和客户端

服务端就是统一的认证中心,客户端就是所有需要接入单点登录的系统

2.服务端

2.1 下载

cas不建议直接在源码上修改,提供了一个模板项目,在模板项目上做扩展

2.2 部署

项目拉下来之后,导入idea进行编译打包

或者直接使用

mvn clean package

打成war包 部署到tomcat下

启动Tomcat cas服务端就部署好了

3.客户端

3.1简单接入
  1. 增加依赖

    1.  <dependencies>
           <dependency>
               <groupId>org.springframework.boot</groupId>
               <artifactId>spring-boot-starter-web</artifactId>
           </dependency>
         
           <dependency>
               <groupId>org.springframework.boot</groupId>
               <artifactId>spring-boot-test</artifactId>
           </dependency>
         
           <dependency>
               <groupId>net.unicon.cas</groupId>
               <artifactId>cas-client-autoconfig-support</artifactId>
           </dependency>
      </dependencies>
      
  2. 配置过滤器

    1. package com.zhangyao.cas.config;
      
      import org.apache.tomcat.util.net.openssl.ciphers.Authentication;
      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.boot.web.servlet.FilterRegistrationBean;
      import org.springframework.boot.web.servlet.ServletListenerRegistrationBean;
      import org.springframework.context.annotation.Bean;
      import org.springframework.context.annotation.Configuration;
      
      import java.util.ArrayList;
      import java.util.HashMap;
      import java.util.List;
      
      /**
       * @author: zhangyao
       * @create:2020-05-08 22:52
       **/
      @Configuration
      public class CasConfig {
      
          @Bean
          public SingleSignOutHttpSessionListener singleSignOutHttpSessionListener(){
              return  new SingleSignOutHttpSessionListener();
          }
      
          /**
           * 监听退出
           * @return
           */
          @Bean
          public ServletListenerRegistrationBean<SingleSignOutHttpSessionListener> singleSignOutHttpSessionListenerServletListenerRegistrationBean(){
              ServletListenerRegistrationBean<SingleSignOutHttpSessionListener> listenerServletListenerRegistrationBean = new ServletListenerRegistrationBean<>();
              listenerServletListenerRegistrationBean.setEnabled(true);
              listenerServletListenerRegistrationBean.setListener(singleSignOutHttpSessionListener());
              listenerServletListenerRegistrationBean.setOrder(1);
              return listenerServletListenerRegistrationBean;
          }
      
          /**
           * 登出拦截器
           * @return
           */
          @Bean
          public FilterRegistrationBean<SingleSignOutFilter> singleSignOutFilterBean(){
              FilterRegistrationBean<SingleSignOutFilter> filterRegistrationBean = new FilterRegistrationBean();
              filterRegistrationBean.setFilter(new SingleSignOutFilter());
              filterRegistrationBean.setEnabled(true);
              filterRegistrationBean.addUrlPatterns("/*");
              filterRegistrationBean.setOrder(1);
              HashMap<String,String> map = new HashMap<>();
              map.put("casServerUrlPrefix","http://127.0.0.1:8085/cas_overlay_template_war/");
              filterRegistrationBean.setInitParameters(map);
              return filterRegistrationBean;
          }
      
          /**
           * 授权
           * @return
           */
          @Bean
          public FilterRegistrationBean filterRegistrationBean(){
              FilterRegistrationBean registrationBean = new FilterRegistrationBean();
              registrationBean.setFilter(new AuthenticationFilter());
      
              registrationBean.addUrlPatterns("/*");
              HashMap<String,String> map = new HashMap<>();
              map.put("casServerLoginUrl","http://127.0.0.1:8085/cas_overlay_template_war/login");
              map.put("casServerUrlPrefix","http://127.0.0.1:8085/cas_overlay_template_war/");
              map.put("serverName", "http://127.0.0.1:8086");
              map.put("ignorePattern", "/index");
              registrationBean.setInitParameters(map);
      
              registrationBean.setOrder(2);
              return registrationBean;
          }
      
          /**
           * 验证票据
           * @return
           */
          @Bean
          public FilterRegistrationBean validationFilter(){
              FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean();
              filterRegistrationBean.setFilter(new Cas30ProxyReceivingTicketValidationFilter());
              HashMap<String,String> map = new HashMap<>();
              map.put("casServerUrlPrefix","http://127.0.0.1:8085/cas_overlay_template_war/");
              map.put("serverName", "http://127.0.0.1:8086");
              map.put("ignorePattern", "/cas_overlay_template_war/*,/index");
              filterRegistrationBean.setInitParameters(map);
              filterRegistrationBean.setOrder(1);
              filterRegistrationBean.addUrlPatterns("/*");
              return filterRegistrationBean;
          }
      
          // 取用户信息
          @Bean
          public FilterRegistrationBean casHttpServletRequestWrapperFilter() {
              FilterRegistrationBean authenticationFilter = new FilterRegistrationBean();
              authenticationFilter.setFilter(new HttpServletRequestWrapperFilter());
              authenticationFilter.setOrder(1);
              List<String> urlPatterns = new ArrayList<String>();
              urlPatterns.add("/*");// 设置匹配的url
              authenticationFilter.setUrlPatterns(urlPatterns);
              return authenticationFilter;
          }
      
          // 取用户信息
          @Bean
          public FilterRegistrationBean casAssertionThreadLocalFilter() {
              FilterRegistrationBean authenticationFilter = new FilterRegistrationBean();
              authenticationFilter.setFilter(new AssertionThreadLocalFilter());
              authenticationFilter.setOrder(1);
              List<String> urlPatterns = new ArrayList<String>();
              urlPatterns.add("/*");// 设置匹配的url
              authenticationFilter.setUrlPatterns(urlPatterns);
              return authenticationFilter;
          }
      
      }
      
      
  3. 开启注解

    @SpringBootApplication
    @EnableCasClient
    public class TestApplication {
    
        public static void main(String[] args) {
            SpringApplication.run(TestApplication.class, args);
        }
    }
    

4.单点登出

​ 实现的效果是: 每一个客户端登出的时候,cas服务端也需要登出,从而达到一端登出,多端登出的效果

​ 实现方法: 客户端发送登出请求时,后台跳转cas服务端登出路径

package com.zhangyao.cas.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;

import javax.servlet.http.HttpSession;

/**
 * @author: zhangyao
 * @create:2020-05-08 22:58
 **/
@Controller
@RequestMapping("")
public class LoginController {

    @Autowired
    RestTemplate restTemplate;

    @GetMapping("/loginOut")
    public String test(HttpSession session){
        session.invalidate();
//        String forObject = restTemplate.getForObject("http://127.0.0.1:8085/cas_overlay_template_war/logout", String.class);
        return "redirect:http://127.0.0.1:8085/cas_overlay_template_war/logout?service=http://127.0.0.1:8086/index";
    }


}

5.cas服务端overlays 打包方式

上文中说到下载后直接mvn clean package即可打包部署,但是当我们想要对服务端进行一些扩展,比如修改默认登录页,修改登录的验证方式等

有两种方法:

  1. 在package后的war包中直接修改对应的文件,部署后可直接生效 缺点是每次重新打包修改的文件都会被覆盖
  2. 使用cas官方提供的cas_overlay_template项目,也就是我们上文中下载的项目,使用maven 的overlay方式进行合并打包

具体分析overlays

pom.xml中的配置

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-war-plugin</artifactId>
    <version>2.6</version>
    <configuration>
        <warName>cas</warName>
        <failOnMissingWebXml>false</failOnMissingWebXml>
        <recompressZippedFiles>false</recompressZippedFiles>
        <archive>
            <compress>false</compress>
            <manifestFile>${manifestFileToUse}</manifestFile>
        </archive>
        <overlays>
            <overlay>
                <groupId>org.apereo.cas</groupId>
                <artifactId>cas-server-webapp${app.server}</artifactId>
            </overlay>
        </overlays>
    </configuration>
</plugin>

这里的overlay配置的意思是使用我们主项目中的同名同路径文件覆盖cas-server-webapp${app.server}下的文件

image-20200512093750382

具体操作方式如上图

在根项目下新建 src/main/resources作为资源目录

src/main/java作为根目录

官网中也有介绍

image-20200512093919720

修改配置文件时从war包中沾出来再修改 比如修改cas服务端支持http访问

image-20200512095229110

复制出这两个文件

并修改HTTPSandIMAPS-10000001.json

{
  "@class" : "org.apereo.cas.services.RegexRegisteredService",
  "serviceId" : "^(http|https|imaps)://.*", #增加http
  "name" : "HTTPS and IMAPS",
  "id" : 10000001,
  "description" : "This service definition authorizes all application urls that support HTTPS and IMAPS protocols.",
  "evaluationOrder" : 10000
}

修改application.properties

在最后加上

#支持http访问
cas.tgc.secure=false
cas.serviceRegistry.initFromJson=true

#支持退出自定义跳转页面
cas.logout.followServiceRedirects=true
cas.logout.redirectParameter=service
cas.logout.confirmLogout=false

6.源码原理分析

cas官网流程图

img

6.1客户端流程图解析:

ST:service-ticket cas服务端签发的票据,用于浏览器访问不同系统时携带,cas服务端使用此票据验证是否有效用户

TGC:全局session的key,存放于浏览器的cookie中,访问cas服务端时使用

TGT:全局session 存放于cas服务端中

  1. 第一次访问第一个客户端,客户端校验请求中是否含有ST(service_ticket,全局session标识),没有此标识就返回302重定向请求(重定向到cas服务端)给浏览器
  2. 浏览器访问cas服务端,服务端校验是否有TGC,如果没有,返回登录页给浏览器
  3. 浏览器展示cas服务端登录页,登录后发送post登录请求给cas服务端,服务端校验登录通过后,创建全局session TGT并签发ST,浏览器写入TGC 并返回302重定向请求(之前访问第一个客户端的路径)给浏览器
  4. 浏览器携带ST请求再次请求第一个客户端,客户端再次校验是否含有ST,并发送请求到cas服务端验证ST是否有效,如果有效,就创建局部session;至此浏览器与第一个客户端建立session会话
  5. 再次访问第一个客户端,这个时候客户端已经存在session会话,会直接验证会话的有效性,不再跳转登录页
  6. 此时浏览器再次访问第二个客户端,由于此时第二个客户端还没有与浏览器建立局部会话,所以会将请求重定向至cas服务端,但是浏览器访问cas服务端时会携带TGC,cas服务端校验通过后签发ST再302返回至浏览器,浏览器再次访问第二个客户端建立连接
6.2客户端源码分析:
6.2.1 授权过滤器

​ 验证是否授权的过滤器,也即上文中配置的授权过滤器

org.jasig.cas.client.authentication.AuthenticationFilter

进入doFilter

public final void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest)servletRequest;
        HttpServletResponse response = (HttpServletResponse)servletResponse;
    	//判断是否是不拦截请求,如果是不拦截请求直接放行
        if (this.isRequestUrlExcluded(request)) {
            this.logger.debug("Request is ignored.");
            filterChain.doFilter(request, response);
        } else {
            //判断是否与浏览器建立有会话,如果有直接放行
            HttpSession session = request.getSession(false);
            Assertion assertion = session != null ? (Assertion)session.getAttribute("_const_cas_assertion_") : null;
            if (assertion != null) {
                filterChain.doFilter(request, response);
            } else {
                //如果既不是不拦截请求,也没有session,就需要重定向至cas服务端登录
                String serviceUrl = this.constructServiceUrl(request, response);
                String ticket = this.retrieveTicketFromRequest(request);
                boolean wasGatewayed = this.gateway && this.gatewayStorage.hasGatewayedAlready(request, serviceUrl);
                if (!CommonUtils.isNotBlank(ticket) && !wasGatewayed) {
                    this.logger.debug("no ticket and no assertion found");
                    String modifiedServiceUrl;
                    if (this.gateway) {
                        this.logger.debug("setting gateway attribute in session");
                        modifiedServiceUrl = this.gatewayStorage.storeGatewayInformation(request, serviceUrl);
                    } else {
                        modifiedServiceUrl = serviceUrl;
                    }

                    this.logger.debug("Constructed service url: {}", modifiedServiceUrl);
                    String urlToRedirectTo = CommonUtils.constructRedirectUrl(this.casServerLoginUrl, this.getProtocol().getServiceParameterName(), modifiedServiceUrl, this.renew, this.gateway);
                    this.logger.debug("redirecting to \"{}\"", urlToRedirectTo);
                    //此处重定向至登录请求
                    this.authenticationRedirectStrategy.redirect(request, response, urlToRedirectTo);
                } else {
                    filterChain.doFilter(request, response);
                }
            }
        }
    }
6.2.2 验证ticket过滤器

org.jasig.cas.client.validation.AbstractTicketValidationFilter

进入doFilter

public final void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
    	//preFilter 执行过滤器前可以进行一些操作 源码中直接返回true了
        if (this.preFilter(servletRequest, servletResponse, filterChain)) {
            HttpServletRequest request = (HttpServletRequest)servletRequest;
            HttpServletResponse response = (HttpServletResponse)servletResponse;
            //获取ticket
            String ticket = this.retrieveTicketFromRequest(request);
            if (CommonUtils.isNotBlank(ticket)) {
                this.logger.debug("Attempting to validate ticket: {}", ticket);

                try {
                    //发送请求到cas服务端验证ticket是否有效
                    Assertion assertion = this.ticketValidator.validate(ticket, this.constructServiceUrl(request, response));
                    this.logger.debug("Successfully authenticated user: {}", assertion.getPrincipal().getName());
                    //验证通过后设置属性
                    request.setAttribute("_const_cas_assertion_", assertion);
                    //如果是已经登录过有会话,更新session中的属性
                    if (this.useSession) {
                        request.getSession().setAttribute("_const_cas_assertion_", assertion);
                    }
	
                    //验证成功进行一些操作,源码中为空
                    this.onSuccessfulValidation(request, response, assertion);
                    //redirectAfterValidation 源码中定义为true
                    if (this.redirectAfterValidation) {
                        this.logger.debug("Redirecting after successful ticket validation.");
                        //重定向至之前的访问请求
                        response.sendRedirect(this.constructServiceUrl(request, response));
                        return;
                    }
                } catch (TicketValidationException var8) {//如果有异常 则返回错误403
                    this.logger.debug(var8.getMessage(), var8);
                    this.onFailedValidation(request, response);
                    if (this.exceptionOnValidationFailure) {
                        throw new ServletException(var8);
                    }

                    response.sendError(403, var8.getMessage());
                    return;
                }
            }

            filterChain.doFilter(request, response);
        }
    }
6.2.3 获取用户信息过滤器

org.jasig.cas.client.util.HttpServletRequestWrapperFilter

 public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
     //从session/request中获取Principal用户信息
     AttributePrincipal principal = this.retrievePrincipalFromSessionOrRequest(servletRequest);
     //CasHttpServletRequestWrapper是此类中定义的内部类,继承了HttpServletRequestWrapperFilter 增加了获取用户的方法 之后获取用户信息可以直接通过 request.getUserPrincipal()获取用户信息
     filterChain.doFilter(new HttpServletRequestWrapperFilter.CasHttpServletRequestWrapper((HttpServletRequest)servletRequest, principal), servletResponse);
 }
6.3 服务端源码分析

7.自定义操作

7.1 数据库查询用户登录
  1. 修改application.properties,增加如下配置

    #查询用户密码的sql
    cas.authn.jdbc.query[0].sql=SELECT * FROM cof_user WHERE name=?
    #数据库连接
    cas.authn.jdbc.query[0].url=jdbc:mysql://122.51.97.53:3306/coframe?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&useSSL=false
    #数据库用户
    cas.authn.jdbc.query[0].user=root
    #数据库密码
    cas.authn.jdbc.query[0].password=tlqaz1234
    #数据库驱动
    cas.authn.jdbc.query[0].driverClass=com.mysql.jdbc.Driver
    #标识sql查出来的哪个字段是password
    cas.authn.jdbc.query[0].fieldPassword=PASSWORD
    
  2. pom.xml增加jar包

    <dependency>
        <groupId>org.apereo.cas</groupId>
        <artifactId>cas-server-support-jdbc</artifactId>
        <version>${cas.version}</version>
    </dependency>
    <dependency>
        <groupId>org.apereo.cas</groupId>
        <artifactId>cas-server-support-jdbc-drivers</artifactId>
        <version>${cas.version}</version>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>6.0.6</version>
    </dependency>
    <dependency>
        <groupId>org.apereo.cas</groupId>
        <artifactId>cas-server-support-jdbc-authentication</artifactId>
        <version>5.2.6</version>
        <type>pom</type>
    </dependency>
    
    7.2 自定义加密类
    #自定义加密类
    cas.authn.jdbc.query[0].passwordEncoder.type=com.zhangyao.cas.CustomPasswordEncoder
    

    定义加密类 并实现import org.springframework.security.crypto.password.PasswordEncoder

    此处我采用的是 org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder spring security默认的加密类