java · 2022-10-31 0

搭建CAS,单点登录 CAS 流程

一、cas server

搭建 cas server

1.拉取镜像

docker pull apereo/cas:6.6.13

2.配置文件

在宿主机,创建文件 /data/etc/cas/config/cas.properties

cas.server.name=https://172.17.0.2:8443
cas.server.prefix=${cas.server.name}/cas

logging.config=file:/etc/cas/config/log4j2.xml

cas.service-registry.core.init-from-json=true
cas.service-registry.json.location=file:/etc/cas/services

# 配置允许登出后跳转到指定页面
cas.logout.follow-service-redirects=true

3.配置客户端支持 http

在宿主机,创建文件 /data/etc/cas/services/web-10000001.json

{
  "@class" : "org.apereo.cas.services.RegexRegisteredService",
  "serviceId" : "^(https|imaps|http)://.*",
  "name" : "web",
  "id" : 10000001,
  "evaluationOrder" : 10
}

4.制作 ssl 证书

1) 使用 keytool 创建本地密钥库

keytool -genkey -alias cas.server.com -keyalg RSA -storepass changeit -keystore cas.keystore -dname "CN=cas.server.com, OU=cas, O=cas, L=hangzhou, ST=zhejiang, C=zh"

2) 把密钥库导出成证书文件

keytool -export -alias cas.server.com -keystore cas.keystore -file cas.crt -storepass changeit

查看证书

keytool -printcert -file cas.crt

3) 将创建的证书导入到客户端 java 证书库

keytool -import -keystore "/opt/jdk1.8.0_341/jre/lib/security/cacerts" -file "cas.crt" -alias cas.server.com -storepass changeit

查看 jdk 证书内容

keytool -list -v -keystore /opt/jdk1.8.0_341/jre/lib/security/cacerts -alias cas.server.com -storepass changeit

删除客户端 java 证书

keytool -delete -keystore /opt/jdk1.8.0_341/jre/lib/security/cacerts -alias cas.server.com -storepass changeit

4) 把密钥复制成服务端 thekeystore

docker 容器创建的时候,宿主机的 /data/etc/cas/cas.keystore,映射容器的 /etc/cas/thekeystore

5.启动容器

docker run -itd --name cas_1 -v /data/etc/cas/config/cas.properties:/etc/cas/config/cas.properties -v /data/etc/cas/services:/etc/cas/services -v /data/etc/cas/cas.keystore:/etc/cas/thekeystore apereo/cas:6.6.13

6.配置 hosts

宿主机地址为:172.17.0.1
server 容器地址为:172.17.0.2

配置宿主机 /etc/hosts 文件

172.17.0.2   cas.server.com
127.0.0.1    cas.client1.com
127.0.0.1    cas.client2.com

配置 server 容器 /etc/hosts 文件
(如果不配置,server 无法根据域名找到 client,无法实现单点登出)

172.17.0.1   cas.client1.com
172.17.0.1   cas.client2.com

7.验证

访问地址:https://cas.server.com:8443/cas/login

初始用户名:casuser
初始密码:Mellon

二、cas manage

(可选)

搭建 cas manage

1.拉取镜像

docker pull apereo/cas-management:6.3.1

2.配置文件

在宿主机,创建文件 /data/etc/cas/config/manager.properties

cas.server.name=https://cas.server.com:8443
cas.server.prefix=${cas.server.name}/cas

mgmt.serverName=http://172.17.0.3:8091
mgmt.adminRoles[0]=ROLE_ADMIN
mgmt.userPropertiesFile=file:/etc/cas/config/users.json

server.port=8091
server.ssl.enabled=false

logging.config=file:/etc/cas/config/log4j2-management.xml

3.启动容器

docker run -itd --name cas_manager_1 -v /data/etc/cas/config/management.properties:/etc/cas/config/management.properties apereo/cas-management:6.3.1

4.导入证书

把 cas.crt 复制到 cas manager 容器

docker cp cas.crt cas_manager_1:/root

cas manager 容器,导入证书,信任 https 服务端

keytool -import -keystore "/opt/java/openjdk/lib/security/cacerts" -file "cas.crt" -alias cas.server.com -storepass changeit

