CAS单点登录及前后端分离方案

1 cas原理及概念

最近项目上要用到单点登录,因此针对性的学习了下cas,这里只做简单记录,便于后期需要的时候查阅。cas乃目前比较流行的企业单点登录业务整合的解决方案之一,在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。

github sso-demo项目源码

  • 从总体上看,CAS由两大部分组成:一个CAS Server 和多个CAS Client。

    • CAS Server(服务端)负责提供登录认证服务,单独部署,会给用户颁发两个核心票据:TGT(登录票据,服务端使用)和ST(服务票据,客户端使用)。
    • CAS Client(客户端)负责处理对客户端受保护资源的访问请求。一般通过是在web.xml中配置了CAS过滤器,和应用系统部署在一起,可以有多个。
  • cas登录流程如下图:
    在这里插入图片描述

  • 上图中有几个重要的概念,了解一下:

    • TGT(Ticket Grangting Ticket):TGT是CAS为用户签发的登录票据,拥有了TGT,用户就可以证明自己在CAS成功登录过。TGT封装了Cookie值以及此Cookie值对应的用户信息。用户在CAS认证成功后,CAS服务端会生成一个TGT对象,放入自己的session;同时,CAS生成TGC(一个cookie,官方文档说是叫TGC),写入浏览器。

    TGT对象的id就是TGC cookie的值,当HTTP再次请求到来时,如果从浏览器传过来的有TGC这个cookie,并且CAS能找到对应的TGT,则说明用户之前登录过;如果没有,则用户需要重新登录。

    • TGC(Ticket-granting cookie):上面提到,用户登录成功后CAS Server会生成TGT,而TGC就是将TGT的id以cookie形式放到浏览器端,是CAS Server用来明确用户身份的凭证。

    • ST(ServiceTicket):ST是CAS为用户签发的访问某一服务票据。在登录成功后重定向回客户端的时候,会给客户端颁发ST,客户端的CAS Validation Filter会根据ST去服务端再次做校验,获取用户信息。ST是服务端提供给客户端的用来获取用户信息的只能用一次的凭证。

    为了保证ST的安全性,ST是基于随机生成的,没有规律性,而且,CAS规定 ST 只能存活一定的时间,然后 CAS Server 会让它失效。而且,CAS 协议规定ST只能使用一次,无论 Service Ticket 验证是否成功,CASServer 都会清除服务端缓存中的该 Ticket,从而可以确保一个 Service Ticket 不被使用两次。

2 cas服务搭建(cas5.3.2)

2.1 骨架搭建

  1. 下载cas
    官方Github地址,下载指定版本,我用的5.3,将下载下来的压缩包解压,然后进入解压出来的目录,使用maven命令编译,执行mvn clean package,结束之后会出现 target 文件夹,里面有一个cas.war包,这个war包就是我们要运行的程序。

  2. 添加域名映射
    修改/etc/hosts文件,添加服务端域名(server.cas.com) 以及两个客户端的域名(app1.cas.com , app2.cas.com)

  3. tomcat的https访问

  • 生成keystore
keytool -genkey -alias sso -keypass changeit -keyalg RSA -validity 3650 -keystore /key/sso.keystore -storepass changeit


您的名字与姓氏是什么? (注意:这里要输入服务端的域名)
  [Unknown]:  server.cas.com
您的组织单位名称是什么?
  [Unknown]:  hx
您的组织名称是什么?
  [Unknown]:  hx
您所在的城市或区域名称是什么?
  [Unknown]:  wuhan
您所在的省//自治区名称是什么?
  [Unknown]:  wuhan
该单位的双字母国家/地区代码是什么?
  [Unknown]:  zh
是否正确?
  y


点击回车:key目录下面生成sso.keystore的文件。

可以使用以下命令查看生成秘钥库的文件内容:

keytool -list -keystore /key/sso.keystore 
  • 根据keystore生成crt文件
#输入第一步中keystore的密码changeit
keytool -export -alias sso -file /key/sso.cer -keystore /key/sso.keystore -validity 3650
  • 信任授权文件到jdk
keytool -import -keystore %JAVA_HOME%/jre/lib/security/cacerts -file /key/sso.cer -alias sso -storepass changeit

将%JAVA_HOME%替换为自己环境中的java_home路径
  • 修改tomcat的配置文件server.xml
    添加以下内容:
<Connector port="8443" protocol="org.apache.coyote.http11.Http11NioProtocol"
           maxThreads="200" SSLEnabled="true" scheme="https"
           secure="true" clientAuth="false" sslProtocol="TLS"
           keystoreFile="/key/sso.keystore"
           keystorePass="changeit"/>
  • 让chrome浏览器信任证书
  1. 启动CAS服务
    将第一步编译好的cas.war部署到tomcat中启动,然后访问https://server.cas.com:8443/cas/login ,目前这个服务端只能看看,没什么实际用途。

  2. 使用Overlay生成真正有用的服务端

什么是Overlay:overlay可以把多个项目war合并成为一个项目,并且如果项目存在同名文件,那么主项目中的文件将覆盖掉其他项目的同名文件。使用maven 的Overlay配置实现无侵入的改造cas。

  • 新建maven项目
    新建一个maven项目,将cas.war包中的pom.xml文件拷贝到新项目中覆盖,可自行删除无用配置。

  • application.properties、log4j2.xml和META-INF文件夹从 css.war 里面拷贝出来,最终项目目录如下:
    在这里插入图片描述

  • 修改application.properties

# 也可以把sso.keystore文件放到项目的resource目录
server.ssl.enabled=true
server.ssl.key-store=file:/key/sso.keystore
server.ssl.key-store-password=changeit
server.ssl.key-password=changeit
server.ssl.keyAlias=sso

配置好配了https的tomcat后就可以启动项目了。

2.2 记住我

application.properties添加如下配置:

#记住我
cas.ticket.tgt.rememberMe.enabled=true
cas.ticket.tgt.rememberMe.timeToKillInSeconds=3600

2.2 自定义Credential、自定义AuthenticationHandler

重写Credential来实现自定义登录表单信息,使用sso的时候往往登录不只是需要用户名密码,有时候可能还需要验证码,Credential定义前端所需定义的绑定参数,后续会交给AuthenticationHandler进行认证。

CAS服务器的org.apereo.cas.authentication.AuthenticationManager负责基于提供的凭证信息进行用户认证。与Spring Security很相似,实际的认证委托给了一个或多个实现了org.apereo.cas.authentication.AuthenticationHandler接口的处理类。在cas的认证过程中逐个执行authenticationHandlers中配置的认证管理,直到有一个成功为止。
自定义验证很重要

添加依赖

<!-- 自定义认证的方式 -->
<dependency>
    <groupId>org.apereo.cas</groupId>
    <artifactId>cas-server-core-webflow</artifactId>
    <version>${cas.version}</version>
</dependency>
<dependency>
    <groupId>org.apereo.cas</groupId>
    <artifactId>cas-server-core-authentication</artifactId>
    <version>${cas.version}</version>
</dependency>
<dependency>
    <groupId>org.apereo.cas</groupId>
    <artifactId>cas-server-core-authentication-api</artifactId>
    <version>${cas.version}</version>
</dependency>
<dependency>
    <groupId>org.apereo.cas</groupId>
    <artifactId>cas-server-webapp-config</artifactId>
    <version>${cas.version}</version>
    <scope>provided</scope>
</dependency>
  1. 重写credential,继承RememberMeUsernamePasswordCredential,加上capcha属性。
package com.aaron.cas.adaptors.generic;

import org.apache.commons.lang3.builder.HashCodeBuilder;
import org.apereo.cas.authentication.RememberMeUsernamePasswordCredential;


/**
 * @author Aaron
 * @description 验证码 Credential
 * @date 2020/9/12
 */
public class UsernamePasswordCaptchaCredential extends RememberMeUsernamePasswordCredential {

    private String captcha;

    public String getCaptcha() {
        return captcha;
    }

    public void setCaptcha(String captcha) {
        this.captcha = captcha;
    }

    @Override
    public int hashCode() {
        return new HashCodeBuilder()
                .appendSuper(super.hashCode())
                .append(this.captcha)
                .toHashCode();
    }
}

  1. 新建CustomWebflowConfigurer修改之前默认的Credential为自定义的UsernamePasswordCaptchaCredential
package com.aaron.cas.adaptors.generic;