配置 cas manager 容器 /etc/hosts

172.17.0.3  localhost
172.17.0.2  cas.server.com

5.验证

访问地址:http://172.17.0.3:8091/cas-management/

三、cas client

1.maven

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.0.5.RELEASE</version>
    <relativePath/>
</parent>

<properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    <java.version>1.8</java.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.jasig.cas.client</groupId>
        <artifactId>cas-client-support-springboot</artifactId>
        <version>3.6.3</version>
    </dependency>

    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>1.18.24</version>
        <scope>provided</scope>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
            <configuration>
                <fork>true</fork>
            </configuration>
        </plugin>
    </plugins>
</build>

2.配置

application.properties 文件

# application.properties
server.port=9003
server.servlet.context-path=/

cas.server-url-prefix=https://cas.server.com:8443/cas
cas.server-login-url=https://cas.server.com:8443/cas/login
cas.client-host-url=http://cas.client1.com:9003
cas.single-logout.enabled=true

3.过滤器

3.1 自动配置

在启动类增加 @EnableCasClient 的注解,实现自动配置过滤器

@SpringBootApplication
@EnableCasClient
public class CasApplication3 {

    public static void main(String[] args) {
        SpringApplication.run(CasApplication3.class, args);
    }
}

认证过滤器,配置忽略的 url

/**
 * cas-client-support-springboot 依赖提供了CAS客户端的自动配置,
 * 当自动配置不满足需要时,可通过实现 {@link CasClientConfigurer} 接口来重写需要自定义的逻辑
 */
@Component
public class CasClientConfigurerImpl implements CasClientConfigurer {

    /**
     * 配置认证过滤器,添加忽略参数,使 /index 和 /logout 免登录
     */
    @Override
    public void configureAuthenticationFilter(final FilterRegistrationBean authenticationFilter) {
        Map<String, String>  initParameters = authenticationFilter.getInitParameters();
        initParameters.put("ignorePattern", "(/index)|(/logout)");
    }
}

3.2 手动配置

// CASProperties.java
@ConfigurationProperties(prefix = "cas")
@Component
@Getter
@Setter
public class CASProperties {

    private String serverUrlPrefix;

    private String serverLoginUrl;

    private String clientHostUrl;
}
// CASConfiguration.java
@Configuration
public class CASConfiguration {

    @Autowired
    private CASProperties casProperties;

    /**
     * 登出过滤器
     * SingleSignOutFilter 会拦截所有请求,而不是仅仅拦截 logout 时的请求
     * 登陆时,SingleSignOutFilter 会记录 token,session
     * 单点登出时,server 请求每个 client,SingleSignOutFilter 会移除 token,session
     */
    @Bean
    public FilterRegistrationBean filterSingleRegistration() {
        FilterRegistrationBean registration = new FilterRegistrationBean();
        registration.setFilter(new SingleSignOutFilter());
        // 设定匹配的路径
        registration.addUrlPatterns("/*");

        Map<String, String> initParameters = new HashMap<>();
        initParameters.put("casServerUrlPrefix", casProperties.getServerUrlPrefix());
        registration.setInitParameters(initParameters);

        // 设定加载的顺序
        registration.setOrder(1);
        return registration;
    }

    /**
     * ticket 验证器
     */
    @Bean
    public FilterRegistrationBean filterValidationRegistration() {
        FilterRegistrationBean registration = new FilterRegistrationBean();
        registration.setFilter(new Cas30ProxyReceivingTicketValidationFilter());
        // 设定匹配的路径
        registration.addUrlPatterns("/*");

        Map<String, String> initParameters = new HashMap<String, String>();
        initParameters.put("casServerUrlPrefix", casProperties.getServerUrlPrefix());
        initParameters.put("serverName", casProperties.getClientHostUrl());
        initParameters.put("useSession", "true");
        registration.setInitParameters(initParameters);

        // 设定加载的顺序
        registration.setOrder(1);
        return registration;
    }