import org.apereo.cas.authentication.UsernamePasswordCredential;
import org.apereo.cas.configuration.CasConfigurationProperties;
import org.apereo.cas.web.flow.CasWebflowConstants;
import org.apereo.cas.web.flow.configurer.DefaultLoginWebflowConfigurer;
import org.springframework.context.ApplicationContext;
import org.springframework.webflow.definition.registry.FlowDefinitionRegistry;
import org.springframework.webflow.engine.Flow;
import org.springframework.webflow.engine.ViewState;
import org.springframework.webflow.engine.builder.BinderConfiguration;
import org.springframework.webflow.engine.builder.support.FlowBuilderServices;

/**
 * @author Aaron
 * @description 自定义系统登入设置自定义用户凭证
 * @date 2020/9/12
 */
public class CustomWebflowConfigurer extends DefaultLoginWebflowConfigurer {

    public CustomWebflowConfigurer(FlowBuilderServices flowBuilderServices,
                                   FlowDefinitionRegistry flowDefinitionRegistry,
                                   ApplicationContext applicationContext,
                                   CasConfigurationProperties casProperties) {
        super(flowBuilderServices, flowDefinitionRegistry, applicationContext, casProperties);
    }

    @Override
    protected void createRememberMeAuthnWebflowConfig(Flow flow) {
        if (casProperties.getTicket().getTgt().getRememberMe().isEnabled()) {
            //重写绑定自定义credential
            createFlowVariable(flow, CasWebflowConstants.VAR_ID_CREDENTIAL, UsernamePasswordCaptchaCredential.class);
            //登录页绑定新参数
            final ViewState state = getState(flow, CasWebflowConstants.STATE_ID_VIEW_LOGIN_FORM, ViewState.class);
            final BinderConfiguration cfg = getViewStateBinderConfiguration(state);
            cfg.addBinding(new BinderConfiguration.Binding("rememberMe", null, false));
            //由于用户名以及密码已经绑定,所以只需对新加参数绑定即可
            cfg.addBinding(new BinderConfiguration.Binding("captcha", null, false));
        } else {
            createFlowVariable(flow, CasWebflowConstants.VAR_ID_CREDENTIAL, UsernamePasswordCredential.class);
        }
    }

}

主要把原来的RememberMeUsernamePasswordCredential换成了我们自己的
UsernamePasswordCaptchaCredential,并且加上cpacha的bind。

  1. 注册CasWebflowConfigurer

这里是spring boot的知识,需要对配置进行识别,由于需要对Credential进行重写定义,必须在CasWebflowContextConfiguration配置之前注册,否则自定义的无法重写

package com.aaron.cas.config;

import com.aaron.cas.adaptors.generic.CustomWebflowConfigurer;
import org.apereo.cas.configuration.CasConfigurationProperties;
import org.apereo.cas.web.flow.CasWebflowConfigurer;
import org.apereo.cas.web.flow.config.CasWebflowContextConfiguration;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.AutoConfigureBefore;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.webflow.definition.registry.FlowDefinitionRegistry;
import org.springframework.webflow.engine.builder.support.FlowBuilderServices;

/**
 * @author Aaron
 * @description 配置 CustomWebflowConfigurer 和 表单处理器
 * @date 2020/9/12
 */
@Configuration("customAuthWebflowConfiguration")
@EnableConfigurationProperties(CasConfigurationProperties.class)
@AutoConfigureBefore(value = CasWebflowContextConfiguration.class)
public class CustomAuthWebflowConfiguration {

    @Autowired
    @Qualifier("loginFlowRegistry")
    private FlowDefinitionRegistry loginFlowRegistry;
    @Autowired
    private ApplicationContext applicationContext;
    @Autowired
    private CasConfigurationProperties casProperties;
    @Autowired
    private FlowBuilderServices flowBuilderServices;

    @Bean
    public CasWebflowConfigurer defaultLoginWebflowConfigurer() {
        final CustomWebflowConfigurer c = new CustomWebflowConfigurer(
                flowBuilderServices,
                loginFlowRegistry,
                applicationContext,
                casProperties);
        c.initialize();
        return c;
    }
}

  1. 自定义登录验证AuthenticationHandler
package com.aaron.cas.adaptors.generic;

import com.aaron.cas.exception.CaptchaErrorException;
import com.aaron.cas.service.IUserService;
import org.apereo.cas.authentication.AuthenticationHandlerExecutionResult;
import org.apereo.cas.authentication.Credential;
import org.apereo.cas.authentication.PreventedException;
import org.apereo.cas.authentication.handler.support.AbstractPreAndPostProcessingAuthenticationHandler;
import org.apereo.cas.authentication.principal.PrincipalFactory;
import org.apereo.cas.services.ServicesManager;
import org.springframework.beans.factory.annotation.Autowired;

import javax.security.auth.login.AccountNotFoundException;
import java.security.GeneralSecurityException;

/**
 * @author Aaron
 * @description 自定义验证器
 * @date 2020/9/9
 */
public class UserNamePassWordCaptchaAuthenticationHandler extends AbstractPreAndPostProcessingAuthenticationHandler {

    @Autowired
    private IUserService userService;

    public UserNamePassWordCaptchaAuthenticationHandler(String name, ServicesManager servicesManager, PrincipalFactory principalFactory, Integer order) {
        super(name, servicesManager, principalFactory, order);
    }

    @Override
    protected AuthenticationHandlerExecutionResult doAuthentication(Credential credential) throws GeneralSecurityException, PreventedException {
        UsernamePasswordCaptchaCredential myCredential = (UsernamePasswordCaptchaCredential) credential;
        String username = myCredential.getUsername();
        // TODO 这里可以添加验证码校验逻辑
        String requestCaptcha = myCredential.getCaptcha();
        if(!"123".equals(requestCaptcha)) {
            throw new CaptchaErrorException("验证码校验失败");
        }
        // 用户名密码校验
        // UserDto user = userService.findByUserName(username);
        //可以在这里直接对用户名密码校验,或者调用 CredentialsMatcher 校验
        if (!"admin".equals(username)) {
            throw new AccountNotFoundException("用户名或密码错误!");
        }
        return createHandlerResult(credential, this.principalFactory.createPrincipal(username));

    }

    @Override
    public boolean supports(Credential credential) {
        return credential instanceof UsernamePasswordCaptchaCredential;
    }
    
}

  1. 注册验证器
package com.aaron.cas.config;

import com.aaron.cas.adaptors.generic.UserNamePassWordCaptchaAuthenticationHandler;
import org.apereo.cas.authentication.AuthenticationEventExecutionPlan;
import org.apereo.cas.authentication.AuthenticationEventExecutionPlanConfigurer;
import org.apereo.cas.authentication.AuthenticationHandler;
import org.apereo.cas.authentication.principal.DefaultPrincipalFactory;
import org.apereo.cas.configuration.CasConfigurationProperties;
import org.apereo.cas.services.ServicesManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * @author Aaron
 * @description 注册验证器
 * @date 2020/9/9
 */
@Configuration("customAuthenticationConfiguration")
@EnableConfigurationProperties(CasConfigurationProperties.class)
public class CustomAuthenticationConfiguration implements AuthenticationEventExecutionPlanConfigurer {
    @Autowired
    private CasConfigurationProperties casProperties;

    @Autowired
    @Qualifier("servicesManager")
    private ServicesManager servicesManager;

    /**
     * 将自定义验证器注册为Bean
     * @return
     */
    @Bean
    public AuthenticationHandler userNamePassWordCaptchaAuthenticationHandler() {
        UserNamePassWordCaptchaAuthenticationHandler handler = new UserNamePassWordCaptchaAuthenticationHandler(
                UserNamePassWordCaptchaAuthenticationHandler.class.getSimpleName(),
                servicesManager,
                new DefaultPrincipalFactory(),
                10);
        return handler;
    }

    /**
     * 注册验证器
     * @param plan
     */
    @Override
    public void configureAuthenticationExecutionPlan(AuthenticationEventExecutionPlan plan) {
        plan.registerAuthenticationHandler(userNamePassWordCaptchaAuthenticationHandler());
    }
}

  1. 加载配置类
    在resources/META-INF/spring.factories中配置该项
org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.aaron.cas.config.SpringConfig,\
  com.aaron.cas.config.CustomAuthWebflowConfiguration,\
  com.aaron.cas.config.CustomAuthenticationConfiguration

2.3 自定义登录验证集成shiro

  1. 添加依赖
<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-spring</artifactId>
    <version>1.4.0</version>
</dependency>
  1. 自定义验证器ShiroAuthenticationHandler
package com.aaron.cas.handler;

import com.aaron.cas.adaptors.generic.UsernamePasswordCaptchaCredential;
import com.aaron.cas.service.IUserService;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.*;
import org.apache.shiro.subject.Subject;
import org.apereo.cas.authentication.AuthenticationHandlerExecutionResult;
import org.apereo.cas.authentication.Credential;
import org.apereo.cas.authentication.PreventedException;
import org.apereo.cas.authentication.exceptions.AccountDisabledException;
import org.apereo.cas.authentication.handler.support.AbstractPreAndPostProcessingAuthenticationHandler;
import org.apereo.cas.authentication.principal.PrincipalFactory;
import org.apereo.cas.services.ServicesManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;

import javax.security.auth.login.AccountLockedException;
import javax.security.auth.login.AccountNotFoundException;
import javax.security.auth.login.CredentialExpiredException;
import javax.security.auth.login.FailedLoginException;
import java.security.GeneralSecurityException;
import java.util.HashMap;
import java.util.Map;

/**
 * @author Aaron
 * @description 自定义验证器(shiro)
 * CAS服务器的org.apereo.cas.authentication.AuthenticationManager负责基于提供的凭证信息进行用户认证。
 * 与Spring Security很相似,实际的认证委托给了一个或多个实现了
 * org.apereo.cas.authentication.AuthenticationHandler接口的处理类。
 * 在cas的认证过程中逐个执行authenticationHandlers中配置的认证管理,直到有一个成功为止。
 * @date 2020/9/9
 */
public class ShiroAuthenticationHandler extends AbstractPreAndPostProcessingAuthenticationHandler {

    private static final Logger LOGGER = LoggerFactory.getLogger(ShiroAuthenticationHandler.class);

    /*@Autowired
    private IUserService userService;*/

    public ShiroAuthenticationHandler(String name,ServicesManager servicesManager,PrincipalFactory principalFactory,Integer order) {
        super(name, servicesManager, principalFactory, order);
    }

    @Override
    protected AuthenticationHandlerExecutionResult doAuthentication(Credential credential) throws GeneralSecurityException, PreventedException {
        UsernamePasswordCaptchaCredential myCredential = (UsernamePasswordCaptchaCredential) credential;
        try {
            UsernamePasswordToken token = new UsernamePasswordToken(myCredential.getUsername(), myCredential.getPassword());
            // 交给shiro验证
            Subject subject = SecurityUtils.getSubject();
            subject.login(token);

            checkSubjectRolesAndPermissions(subject);
            // 要返回给cas客户端的参数放在这里
            Map<String,Object> returnParams = new HashMap<>();
            return createHandlerResult(credential, this.principalFactory.createPrincipal(myCredential.getUsername(), returnParams));
        } catch (final UnknownAccountException uae) {
            throw new AccountNotFoundException(uae.getMessage());
        } catch (final IncorrectCredentialsException ice) {
            throw new FailedLoginException(ice.getMessage());
        } catch (final LockedAccountException | ExcessiveAttemptsException lae) {
            throw new AccountLockedException(lae.getMessage());
        } catch (final ExpiredCredentialsException eae) {
            throw new CredentialExpiredException(eae.getMessage());
        } catch (final DisabledAccountException eae) {
            throw new AccountDisabledException(eae.getMessage());
        } catch (final AuthenticationException e) {
            throw new FailedLoginException(e.getMessage());
        }
    }


    /**
     * Check subject roles and permissions.
     * 这只是举个简单的例子 进行对比,可以自己写 自己对应的逻辑
     *
     * @param subject the current user
     * @throws FailedLoginException the failed login exception in case roles or permissions are absent
     */
    protected void checkSubjectRolesAndPermissions(final Subject subject) throws FailedLoginException {

        /*//查询用户id, 也可以在登录成功之后,将id 放到session中,从session中获取,这里直接查库
        UserDto user = IUserService.findByUserName(String.valueOf(currentUser.getPrincipal()));
        //获取所有的用户角色
        Set<String> allRoles = roleService.findAllRoles();
        //根据id获取用户的角色,这里一个用户只对应一个角色
        String userRole = roleService.findRolesByUserId(String.valueOf(user.get("uid")));
        //判断如果有角色,就登陆成功
        for (String role : allRoles){
            if (role.equals(userRole)) {
                return;
            }
        }
        //否则抛出异常,也可以自定义异常,返回不同的提示
        throw new FailedLoginException();*/
    }

    @Override
    public boolean supports(Credential credential) {
        return credential instanceof UsernamePasswordCaptchaCredential;
    }
}

  1. 注册验证器并添加shiro配置
package com.aaron.cas.config;

import com.aaron.cas.handler.ShiroAuthenticationHandler;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.mgt.DefaultSecurityManager;
import org.apache.shiro.mgt.SecurityManager;
import org.apereo.cas.authentication.AuthenticationEventExecutionPlan;
import org.apereo.cas.authentication.AuthenticationEventExecutionPlanConfigurer;
import org.apereo.cas.authentication.AuthenticationHandler;
import org.apereo.cas.authentication.principal.DefaultPrincipalFactory;
import org.apereo.cas.configuration.CasConfigurationProperties;
import org.apereo.cas.services.ServicesManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * 注册验证器并添加shiro配置
 */
@Configuration("shiroAuthenticationConfiguration")
@EnableConfigurationProperties(CasConfigurationProperties.class)
public class ShiroAuthenticationConfiguration  implements AuthenticationEventExecutionPlanConfigurer {

    @Autowired
    private CasConfigurationProperties casProperties;

    @Autowired
    @Qualifier("servicesManager")
    private ServicesManager servicesManager;

    @Bean
    public AuthenticationHandler shiroAuthenticationHandler() {
        ShiroAuthenticationHandler handler = new ShiroAuthenticationHandler(ShiroAuthenticationHandler.class.getSimpleName(),
                servicesManager,
                new DefaultPrincipalFactory(),
                1);
        return handler;
    }

    @Override
    public void configureAuthenticationExecutionPlan(AuthenticationEventExecutionPlan plan) {
        plan.registerAuthenticationHandler(shiroAuthenticationHandler());
    }


    @Bean(name="securityManager")
    public SecurityManager securityManager() {
        DefaultSecurityManager securityManager =  new DefaultSecurityManager();
        //设置自定义realm.
        securityManager.setRealm(shiroRealm());
        SecurityUtils.setSecurityManager(securityManager);
        return securityManager;
    }

    @Bean
    public ShiroRealm shiroRealm(){
        ShiroRealm shiroRealm = new ShiroRealm();
        shiroRealm.setCachingEnabled(false);
        //启用身份验证缓存,即缓存AuthenticationInfo信息,默认false
        shiroRealm.setAuthenticationCachingEnabled(false);
        //启用授权缓存,即缓存AuthorizationInfo信息,默认false
        shiroRealm.setAuthorizationCachingEnabled(false);
        return shiroRealm;
    }
}

在resources\META-INF\spring.factories中配置该类,以使spring加载该配置类。

  1. ShiroRealm
package com.aaron.cas.config;


import com.aaron.cas.model.UserDto;
import com.aaron.cas.service.IUserService;
import org.apache.shiro.authc.*;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.springframework.beans.factory.annotation.Autowired;

/**
 * 在Shiro中,最终是通过Realm来获取应用程序中的用户、角色及权限信息的
 * 在Realm中会直接从我们的数据源中获取Shiro需要的验证信息。可以说,Realm是专用于安全框架的DAO.
 */
public class ShiroRealm extends AuthorizingRealm {

    @Autowired
    private IUserService userService;

    /**
     * 验证用户身份
     * @param authenticationToken
     * @return
     * @throws AuthenticationException
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) authenticationToken;
        String username = usernamePasswordToken.getUsername();
        UserDto user = userService.findByUserName(username);
        //可以在这里直接对用户名校验,或者调用 CredentialsMatcher 校验
        if (user == null) {
            throw new UnknownAccountException("用户名或密码错误!");
        }
        if (user.getState() == 1) {
            throw new LockedAccountException("账号已被锁定,请联系管理员!");
        }
        return new SimpleAuthenticationInfo(username, user.getPassword(), getName());
    }

    /**
     * 授权用户权限 但是这个方法并不用,我们会在 ShiroAuthenticationHandler的 checkSubjectRolesAndPermissions 中单独去验证
     * @param principalCollection
     * @return
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        System.out.println("查询权限方法调用了!!!");
        //添加角色
        SimpleAuthorizationInfo authorizationInfo =  new SimpleAuthorizationInfo();
        return authorizationInfo;
    }
}

2.4 自定义错误信息提示

cas自定义错误信息一般需要以下几步:

  • 创建 一个异常类继承javax.security.auth.login.AccountExpiredException;
  • 配置异常到application.properties中;
  • 在messages_zh_CN.properties 配置文件中,配置异常弹出的消息(使用maven的Overlay编译好之后,默认已经有messages_zh_CN.properties文件了,直接拷贝过来,在基础上进行修改)。
  1. 自定义异常类
package com.aaron.cas.exception;

import javax.security.auth.login.AccountException;

/**
 * @author Aaron
 * @description 验证码错误异常
 * @date 2020/9/16
 */
public class CaptchaErrorException extends AccountException {