    /**
     * 授权过滤器,用于登录
     */
    @Bean
    public FilterRegistrationBean filterAuthenticationRegistration() {
        FilterRegistrationBean registration = new FilterRegistrationBean();
        registration.setFilter(new AuthenticationFilter());
        // 设定匹配的路径
        registration.addUrlPatterns("/*");

        Map<String, String> initParameters = new HashMap<>();
        initParameters.put("casServerLoginUrl", casProperties.getServerLoginUrl());
        initParameters.put("serverName", casProperties.getClientHostUrl());
        // 忽略 url
        initParameters.put("ignorePattern", "/logout/success");
        initParameters.put("ignoreUrlPatternType", "CONTAINS");
        registration.setInitParameters(initParameters);

        // 设定加载的顺序
        registration.setOrder(1);
        return registration;
    }

    /**
     * wraper 过滤器
     * 负责包装HttpServletRequest,从而可通过HttpServletRequest的getRemoteUser()方法获取登录用户的登录名
     */
    @Bean
    public FilterRegistrationBean filterWrapperRegistration() {
        FilterRegistrationBean registration = new FilterRegistrationBean();
        registration.setFilter(new HttpServletRequestWrapperFilter());
        // 设定匹配的路径
        registration.addUrlPatterns("/*");
        // 设定加载的顺序
        registration.setOrder(1);
        return registration;
    }

    /**
     * 添加监听器
     */
    @Bean
    public ServletListenerRegistrationBean<EventListener> singleSignOutListenerRegistration() {
        ServletListenerRegistrationBean<EventListener> registrationBean = new ServletListenerRegistrationBean<EventListener>();
        registrationBean.setListener(new SingleSignOutHttpSessionListener());
        registrationBean.setOrder(1);
        return registrationBean;
    }
}

4.测试

// CASController.java
@Controller
public class CASController {

    @ResponseBody
    @RequestMapping("/index")
    public String index() {
        return "index";
    }

    @ResponseBody
    @RequestMapping("/getUsers")
    public String getUsers() {
        return "name: client C";
    }

    @RequestMapping("/logout")
    public String logout() {
        // 调用 https://cas.server.com:8443/cas/logout 后,server 会向每个 client,发送请求,
        // SingleSignOutFilter 会注销 session
        // 可跳转 https://cas.server.com:8443/cas/logout
        // 也可跳转指定页 https://cas.server.com:8443/cas/logout?service=http://cas.client1.com:9003/logout/success,
        // 会重定向指定的 service
        return "redirect:https://cas.server.com:8443/cas/logout";
    }
}
// CasApplication3.java
@SpringBootApplication
// @EnableCasClient 启用cas client 自动配置
public class CasApplication3 {

    public static void main(String[] args) {
        SpringApplication.run(CasApplication3.class, args);
    }
}

四、过滤器

  1. CAS Single Sign Out Filter——SingleSignOutFilter
    实现单点登出,放在首个位置;
  2. CAS Validation Filter——Cas30ProxyReceivingTicketValidationFilter
    负责对Ticket的校验
    需要指定服务端地址:casServerUrlPrefix
    需要指定客户端地址:serverName
  3. CAS Authentication Filter——AuthenticationFilter
    负责用户的鉴权
    需要指定服务端登录地址:casServerLoginUrl
    需要指定客户端地址:serverName
  4. CAS HttpServletRequest Wrapper Filter——HttpServletRequestWrapperFilter
    负责包装HttpServletRequest,从而可通过HttpServletRequest的getRemoteUser()方法获取登录用户的登录名

五、流程

  1. 用户访问 http://cas.client1.com:9003,过滤器判断用户是否登录,没有登录,则重定向(302)到 https://cas.server.com:8443
  2. 重定向到 https://cas.server.com:8443,到登陆页面,登陆成功后,https://cas.server.com:8443 将用户登录的信息记录到服务器的 session 中
  3. https://cas.server.com:8443 重定向到 cas.client1.com:9003,并携带凭证,cas.client1.com:9003 拿着凭证去 cas.server.com 验证凭证是否有效,从而判断用户是否登录成功
  4. 登录成功,浏览器与网站之间进行正常的访问

1.访问客户端

1) 访问 http://cas.client1.com:9003/getUsers经过 Cas30ProxyReceivingTicketValidationFilter 过滤器,取出 ticket,若无ticket,进入下个过滤器,即进入 AuthenticationFilter 过滤器。
AuthenticationFilter 过滤器,判断是否有session,若无session,判断是否有 ticket,若无ticket,重定向到 https://cas.server.com:8443/cas/login?service=http://cas.client1.com:9003/getUsers (重定向生成 302 响应码和 Location 响应头,从而通知浏览器客户端重新访问 Location 响应头中指定的URL)