    public CaptchaErrorException() {
        super();
    }

    public CaptchaErrorException(String msg) {
        super(msg);
    }
}

  1. 在application.properties中添加异常信息
#自定义错误信息,多个用逗号隔开即可
cas.authn.exceptions.exceptions=com.aaron.cas.exception.CaptchaErrorException
  1. 配置messages_zh_CN.properties
    messages_zh_CN.properties是从编译好的cas中拷贝过来的,直接拷贝到resourece根路径下,并添加自己的异常:
# 自己添加的
authenticationFailure.CaptchaErrorException=验证码错误

2.5 自定义返回信息给客户端

  1. 首先要在注册Service的json中配置返回信息的规则
  • Return All (所有配置返回的都返回)
  • Deny All (配置拒绝的出现则报错)
  • Return Allowed(只返回允许的主要属性)
  • 自定义Filter(自定义过滤策略)

这里给出Return All、Return Allowed两种例子

#返回所有信息
{
  "@class" : "org.apereo.cas.services.RegexRegisteredService",
  "serviceId" : "^(https|imaps|http)://app1.cas.com.*",
  "name" : "测试客户端app1",
  "id" : 1000,
  "description" : "这是app1的客户端",
  "evaluationOrder" : 10,
  "theme" : "app1",
  "attributeReleasePolicy" : {
    "@class" : "org.apereo.cas.services.ReturnAllAttributeReleasePolicy"
  }
}
#返回姓名和身份证号
{
  "@class" : "org.apereo.cas.services.RegexRegisteredService",
  "serviceId" : "^(https|imaps|http)://app2.cas.com.*",
  "name" : "测试客户端app2",
  "id" : 1001,
  "description" : "这是app2的客户端",
  "evaluationOrder" : 11,
  "theme" : "app2",
  "attributeReleasePolicy" : {
    "@class" : "org.apereo.cas.services.ReturnAllowedAttributeReleasePolicy"
    "allowedAttributes" : [ "java.util.ArrayList", [ "name", "id_card_num" ] ]
  }
}
  1. 修改表单处理器Handler中添加返回结果
// 将要返回给客户端的内容放在 userMap 中
return createHandlerResult(credential, this.principalFactory.createPrincipal(username,userMap));

这样客户端就可以通过以下代码取到相应值:

AttributePrincipal principal = (AttributePrincipal)request.getUserPrincipal();
String name = principal.getName();
Map userMap = principal.getAttributes();

2.6 自定义登录页面

  1. 主题规范
  • 静态资源(js,css)存放目录为src/main/resources/static
  • html资源(thymeleaf)存放目录为src/main/resources/templates
  • 主题配置文件存放在src/main/resources并且命名为[theme_name].properties
  • 主题页面html存放目录为src/main/resources/templates/
  1. 在客户端注册的json文件中添加theme属性
{
  "@class" : "org.apereo.cas.services.RegexRegisteredService",
  "serviceId" : "^(https|imaps|http)://app1.cas.com.*",
  "name" : "测试客户端app1",
  "id" : 1000,
  "description" : "这是app1的客户端",
  "evaluationOrder" : 10,
  "theme" : "app1"
}
  1. 在src/main/resources下创建app1.properties 和 app2.properties
    需要在src/main/resources文件夹的根目录下创建与json文件中theme属性值对应的properties
#原cas默认的css样式,如果更改了,某些页面样式将丢失
cas.standard.css.file=/css/cas.css
#自己的样式
cas.myself.css=/themes/app1/css/cas.css
cas.javascript.file=/themes/app1/js/jquery-1.4.2.min.js
cas.page.title=app1的主题

属性值都是随便起,只要在html中指明引用的key就可以了,例如:properties中指明css和js文件地址,然后在html中用下面的方式使用:

<link rel="stylesheet" th:href="@{${#themes.code('cas.myself.css')}}"/>
<script th:src="@{${#themes.code('cas.javascript.file')}}"></script>
  1. 根据上面配置创建cas.css文件
    如客户端1:
h2 {
    color: red;
}

客户端2:

h2 {
    color: pink;
}
  1. 在application.properties中添加以下属性,配置默认主题
# 默认主题
cas.theme.defaultThemeName=app1
  1. 然后在不同主题的登录页面引用不同的样式文件即可。目录结构如下:
    在这里插入图片描述

2.7 单点登出

单点登出是通过请求/logout发生的,在请求Cas Server的logout时,Cas Server会将客户端携带的TGC对应的TGT删除,同时回调该TGT对应的所有sevice,即Cas Client,若Cas Client需要响应该回调,进而触发Cas Client的登出的话则需要客户端配置对应的支持。

  • CAS Serve登出r配置:
#配置单点登出
#配置允许登出后跳转到指定页面
cas.logout.followServiceRedirects=false
#跳转到指定页面需要的参数名为 service
cas.logout.redirectParameter=service
#登出后需要跳转到的地址,如果配置该参数,service将无效。
#cas.logout.redirectUrl=https://www.taobao.com
#在退出时是否需要 确认退出提示   true弹出确认提示框  false直接退出
cas.logout.confirmLogout=false
#是否移除子系统的票据
cas.logout.removeDescendantTickets=true
#禁用单点登出,默认是false不禁止
#cas.slo.disabled=true
#默认异步通知客户端,清除session
#cas.slo.asynchronous=true
  • Cas Client登出配置:需要在Cas Client应用的web.xml文件中添加如下Filter和Listener
<!-- 用于单点退出,用于实现单点登出功能 -->
<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>
</filter>
<filter-mapping>
   <filter-name>CAS Single Sign Out Filter</filter-name>
   <url-pattern>/*</url-pattern>
</filter-mapping>

可以跟踪SingleSignOutFilter源码:

public void doFilter(final ServletRequest servletRequest, final ServletResponse servletResponse, final FilterChain filterChain) throws IOException, ServletException {
    final HttpServletRequest request = (HttpServletRequest) servletRequest; 
    //判断参数中是否具有artifactParameterName属性指定的参数名称,默认是ticket 
      if (handler.isTokenRequest(request)) { 
        // 如果存在,在本地sessionMappingStorage中记录session。 
          handler.recordSession(request);  
      } else if (handler.isLogoutRequest(request)) {//判断是否具有logoutParameterName参数指定的参数,默认参数名称为logoutRequest 
        // 如果存在,则在sessionMappingStorage中删除记录,并注销session。 
        handler.destroySession(request); 
          // 注销session后,立刻停止执行后面的过滤器 
          return; 
      } else { 
          log.trace("Ignoring URI " + request.getRequestURI()); 
      } 
      //条件都不满足,继续执行下面的过滤器 
      filterChain.doFilter(servletRequest, servletResponse); 
  }

具体流程为:

  1. 认证通过后,Cas Server除了重定向回Cas Client,还会注册服务,就是为了让Cas Server知道有哪些Cas Client在这里登陆过;
  2. 携带ticket(ST)回跳到Cas Client后,Cas Client判断参数中是否携带了ticket,如果有,singleSignOutFilter注册将ticket作为id的session到sessionMappingStorage(是一个map,key为ticket,value为session);
  3. 当用户访问Cas Server的/logout登出时,Cas Server先将TGT干掉,然后给之前注册过那些服务的地址发送退出登录的请求,并且携带之前登录的ticket;
  4. Cas Client的singleSignOutFilter根据传过来的这个 ticket 来将对应的用户 session 注销掉;
  5. SingleSignOutHttpSessionListener就是用来监听session注销事件的,当有session被注销的时候,触发该监听器将sessionMappingStorage中对应的sessionId中的数据删除,所以在配置单点登出的时候,一定要配置这个监听器,否则客户端很容易导致内存溢出。

可以在客户端的登出链接写成服务端的登出地址,或者在客户端写一个登出接口,在各自的系统点击登出,先进入本服务的后台方法,在该方法中重定向到服务端的登出地址。

    /**
     * 跳转到默认页面
     * @param session
     * @return
     */
    @RequestMapping("/logout1")
    public String loginOut(HttpSession session){
        session.invalidate();
        //这个是直接退出,走的是默认退出方式
        return "redirect:https://server.cas.com:8443/cas/logout";
    }

    /**
     * 跳转到指定页面
     * @param session
     * @return
     */
    @RequestMapping("/logout2")
    public String loginOut2(HttpSession session){
        session.invalidate();
        //退出登录后,跳转到退成成功的页面,不走默认页面
        return "redirect:https://server.cas.com:8443/cas/logout?service=http://app1.cas.com:8081";
    }

2.8 动态添加services

// TODO 因为我这个步骤失败了,因此先略过,后期再补,主要原因为 只要导入了这个包,启动就一直报错,没有取细查原因,后期空闲再整。

<!--动态添加services:一引入cas-server-support-jpa-service-registry包启动就报错,未解决-->
<!--<dependency>
   <groupId>org.apereo.cas</groupId>
   <artifactId>cas-server-support-jpa-service-registry</artifactId>
   <version>${cas.version}</version>
</dependency>

3 客户端集成

客户端接入 CAS 首先需要在服务端进行注册,否则客户端访问将提示“未认证授权的服务”警告,如果对所有https和http请求的service进行允许认证,在resources/services下新建文件HTTPSandIMAPS-10000001.json,这个文件是从cas源代码同路径下拷贝过来的。

{
  "@class" : "org.apereo.cas.services.RegexRegisteredService",
  "serviceId" : "^(https|imaps|http)://.*",
  "name" : "测试客户端",
  "id" : 10000001,
  "description" : "这是一个测试客户端的服务,所有的https或者http访问都允许通过",
  "evaluationOrder" : 10000
}

注意:services目录中可包含多个 JSON 文件,其命名必须满足以下规则: n a m e − {name}- name{id}.json,id必须为json文件中内容id一致。

  • @class:必须为org.apereo.cas.services.RegisteredService的实现类;
  • serviceId:对服务进行描述的表达式,可用于匹配一个或多个 URL 地址;
  • name: 服务名称;
  • id:全局唯一标志;
  • description:服务描述,会显示在默认登录页;
  • evaluationOrder:定义多个服务的执行顺序。
  1. 修改application.properties
    修改 application.properties 文件告知 CAS 服务端从本地加载服务定义文件。
#注册客户端
cas.serviceRegistry.initFromJson=true
cas.serviceRegistry.watcherEnabled=true
cas.serviceRegistry.schedule.repeatInterval=120000
cas.serviceRegistry.schedule.startDelay=15000
cas.serviceRegistry.managementType=DEFAULT
cas.serviceRegistry.json.location=classpath:/services
cas.logout.followServiceRedirects=true

启动时,打印如下日志,说明服务注册成功:

2018-07-31 18:49:38,611 WARN [org.apereo.cas.services.ServiceRegistryInitializer] - <Skipping [Apereo] JSON service definition as a matching service [Apereo] is found in the registry>
2018-07-31 18:49:38,611 WARN [org.apereo.cas.services.ServiceRegistryInitializer] - <Skipping [测试客户端] JSON service definition as a matching service [测试客户端] is found in the registry>
  1. 客户端导入证书
    必须保证客户端证书和服务端证书是同一个证书,不然就会报错。
keytool -import -keystore %JAVA_HOME%/jre/lib/security/cacerts -file /key/sso.cer -alias sso -storepass changeit

将%JAVA_HOME%替换为自己环境中的java_home路径
  1. 导包
    <dependency>
        <groupId>org.jasig.cas.client</groupId>
        <artifactId>cas-client-core</artifactId>
        <version>3.5.0</version>
    </dependency>

传统项目

  1. 传统项目:修改web.xml,注册过滤器
<!-- ========================单点登录开始 ======================== -->
    <!-- 用于单点退出,用于实现单点登出功能 -->
    <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>https://server.cas.com:8443/cas</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 Filter</filter-name>
        <filter-class>org.jasig.cas.client.authentication.AuthenticationFilter</filter-class>
        <init-param>
            <param-name>serverName</param-name>
            <param-value>http://app2.cas.com:8082</param-value>
            <!-- 当前Client系统的地址 -->
        </init-param>
        <init-param>
            <param-name>casServerLoginUrl</param-name>
            <param-value>https://server.cas.com:8443/cas/login</param-value>
            <!-- 使用的CAS-Server的登录地址,一定是到登录的action -->
        </init-param>
        <init-param>
            <param-name>ignorePattern</param-name>
            <param-value>/js/*|/images/*|/view/*|/css/*</param-value>
            <!--静态资源路径,不会跳到cas去登录-->
        </init-param>
        <!--<init-param>
            <param-name>ignoreUrlPatternType</param-name>
            <param-value>com.aaron.cas.config.SimpleUrlPatternMatcherStrategy</param-value>
        </init-param>-->
    </filter>
    <filter-mapping>
        <filter-name>CAS Filter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

    <!-- 该过滤器负责对Ticket的校验工作,必须启用它 -->
    <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>https://server.cas.com:8443/cas</param-value>
            <!-- 使用的CAS-Server的地址,一定是在浏览器输入该地址能正常打开CAS-Server的根地址 -->
        </init-param>
        <init-param>
            <param-name>serverName</param-name>
            <param-value>http://app2.cas.com:8082</param-value>
            <!-- 当前Client系统的地址 -->
        </init-param>
    </filter>
    <filter-mapping>
        <filter-name>CAS Validation Filter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

    <!--
    该过滤器负责实现HttpServletRequest请求的包裹,
    比如允许开发者通过HttpServletRequest的getRemoteUser()方法获得SSO登录用户的登录名,可选配置。
    -->
    <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>

    <!--
        该过滤器使得开发者可以通过org.jasig.cas.client.util.AssertionHolder来获取用户的登录名。
         比如AssertionHolder.getAssertion().getPrincipal().getName()
         或者request.getUserPrincipal().getName()
    -->
    <filter>
        <filter-name>CAS Assertion Thread Local Filter</filter-name>
        <filter-class>org.jasig.cas.client.util.AssertionThreadLocalFilter</filter-class>
    </filter>
    <filter-mapping>
        <filter-name>CAS Assertion Thread Local Filter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>
    <!-- ========================单点登录结束 ======================== -->

构建cas-client-spring-boot-starter

  1. 构建cas-client-spring-boot-starter
  • 创建一个maven项目cas-client-spring-boot-starter

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.hx.cas</groupId>
    <artifactId>cas-client-spring-boot-starter</artifactId>
    <version>1.0.0</version>
    <packaging>jar</packaging>
    <name>cas-client-spring-boot-starter</name>
    <description>自定义 cas SpringBoot client starter.</description>
    <!-- 引用依赖的父包 -->
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.0.0.RELEASE</version>
    </parent>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-autoconfigure</artifactId>
        </dependency>
        <dependency>
            <groupId>org.jasig.cas.client</groupId>
            <artifactId>cas-client-core</artifactId>
            <version>3.5.0</version>
        </dependency>
        <dependency>
            <groupId>javax.servlet</groupId>
            <artifactId>servlet-api</artifactId>
            <version>2.5</version>
        </dependency>
        <dependency>
            <groupId>javax.validation</groupId>
            <artifactId>validation-api</artifactId>
            <version>1.1.0.Final</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.5.1</version>
                <configuration>
                    <source>1.8</source>
                    <target>1.8</target>
                </configuration>
            </plugin>
            <!-- 配置该插件将源码放入仓库 -->
            <plugin>
                <artifactId>maven-source-plugin</artifactId>
                <version>2.1</version>
                <configuration>
                    <attach>true</attach>
                </configuration>
                <executions>
                    <execution>
                        <phase>compile</phase>
                        <goals>
                            <goal>jar</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>
  • CasClientConfigProperties.java
package com.hx.cas.client.configuration;

import org.springframework.boot.context.properties.ConfigurationProperties;

import javax.validation.constraints.NotNull;

/**
 * @author Aaron
 * @description cas springboot 配置类
 * @date 2020/9/16
 */
@ConfigurationProperties(prefix = "cas", ignoreUnknownFields = false)
public class CasClientConfigProperties {
    /**
     * CAS 服务端 url,不能为空
     */
    @NotNull
    private String serverUrlPrefix;

    /**
     * CAS 服务端登录地址,serverUrlPrefix加上/login,不能为空
     */
    @NotNull
    private String serverLoginUrl;

    /**
     * 当前客户端的地址,不能为空
     */
    @NotNull
    private String serverName;