请求头:

GET /getUsers HTTP/1.1
Host: cas.client1.com:9003

响应头:

HTTP/1.1 302
Location: https://cas.server.com:8443/cas/login?service=http%3A%2F%2Fcas.client1.com%3A9003%2FgetUsers
// Cas30ProxyReceivingTicketValidationFilter.java
public final void doFilter(final ServletRequest servletRequest, final ServletResponse servletResponse,
        final FilterChain filterChain) throws IOException, ServletException {

    if (!preFilter(servletRequest, servletResponse, filterChain)) {
        return;
    }

    final HttpServletRequest request = (HttpServletRequest) servletRequest;
    final HttpServletResponse response = (HttpServletResponse) servletResponse;
    final String ticket = retrieveTicketFromRequest(request);

    if (CommonUtils.isNotBlank(ticket)) {
        logger.debug("Attempting to validate ticket: {}", ticket);

        try {
            final Assertion assertion = this.ticketValidator.validate(ticket,
                    constructServiceUrl(request, response));

            logger.debug("Successfully authenticated user: {}", assertion.getPrincipal().getName());

            request.setAttribute(CONST_CAS_ASSERTION, assertion);

            if (this.useSession) {
                request.getSession().setAttribute(CONST_CAS_ASSERTION, assertion);
            }
            onSuccessfulValidation(request, response, assertion);

            if (this.redirectAfterValidation) {
                logger.debug("Redirecting after successful ticket validation.");
                response.sendRedirect(constructServiceUrl(request, response));
                return;
            }
        } catch (final TicketValidationException e) {
            logger.debug(e.getMessage(), e);

            onFailedValidation(request, response);

            if (this.exceptionOnValidationFailure) {
                throw new ServletException(e);
            }

            response.sendError(HttpServletResponse.SC_FORBIDDEN, e.getMessage());

            return;
        }
    }

    filterChain.doFilter(request, response);

}
// AuthenticationFilter.java
public final 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 (isRequestUrlExcluded(request)) {
        logger.debug("Request is ignored.");
        filterChain.doFilter(request, response);
        return;
    }

    final HttpSession session = request.getSession(false);
    final Assertion assertion = session != null ? (Assertion) session.getAttribute(CONST_CAS_ASSERTION) : null;

    if (assertion != null) {
        filterChain.doFilter(request, response);
        return;
    }

    final String serviceUrl = constructServiceUrl(request, response);
    final String ticket = retrieveTicketFromRequest(request);
    final boolean wasGatewayed = this.gateway && this.gatewayStorage.hasGatewayedAlready(request, serviceUrl);

    if (CommonUtils.isNotBlank(ticket) || wasGatewayed) {
        filterChain.doFilter(request, response);
        return;
    }

    final String modifiedServiceUrl;

    logger.debug("no ticket and no assertion found");
    if (this.gateway) {
        logger.debug("setting gateway attribute in session");
        modifiedServiceUrl = this.gatewayStorage.storeGatewayInformation(request, serviceUrl);
    } else {
        modifiedServiceUrl = serviceUrl;
    }

    logger.debug("Constructed service url: {}", modifiedServiceUrl);

    final String urlToRedirectTo = CommonUtils.constructRedirectUrl(this.casServerLoginUrl,
            getProtocol().getServiceParameterName(), modifiedServiceUrl, this.renew, this.gateway);

    logger.debug("redirecting to "{}"", urlToRedirectTo);
    this.authenticationRedirectStrategy.redirect(request, response, urlToRedirectTo);
}

2) 访问 https://cas.server.com:8443/cas/login?service=http://cas.client1.com:9003/getUsers,进行登陆成功后,重新定向 http://cas.client1.com:9003/getUsers?ticket=ST-1-kQdfseIgdkZbIfsMWtFQXhmM-2kzxm-pc,并携带 Set-Cookie