    /**
     * 忽略规则,访问那些地址不需要登录,如 /js/*|/images/*|/css/*
     */
    private String ignorePattern;

    /**
     * 自定义UrlPatternMatcherStrategy验证,必须全类名
     */
    private String ignoreUrlPatternType;

    public String getServerUrlPrefix() {
        return serverUrlPrefix;
    }

    public void setServerUrlPrefix(String serverUrlPrefix) {
        this.serverUrlPrefix = serverUrlPrefix;
    }

    public String getServerLoginUrl() {
        return serverLoginUrl;
    }

    public void setServerLoginUrl(String serverLoginUrl) {
        this.serverLoginUrl = serverLoginUrl;
    }

    public String getServerName() {
        return serverName;
    }

    public void setServerName(String serverName) {
        this.serverName = serverName;
    }

    public String getIgnorePattern() {
        return ignorePattern;
    }

    public void setIgnorePattern(String ignorePattern) {
        this.ignorePattern = ignorePattern;
    }

    public String getIgnoreUrlPatternType() {
        return ignoreUrlPatternType;
    }

    public void setIgnoreUrlPatternType(String ignoreUrlPatternType) {
        this.ignoreUrlPatternType = ignoreUrlPatternType;
    }
}

  • CasClientConfiguration.java
package com.hx.cas.client.configuration;

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.context.properties.EnableConfigurationProperties;
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.EventListener;
import java.util.HashMap;
import java.util.Map;

/**
 * @author Aaron
 * @description cas客户端自动化配置
 * @date 2020/9/17
 */
@Configuration
@EnableConfigurationProperties(CasClientConfigProperties.class)
public class CasClientConfiguration {

    @Autowired
    CasClientConfigProperties configProps;

    /**
     * 该监听器用于实现单点登出功能,session失效监听器
     * @return
     */
    @Bean
    public ServletListenerRegistrationBean<EventListener> singleSignOutListenerRegistration(){
        ServletListenerRegistrationBean<EventListener> registrationBean = new ServletListenerRegistrationBean<EventListener>();
        registrationBean.setListener(new SingleSignOutHttpSessionListener());
        registrationBean.setOrder(1);
        return registrationBean;
    }

    /**
     * 该过滤器用于实现单点登出功能,当一个系统登出时,cas服务端会通知,各个应
     * 用进行进行退出操作,该过滤器就是用来接收cas回调的请求,如果是前后端分离
     * 应用,需要重写SingleSignOutFilter过滤器,按自已的业务规则去处理
     */
    @Bean
    public FilterRegistrationBean filterSingleRegistration() {
        final FilterRegistrationBean registration = new FilterRegistrationBean();
        registration.setFilter(new SingleSignOutFilter());
        // 设定匹配的路径
        registration.addUrlPatterns("/*");
        Map<String,String>  initParameters = new HashMap<String, String>();
        initParameters.put("casServerUrlPrefix", configProps.getServerUrlPrefix());
        registration.setInitParameters(initParameters);
        // 设定加载的顺序
        registration.setOrder(2);
        return registration;
    }

    /**
     * 配置授权过滤器,该过滤器负责用户的认证工作
     * @return
     */
    @Bean
    public FilterRegistrationBean filterAuthenticationRegistration() {
        final FilterRegistrationBean registration = new FilterRegistrationBean();
        registration.setFilter(new AuthenticationFilter());
        // 设定匹配的路径
        registration.addUrlPatterns("/*");
        Map<String,String>  initParameters = new HashMap();
        initParameters.put("casServerLoginUrl", configProps.getServerLoginUrl());
        initParameters.put("serverName", configProps.getServerName());
        // 静态资源路径,不会跳到cas去登录
        if(configProps.getIgnorePattern() != null && !"".equals(configProps.getIgnorePattern())){
            initParameters.put("ignorePattern", configProps.getIgnorePattern());
        }

        //自定义UrlPatternMatcherStrategy 验证规则
        if(configProps.getIgnoreUrlPatternType() != null && !"".equals(configProps.getIgnoreUrlPatternType())){
            initParameters.put("ignoreUrlPatternType", configProps.getIgnoreUrlPatternType());
        }

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

    /**
     * 配置过滤验证器 这里用的是Cas30ProxyReceivingTicketValidationFilter
     * 该过滤器负责对Ticket的校验工作
     * @return
     */
    @Bean
    public FilterRegistrationBean filterValidationRegistration() {
        final FilterRegistrationBean registration = new FilterRegistrationBean();
        registration.setFilter(new Cas30ProxyReceivingTicketValidationFilter());
        // 设定匹配的路径
        registration.addUrlPatterns("/*");
        Map<String,String> initParameters = new HashMap();
        initParameters.put("casServerUrlPrefix", configProps.getServerUrlPrefix());
        initParameters.put("serverName", configProps.getServerName());
        initParameters.put("useSession", "true");
        registration.setInitParameters(initParameters);
        // 设定加载的顺序
        registration.setOrder(4);
        return registration;
    }

    /**
     * request wraper过滤器
     * 该过滤器负责实现HttpServletRequest请求的包裹,
     * 比如允许开发者通过HttpServletRequest的getRemoteUser()方法获得SSO登录用户的登录名
     * @return
     */
    @Bean
    public FilterRegistrationBean filterWrapperRegistration() {
        final FilterRegistrationBean registration = new FilterRegistrationBean();
        registration.setFilter(new HttpServletRequestWrapperFilter());
        // 设定匹配的路径
        registration.addUrlPatterns("/*");
        // 设定加载的顺序
        registration.setOrder(5);
        return registration;
    }

    /**
     * 该过滤器使得开发者可以通过org.jasig.cas.client.util.AssertionHolder来获取用户的登录名。
     * 比如AssertionHolder.getAssertion().getPrincipal().getName()
     * 或者request.getUserPrincipal().getName()
     * 这个类把Assertion信息放在ThreadLocal变量中,这样应用程序不在web层也能够获取到当前登录信息
     * @return
     */
    @Bean
    public FilterRegistrationBean filterAssertionRegistration() {
        final FilterRegistrationBean registration = new FilterRegistrationBean();
        registration.setFilter(new AssertionThreadLocalFilter());
        // 设定匹配的路径
        registration.addUrlPatterns("/*");
        // 设定加载的顺序
        registration.setOrder(6);
        return registration;
    }

}

  • EnableCasClient.java
package com.hx.cas.client.configuration;

import org.springframework.context.annotation.Import;

import java.lang.annotation.*;

/**
 * @author Aaron
 * @description 配置该注解开启cas功能
 * @date 2020/9/17
 */
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@Import(CasClientConfiguration.class)
public @interface EnableCasClient {
}

然后在终端执行mvn build install命令,就会将cas-client-spring-boot-starter的jar包安装到本地maven仓库。

spring boot项目

  1. spring boot项目:
  • 引入第5部的jar包
<!--自定义cas的客户端 -->
<dependency>
    <groupId>com.hx.cas</groupId>
    <artifactId>cas-client-spring-boot-starter</artifactId>
    <version>1.0.0</version>
</dependency>
  • application.properties添加配置
#cas配置: 必配
cas.server-url-prefix=https://server.cas.com:8443/cas
cas.server-login-url=https://server.cas.com:8443/cas/login
cas.server-name=http://app2.cas.com:8082
#cas配置: 可选
cas.ignore-pattern=/js/*|/images/*|/view/*|/css/*
cas.ignore-url-pattern-type=com.aaron.cas.config.SimpleUrlPatternMatcherStrategy
  • 在spring boot的主启动文件上加上注解@EnableCasClient//开启cas即可。

4 前后端分离项目集成方案

遇到的问题:

  1. 前后端分离项目的静态页面是单独部署的,比如我们项目就用的nginx,访问任何一个页面后台都无法拦截到;
  2. 只有在通过ajax访问后台接口时,方可被Cas Client拦截器拦截,如果此时未登录,后台Cas拦截器会自动重定向到Cas Server的登录页面,而后台的重定向对ajax请求来说无效;
  3. 登录成功后跳转的第一个地址必须是可以被后台拦截到的地址(因为要通过Cas Client后台去Cas Server验证ST,并且在Cas Client后台生成session),因此不能是首页;
  4. 前端发起的后续请求必须与第一个地址属于同一个域(否则后续请求不能自动带上sessionid,Cas Client后台无法通过验证,导致死循环);

再说说思路吧:

  1. 修改Cas拦截器源码,检测到未认证时,不进行重定向,而是返回一个可以被前端捕获到的状态401;
  2. 前端要对ajax请求进行封装,所有ajax请求都通过封装的方法执行,方便进行统一处理,只要捕获到后台接口返回了401状态,就重定向到Cas Server的登录页面,url中带上登录成功后需跳转的第一个可以被Cas客户端拦截器拦截到的接口;
  3. 当在Cas Server的登录页面登录成功后,携带ticket(ST)跳转到该地址(接口),这时候Cas Client后台拦截到该请求,发现携带了ST,则带上该ST到Cas Server去验证ST的有效性,验证通过后生成session,然后就可以进入到该接口了,在该接口里可以做一些客户端的特殊处理,处理完后重定向到项目首页即可;
  4. 该接口最后只用来做重定向。

大致的思路如上,根据这个思路可以实现前后端分离项目的客户端单点登陆,这里贴出Cas拦截器AuthenticationFilter.java修改地方的代码,实际上只改了里面的doFilte()方法,把源码拷贝出来,按照如下方式修改后,覆盖进去即可:

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;
    // 存在assertion,即认为这是一个已通过认证的请求.予以放行
    if (assertion != null) {
        filterChain.doFilter(request, response);
        return;
    }
    // 不存在 assertion,那么就来判断这个请求是否是用来校验ST的(校验通过后会将信息写入assertion)
    final String serviceUrl = constructServiceUrl(request, response);
    final String ticket = retrieveTicketFromRequest(request);
    final boolean wasGatewayed = this.gateway && this.gatewayStorage.hasGatewayedAlready(request, serviceUrl);
    // 是校验ST的请求,予以放行
    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);
    // 要重定向界面地址(cas服务端登录界面).
    final String urlToRedirectTo = CommonUtils.constructRedirectUrl(this.casServerLoginUrl,
            getProtocol().getServiceParameterName(), modifiedServiceUrl, this.renew, this.gateway);

    logger.debug("redirecting to \"{}\"", urlToRedirectTo);
    
    // 修改这里---------->
    // 这里要先设置编码,再获取 PrintWriter, 因为getWriter 会先获取项目的 编码,根据编码来自己设定characterEncoding
    response.setContentType("application/json; charset=UTF-8");
    response.setCharacterEncoding("UTF-8");
    PrintWriter out = response.getWriter();
    response.setStatus(HttpStatus.UNAUTHORIZED.value());
    out.println("未通过认证,请重新登录");
    //this.authenticationRedirectStrategy.redirect(request, response, urlToRedirectTo);
}

2020-11-04更新

5 解决https客户端无法统一登出

  • 登出具体流程见前面章节 2.7;
  • 问题描述:该问题发生在一个cas client登出时,其他的http客户端都收到了登出通知,而https客户端并未收到通知,导致https客户端没有销毁session,未登出;
  • 排查过程:
    1. 修改logoutType为显式登出:"logoutType" : "FRONT_CHANNEL",(logoutType:分为FRONT_CHANNEL、BACK_CHANNEL,FRONT_CHANNEL:显式登出.cas直接通过前端发送http post请求到已认证服务;BACK_CHANNEL:隐式登出.cas发送异步请求到已认证服务.通过cas客户端使应用会话失效)这样可以登出,但这需要跳转到cas server的一个特定页面,不符合要求;
    2. 开启调试,跟踪源码,DefaultSingleLogoutServiceMessageHandler.handle()DefaultSingleLogoutServiceMessageHandler.performBackChannelLogout()SimpleHttpClient.sendMessageToEndPoint(final HttpMessage message)SimpleHttpClientFactoryBean等,最后定位到SimpleHttpClientFactoryBeanbuildHttpClient()方法。
  • 原因分析:cas server通过httpclient的FutureRequestExecutionService发送异步请求到各个已认证的服务,并且里面对请求出现的异常进行了处理,导致我们通过日志看不到正确的异常信息,这点蛮坑的,最后将源码抠出来,打印异常信息后发现,发送报的错误是
    HttpClient javax.net.ssl.SSLPeerUnverifiedException: Certificate doesn't match
    也就是ssl证书和实际的主机域名不匹配,其实是证书问题,不过整合到单点登陆的客户端各式各样,我们不去深究证书,直接从代码上关闭https请求的主机认证。
  • 解决方法:修改源码中的SimpleHttpClientFactoryBean类,关键代码如下
SSLConnectionSocketFactory sslsf = new SSLConnectionSocketFactory(
                sslcontext,
                new String[] { "TLSv1" },
                null,
                NoopHostnameVerifier.INSTANCE);//暂时关闭hostnameVerify

由于该类依赖了cas-server-core-util-api包,因此需要在cas server项目中导入该包

<dependency>
    <groupId>org.apereo.cas</groupId>
    <artifactId>cas-server-core-util-api</artifactId>
    <version>${cas.version}</version>
</dependency>

修改后的源码如下:SimpleHttpClientFactoryBean.java

package org.apereo.cas.util.http;

import org.apache.http.ConnectionReuseStrategy;
import org.apache.http.Header;
import org.apache.http.client.*;
import org.apache.http.config.Registry;
import org.apache.http.config.RegistryBuilder;
import org.apache.http.conn.socket.ConnectionSocketFactory;
import org.apache.http.conn.socket.PlainConnectionSocketFactory;
import org.apache.http.conn.ssl.NoopHostnameVerifier;
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
import org.apache.http.impl.DefaultConnectionReuseStrategy;
import org.apache.http.impl.client.*;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.FactoryBean;

import javax.annotation.PreDestroy;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.net.HttpURLConnection;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.CertificateException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

/**
 * The factory to build a {@link SimpleHttpClient}.
 *
 * @author Jerome Leleu
 * @since 4.1.0
 */
public class SimpleHttpClientFactoryBean implements FactoryBean<SimpleHttpClient> {

    private static final Logger LOGGER = LoggerFactory.getLogger(SimpleHttpClientFactoryBean.class);
    /**
     * Max connections per route.
     */
    public static final int MAX_CONNECTIONS_PER_ROUTE = 50;

    private static final int MAX_POOLED_CONNECTIONS = 100;

    private static final int DEFAULT_THREADS_NUMBER = 200;

    private static final int DEFAULT_TIMEOUT = 5000;

    /**
     * The default status codes we accept.
     */
    private static final int[] DEFAULT_ACCEPTABLE_CODES = new int[]{HttpURLConnection.HTTP_OK,
        HttpURLConnection.HTTP_NOT_MODIFIED, HttpURLConnection.HTTP_MOVED_TEMP,
        HttpURLConnection.HTTP_MOVED_PERM, HttpURLConnection.HTTP_ACCEPTED,
        HttpURLConnection.HTTP_NO_CONTENT};

    /**
     * 20% of the total of threads in the pool to handle overhead.
     */
    private static final int DEFAULT_QUEUE_SIZE = (int) (DEFAULT_THREADS_NUMBER * 0.2);

    /**
     * The number of threads used to build the pool of threads (if no executorService provided).
     */
    private int threadsNumber = DEFAULT_THREADS_NUMBER;

    /**
     * The queue size to absorb additional tasks when the threads pool is saturated (if no executorService provided).
     */
    private int queueSize = DEFAULT_QUEUE_SIZE;

    /**
     * The Max pooled connections.
     */
    private int maxPooledConnections = MAX_POOLED_CONNECTIONS;

    /**
     * The Max connections per each route connections.
     */
    private int maxConnectionsPerRoute = MAX_CONNECTIONS_PER_ROUTE;

    /**
     * List of HTTP status codes considered valid by the caller.
     */
    private List<Integer> acceptableCodes = IntStream.of(DEFAULT_ACCEPTABLE_CODES).boxed().collect(Collectors.toList());

    private long connectionTimeout = DEFAULT_TIMEOUT;

    private int readTimeout = DEFAULT_TIMEOUT;

    private RedirectStrategy redirectionStrategy = new DefaultRedirectStrategy();

    /**
     * The socket factory to be used when verifying the validity of the endpoint.
     */
    // 我们不用这个sslSocketFactory,在下面代码中自己创建一个
    private SSLConnectionSocketFactory sslSocketFactory = SSLConnectionSocketFactory.getSocketFactory();

    /**
     * The hostname verifier to be used when verifying the validity of the endpoint.
     */
    // 关闭https请求的主机认证
    private HostnameVerifier hostnameVerifier = NoopHostnameVerifier.INSTANCE;

    /**
     * The credentials provider for endpoints that require authentication.
     */
    private CredentialsProvider credentialsProvider;