这个 ticket 就是 ST,同时会在Cookie中设置一个 TGC,该 cookie 是网站 cas.server.com 的 cookie ,只有访问这个网站才会携带这个 cookie 过去

Cookie 中的 TGC:向 cookie 中添加该值的目的是当下次访问 cas.server.com 时,浏览器将 Cookie 中的 TGC 携带到服务器,服务器根据这个 TGC,查找与之对应的 TGT。从而判断用户是否登录过了,是否需要展示登录页面。TGT 与 TGC 的关系就像 SESSION 与 Cookie 中 SESSIONID 的关系

  • TGT:Ticket Granted Ticket(俗称大令牌,或者说票根,他可以签发ST)
  • TGC:Ticket Granted Cookie(cookie中的value),存在Cookie中,根据他可以找到TGT
  • ST:Service Ticket (小令牌),是TGT生成的,默认是用一次就生效了。也就是 ticket 值

请求头:

POST /cas/login?service=http%3A%2F%2Fcas.client1.com%3A9003%2FgetUsers HTTP/1.1
Host: cas.server.com:8443
Origin: http://cas.server.com:8443
Referer: http://cas.server.com:8443/cas/login?service=http%3A%2F%2Fcas.client1.com%3A9003%2FgetUsers

响应头:

HTTP/1.1 302
Set-Cookie: TGC=eyJhbGciOiJIUzUxMiJ9.ZXlKNmFYQWlPaUpFUlVZaUxDSmhiR2NpT2lKa2FYSWlMQ0psYm1NaU9pSkJNVEk0UTBKRExVaFRNalUySW4wLi5lYUxnUk5FZ2NrcE5TZDd6STNwTHBnLnRuM2ZQS0tZZnRjM3I1RWdxZkJUbzZqT2xvTlFpQWRTay1OT2JHalhMd3ZsRlYyMk52NGNTd3JKNVhHSW1nSFVlN3NZalNrbDBOSUxmdVZmX0c5dE1wdXpMRG9RNEhua0hiMlBvSDNUU1Qxa2tDTl9tV1l2UWlLejl0REt2Y1N5Qk5RT2s4WTJESkNvY2pzZjRFMWotRDVCQmtxUXlka0VXMDZ5U3NydHMtZEJaWE92NXUyMkZ1UUxFdUhKanVyVGNvckpJLXVZUUFNMkkzWGJtVElleHBNb19wM2ZMY1l3cXFuS29aV3ZHMmsuRDlvWFNGWTBiTFliem5wa0FiQ1ExQQ==.qeJmVbtNlVkbMFoFL1XjFD8o4BY8EtbZym10dAJoVqeHvy4miF9QB6pfurvR0iaBlgOcXTaJfYRhH3sSQM5Ysw; Path=/cas/; HttpOnly
Location: http://cas.client1.com:9003/getUsers?ticket=ST-1-kQdfseIgdkZbIfsMWtFQXhmM-2kzxm-pc

3) 访问 http://cas.client1.com:9003/getUsers?ticket=ST-1-FFtKZrDi1Fs-r4azaIkaXvDHIc4zxm-pc,进入 Cas30ProxyReceivingTicketValidationFilter 过滤器,取出 ticket,使用 Cas30ServiceTicketValidator 验证。
Cas30ServiceTicketValidator 进行 java 请求 https://cas.server.com:8443/cas/p3/serviceValidate?ticket=ST-1-kQdfseIgdkZbIfsMWtFQXhmM-2kzxm-pc&service=http://cas.client1.com:9003/getUsers,返回验证信息,封装到 Assertion,把其放入 request 和 session,后重定向到 http://cas.client1.com:9003/getUsers;jsessionid=518E600320516FA8EA8BC9C603E7198E

请求头:

GET /getUsers?ticket=ST-1-xiFOAQsTeydCG6y67-mxTnPZx6Uzxm-pc HTTP/1.1
Host: cas.client1.com:9003
Referer: https://cas.server.com:8443/

响应头:

HTTP/1.1 302
Set-Cookie: JSESSIONID=518E600320516FA8EA8BC9C603E7198E; Path=/; HttpOnly
Location: http://cas.client1.com:9003/getUsers;jsessionid=518E600320516FA8EA8BC9C603E7198E

4) 访问 http://cas.client1.com:9003/getUsers;jsessionid=518E600320516FA8EA8BC9C603E7198E,进入 Cas30ProxyReceivingTicketValidationFilter 过滤器,取出 ticket,无 ticket,进入 AuthenticationFilter 过滤器,得到 session,从 session 得到 Assertion,若 Assertion 不为空,进入下个过滤器,即 HttpServletRequestWrapperFilter 过滤器

ST 的默认过期策略,st.timeToKillInSeconds=10 当你访问一个应用系统时,cas server 签发了一张票据,你需要在十秒钟之内拿着这种 ST 去 cas server 进行校验,过了10秒钟就过期了。

请求头:

GET /getUsers;jsessionid=518E600320516FA8EA8BC9C603E7198E HTTP/1.1
Cookie: JSESSIONID=518E600320516FA8EA8BC9C603E7198E
Host: cas.client1.com:9003
Referer: https://cas.server.com:8443/

5) 用户第二次访问

因为 http://cas.client1.com:9003 保存了 session,所以验证通过

2.访问其他客户端

1) 用户访问 http://cas.client2.com:9004/getUsers,重定向到 https://cas.server.com:8443/cas/login?service=http://cas.client2.com:9004/getUsers (重定向生成 302 响应码和 Location 响应头,从而通知浏览器客户端重新访问 Location 响应头中指定的URL)

请求头中携带 Cookie

请求头:

GET /getUsers HTTP/1.1
Cookie: JSESSIONID=518E600320516FA8EA8BC9C603E7198E
Host: cas.client2.com:9004

响应头:

HTTP/1.1 302
Location: https://cas.server.com:8443/cas/login?service=http%3A%2F%2Fcas.client2.com%3A9004%2FgetUsers

2) 重定向 https://cas.server.com:8443/cas/login?service=http%3A%2F%2Fcas.client2.com%3A9004%2FgetUsers 后,cas.server.com 进行处理,验证登陆过,不跳转登陆页面,重定向到 http://cas.client2.com:9004/getUsers?ticket=ST-2-dzyqpzz68wmHgbToR5tFtY0W8Oczxm-pc

请求头:

GET /cas/login?service=http%3A%2F%2Fcas.client2.com%3A9004%2FgetUsers HTTP/1.1
Cookie: TGC=eyJhbGciOiJIUzUxMiJ9.ZXlKNmFYQWlPaUpFUlVZaUxDSmhiR2NpT2lKa2FYSWlMQ0psYm1NaU9pSkJNVEk0UTBKRExVaFRNalUySW4wLi5lYUxnUk5FZ2NrcE5TZDd6STNwTHBnLnRuM2ZQS0tZZnRjM3I1RWdxZkJUbzZqT2xvTlFpQWRTay1OT2JHalhMd3ZsRlYyMk52NGNTd3JKNVhHSW1nSFVlN3NZalNrbDBOSUxmdVZmX0c5dE1wdXpMRG9RNEhua0hiMlBvSDNUU1Qxa2tDTl9tV1l2UWlLejl0REt2Y1N5Qk5RT2s4WTJESkNvY2pzZjRFMWotRDVCQmtxUXlka0VXMDZ5U3NydHMtZEJaWE92NXUyMkZ1UUxFdUhKanVyVGNvckpJLXVZUUFNMkkzWGJtVElleHBNb19wM2ZMY1l3cXFuS29aV3ZHMmsuRDlvWFNGWTBiTFliem5wa0FiQ1ExQQ==.qeJmVbtNlVkbMFoFL1XjFD8o4BY8EtbZym10dAJoVqeHvy4miF9QB6pfurvR0iaBlgOcXTaJfYRhH3sSQM5Ysw
Host: cas.server.com:8443

响应头:

HTTP/1.1 302
Location: http://cas.client2.com:9004/getUsers?ticket=ST-2-dzyqpzz68wmHgbToR5tFtY0W8Oczxm-pc

3) 重定向到 http://cas.client2.com:9004/getUsers?ticket=ST-2-dzyqpzz68wmHgbToR5tFtY0W8Oczxm-pc,经过过滤器验证后,重定向到 http://cas.client2.com:9004/getUsers

请求头:

GET /getUsers?ticket=ST-2-dzyqpzz68wmHgbToR5tFtY0W8Oczxm-pc HTTP/1.1
Cookie: JSESSIONID=518E600320516FA8EA8BC9C603E7198E

响应头:

HTTP/1.1 302
Set-Cookie: JSESSIONID=BE8C4E61A1749069899253B36144E496; Path=/; HttpOnly
Location: http://cas.client2.com:9004/getUsers

4) 重定向到 http://cas.client2.com:9004/getUsers,判断 session,成功访问

请求头:

GET /getUsers HTTP/1.1
Cookie: JSESSIONID=BE8C4E61A1749069899253B36144E496

3.登出

// SingleSignOutFilter.java
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;

    /**
     * <p>Workaround for now for the fact that Spring Security will fail since it doesn't call {@link #init(javax.servlet.FilterConfig)}.</p>
     * <p>Ultimately we need to allow deployers to actually inject their fully-initialized {@link org.jasig.cas.client.session.SingleSignOutHandler}.</p>
     */
    if (!this.handlerInitialized.getAndSet(true)) {
        HANDLER.init();
    }

    if (HANDLER.process(request, response)) {
        filterChain.doFilter(servletRequest, servletResponse);
    }
}

登陆操作:cas client 应用把 session 和 ticket 关系保存到 SessionMappingStorage 中。

退出操作:cas server 接收请求后,获取浏览器的 cookie 中的 tgc 信息,对 tgc 信息进行解密,解密后将获取到 TGT 的 ID,注销该 TGT。cas server 除了自己销毁 TGT 外,还需要通知 cas client 销毁 ticket。cas server 发送带 logoutRequest 参数的 http 请求到每一个 cas client。

logoutRequest 的值为:

<samlp:LogoutRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="LR-12-fuDolbm4i66mhG28CYqrw8cd" Version="2.0" IssueInstant="2023-12-15T03:21:24Z">
    <saml:NameID xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">casuser</saml:NameID>
    <samlp:SessionIndex>ST-11-8xVp0mCsxaLnAKWO-yxWGGuZNxw-36c3c2e505d2</samlp:SessionIndex>
</samlp:LogoutRequest>"

其他:若要实现 session 共享,可 cas 通过 redis 覆写可以实现多节点 cas 的集群(都是用 redis 存储 session,不再使用 HashMapBackedSessionMappingStorage 这个内部的 map 存)

// SingleSignOutHandler.java
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;
    }
}

private void recordSession(final HttpServletRequest request) {
    final HttpSession session = request.getSession(this.eagerlyCreateSessions);

    if (session == null) {
        logger.debug("No session currently exists (and none created).  Cannot record session information for single sign out.");
        return;
    }

    final String token = CommonUtils.safeGetParameter(request, this.artifactParameterName, this.safeParameters);
    logger.debug("Recording session for token {}", token);

    try {
        this.sessionMappingStorage.removeBySessionById(session.getId());
    } catch (final Exception e) {
        // ignore if the session is already marked as invalid.  Nothing we can do!
    }
    sessionMappingStorage.addSessionById(token, session);
}

private void destroySession(final HttpServletRequest request) {
    final String logoutMessage;
    // front channel logout -> the message needs to be base64 decoded + decompressed
    if (isFrontChannelLogoutRequest(request)) {
        logoutMessage = uncompressLogoutMessage(CommonUtils.safeGetParameter(request,
                this.frontLogoutParameterName));
    } else {
        logoutMessage = CommonUtils.safeGetParameter(request, this.logoutParameterName, this.safeParameters);
    }
    logger.trace("Logout request:n{}", logoutMessage);

    final String token = XmlUtils.getTextForElement(logoutMessage, "SessionIndex");
    if (CommonUtils.isNotBlank(token)) {
        final HttpSession session = this.sessionMappingStorage.removeSessionByMappingId(token);

        if (session != null) {
            final String sessionID = session.getId();
            logger.debug("Invalidating session [{}] for token [{}]", sessionID, token);

            try {
                session.invalidate();
            } catch (final IllegalStateException e) {
                logger.debug("Error invalidating session.", e);
            }
            this.logoutStrategy.logout(request);
        }
    }
}