    /**
     * The cookie store for authentication.
     */
    private CookieStore cookieStore;

    /**
     * Interface for deciding whether a connection can be re-used for subsequent requests and should be kept alive.
     **/
    private ConnectionReuseStrategy connectionReuseStrategy = new DefaultConnectionReuseStrategy();

    /**
     * When managing a dynamic number of connections for a given route, this strategy assesses whether a
     * given request execution outcome should result in a backoff
     * signal or not, based on either examining the Throwable that resulted or by examining
     * the resulting response (e.g. for its status code).
     */
    private ConnectionBackoffStrategy connectionBackoffStrategy = new DefaultBackoffStrategy();

    /**
     * Strategy interface that allows API users to plug in their own logic to control whether or not a retry
     * should automatically be done, how many times it should be retried and so on.
     */
    private ServiceUnavailableRetryStrategy serviceUnavailableRetryStrategy = new DefaultServiceUnavailableRetryStrategy();

    /**
     * Default headers to be sent.
     **/
    private Collection<? extends Header> defaultHeaders = new ArrayList<>(0);

    /**
     * Default strategy implementation for proxy host authentication.
     **/
    private AuthenticationStrategy proxyAuthenticationStrategy = new ProxyAuthenticationStrategy();

    /**
     * Determines whether circular redirects (redirects to the same location) should be allowed.
     **/
    private boolean circularRedirectsAllowed = true;

    /**
     * Determines whether authentication should be handled automatically.
     **/
    private boolean authenticationEnabled;

    /**
     * Determines whether redirects should be handled automatically.
     **/
    private boolean redirectsEnabled = true;

    /**
     * The executor service used to create a {@link #buildRequestExecutorService}.
     */
    private ExecutorService executorService;

    @Override
    public SimpleHttpClient getObject() {
        final CloseableHttpClient httpClient = buildHttpClient();
        final FutureRequestExecutionService requestExecutorService = buildRequestExecutorService(httpClient);
        final List<Integer> codes = this.acceptableCodes.stream().sorted().collect(Collectors.toList());
        return new SimpleHttpClient(codes, httpClient, requestExecutorService);
    }

    @Override
    public Class<?> getObjectType() {
        return SimpleHttpClient.class;
    }

    @Override
    public boolean isSingleton() {
        return false;
    }

    /**
     * Build a HTTP client based on the current properties.
     *
     * @return the built HTTP client
     */
    // 主要修改该方法,构建出一个关闭了https请求的主机认证的CloseableHttpClient
    private CloseableHttpClient buildHttpClient() {
        CloseableHttpClient client = HttpClients.custom().setConnectionManager(getConnManage()).build();
        return client;
    }

    /**
     * 绕过验证
     *
     * @return
     * @throws NoSuchAlgorithmException
     * @throws KeyManagementException
     */
    private static SSLContext createIgnoreVerifySSL() throws NoSuchAlgorithmException, KeyManagementException {
        SSLContext sc = SSLContext.getInstance("SSLv3");

        // 实现一个X509TrustManager接口,用于绕过验证,不用修改里面的方法
        X509TrustManager trustManager = new X509TrustManager() {
            @Override
            public void checkClientTrusted(
                    java.security.cert.X509Certificate[] paramArrayOfX509Certificate,
                    String paramString) throws CertificateException {
            }

            @Override
            public void checkServerTrusted(
                    java.security.cert.X509Certificate[] paramArrayOfX509Certificate,
                    String paramString) throws CertificateException {
            }

            @Override
            public java.security.cert.X509Certificate[] getAcceptedIssuers() {
                return null;
            }
        };

        sc.init(null, new TrustManager[] { trustManager }, null);
        return sc;
    }

    private static PoolingHttpClientConnectionManager getConnManage() {
        try {
            //采用绕过验证的方式处理https请求
            SSLContext sslcontext = createIgnoreVerifySSL();
            SSLConnectionSocketFactory sslsf = new SSLConnectionSocketFactory(
                    sslcontext,
                    new String[] { "TLSv1" },
                    null,
                    NoopHostnameVerifier.INSTANCE);//暂时关闭hostnameVerify
            //设置协议http和https对应的处理socket链接工厂的对象
            Registry<ConnectionSocketFactory> socketFactoryRegistry = RegistryBuilder.<ConnectionSocketFactory>create()
                    .register("http", PlainConnectionSocketFactory.INSTANCE)
                    .register("https", sslsf)
                    .build();
            PoolingHttpClientConnectionManager connManager = new PoolingHttpClientConnectionManager(socketFactoryRegistry);
            //HttpClients.custom().setConnectionManager(connManager);
            return connManager;
        } catch (Exception e) {
            e.printStackTrace();
            LOGGER.error(e.getMessage());
            return null;
        }
    }

    /**
     * Build a {@link FutureRequestExecutionService} from the current properties and a HTTP client.
     *
     * @param httpClient the provided HTTP client
     * @return the built request executor service
     */
    private FutureRequestExecutionService buildRequestExecutorService(final CloseableHttpClient httpClient) {
        if (this.executorService == null) {
            this.executorService = new ThreadPoolExecutor(this.threadsNumber, this.threadsNumber, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(this.queueSize));
        }
        return new FutureRequestExecutionService(httpClient, this.executorService);
    }

    /**
     * Destroy.
     */
    @PreDestroy
    public void destroy() {
        if (this.executorService != null) {
            this.executorService.shutdownNow();
            this.executorService = null;
        }
    }

    /**
     * The type Default http client.
     */
    public static class DefaultHttpClient extends SimpleHttpClientFactoryBean {
    }

    public void setThreadsNumber(final int threadsNumber) {
        this.threadsNumber = threadsNumber;
    }

    public void setQueueSize(final int queueSize) {
        this.queueSize = queueSize;
    }

    public void setMaxPooledConnections(final int maxPooledConnections) {
        this.maxPooledConnections = maxPooledConnections;
    }

    public void setMaxConnectionsPerRoute(final int maxConnectionsPerRoute) {
        this.maxConnectionsPerRoute = maxConnectionsPerRoute;
    }

    public void setAcceptableCodes(final List<Integer> acceptableCodes) {
        this.acceptableCodes = acceptableCodes;
    }

    public void setConnectionTimeout(final long connectionTimeout) {
        this.connectionTimeout = connectionTimeout;
    }

    public void setReadTimeout(final int readTimeout) {
        this.readTimeout = readTimeout;
    }

    public void setRedirectionStrategy(final RedirectStrategy redirectionStrategy) {
        this.redirectionStrategy = redirectionStrategy;
    }

    public void setSslSocketFactory(final SSLConnectionSocketFactory sslSocketFactory) {
        this.sslSocketFactory = sslSocketFactory;
    }

    public void setHostnameVerifier(final HostnameVerifier hostnameVerifier) {
        this.hostnameVerifier = hostnameVerifier;
    }

    public void setCredentialsProvider(final CredentialsProvider credentialsProvider) {
        this.credentialsProvider = credentialsProvider;
    }

    public void setCookieStore(final CookieStore cookieStore) {
        this.cookieStore = cookieStore;
    }

    public void setConnectionReuseStrategy(final ConnectionReuseStrategy connectionReuseStrategy) {
        this.connectionReuseStrategy = connectionReuseStrategy;
    }

    public void setConnectionBackoffStrategy(final ConnectionBackoffStrategy connectionBackoffStrategy) {
        this.connectionBackoffStrategy = connectionBackoffStrategy;
    }

    public void setServiceUnavailableRetryStrategy(final ServiceUnavailableRetryStrategy serviceUnavailableRetryStrategy) {
        this.serviceUnavailableRetryStrategy = serviceUnavailableRetryStrategy;
    }

    public void setDefaultHeaders(final Collection<? extends Header> defaultHeaders) {
        this.defaultHeaders = defaultHeaders;
    }

    public void setProxyAuthenticationStrategy(final AuthenticationStrategy proxyAuthenticationStrategy) {
        this.proxyAuthenticationStrategy = proxyAuthenticationStrategy;
    }

    public void setCircularRedirectsAllowed(final boolean circularRedirectsAllowed) {
        this.circularRedirectsAllowed = circularRedirectsAllowed;
    }

    public void setAuthenticationEnabled(final boolean authenticationEnabled) {
        this.authenticationEnabled = authenticationEnabled;
    }

    public void setRedirectsEnabled(final boolean redirectsEnabled) {
        this.redirectsEnabled = redirectsEnabled;
    }

    public void setExecutorService(final ExecutorService executorService) {
        this.executorService = executorService;
    }
}

评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值