目录
1 cas原理及概念
最近项目上要用到单点登录,因此针对性的学习了下cas,这里只做简单记录,便于后期需要的时候查阅。cas乃目前比较流行的企业单点登录业务整合的解决方案之一,在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。
-
从总体上看,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 骨架搭建
-
下载cas
官方Github地址,下载指定版本,我用的5.3,将下载下来的压缩包解压,然后进入解压出来的目录,使用maven命令编译,执行mvn clean package
,结束之后会出现 target 文件夹,里面有一个cas.war包,这个war包就是我们要运行的程序。 -
添加域名映射
修改/etc/hosts文件,添加服务端域名(server.cas.com) 以及两个客户端的域名(app1.cas.com , app2.cas.com) -
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浏览器信任证书
-
启动CAS服务
将第一步编译好的cas.war部署到tomcat中启动,然后访问https://server.cas.com:8443/cas/login ,目前这个服务端只能看看,没什么实际用途。 -
使用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>
- 重写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();
}
}
- 新建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。
- 注册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;
}
}
- 自定义登录验证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;
}
}
- 注册验证器
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());
}
}
- 加载配置类
在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
- 添加依赖
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.4.0</version>
</dependency>
- 自定义验证器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;
}
}
- 注册验证器并添加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加载该配置类。
- 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文件了,直接拷贝过来,在基础上进行修改)。
- 自定义异常类
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);
}
}
- 在application.properties中添加异常信息
#自定义错误信息,多个用逗号隔开即可
cas.authn.exceptions.exceptions=com.aaron.cas.exception.CaptchaErrorException
- 配置messages_zh_CN.properties
messages_zh_CN.properties是从编译好的cas中拷贝过来的,直接拷贝到resourece根路径下,并添加自己的异常:
# 自己添加的
authenticationFailure.CaptchaErrorException=验证码错误
2.5 自定义返回信息给客户端
- 首先要在注册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" ] ]
}
}
- 修改表单处理器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 自定义登录页面
- 主题规范
- 静态资源(js,css)存放目录为src/main/resources/static
- html资源(thymeleaf)存放目录为src/main/resources/templates
- 主题配置文件存放在src/main/resources并且命名为[theme_name].properties
- 主题页面html存放目录为src/main/resources/templates/
- 在客户端注册的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"
}
- 在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>
- 根据上面配置创建cas.css文件
如客户端1:
h2 {
color: red;
}
客户端2:
h2 {
color: pink;
}
- 在application.properties中添加以下属性,配置默认主题
# 默认主题
cas.theme.defaultThemeName=app1
- 然后在不同主题的登录页面引用不同的样式文件即可。目录结构如下:
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);
}
具体流程为:
- 认证通过后,Cas Server除了重定向回Cas Client,还会注册服务,就是为了让Cas Server知道有哪些Cas Client在这里登陆过;
- 携带ticket(ST)回跳到Cas Client后,Cas Client判断参数中是否携带了ticket,如果有,singleSignOutFilter注册将ticket作为id的session到sessionMappingStorage(是一个map,key为ticket,value为session);
- 当用户访问Cas Server的/logout登出时,Cas Server先将TGT干掉,然后给之前注册过那些服务的地址发送退出登录的请求,并且携带之前登录的ticket;
- Cas Client的singleSignOutFilter根据传过来的这个 ticket 来将对应的用户 session 注销掉;
- 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:定义多个服务的执行顺序。
- 修改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>
- 客户端导入证书
必须保证客户端证书和服务端证书是同一个证书,不然就会报错。
keytool -import -keystore %JAVA_HOME%/jre/lib/security/cacerts -file /key/sso.cer -alias sso -storepass changeit
将%JAVA_HOME%替换为自己环境中的java_home路径
- 导包
<dependency>
<groupId>org.jasig.cas.client</groupId>
<artifactId>cas-client-core</artifactId>
<version>3.5.0</version>
</dependency>
传统项目
- 传统项目:修改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
- 构建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项目
- 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 前后端分离项目集成方案
遇到的问题:
- 前后端分离项目的静态页面是单独部署的,比如我们项目就用的nginx,访问任何一个页面后台都无法拦截到;
- 只有在通过ajax访问后台接口时,方可被Cas Client拦截器拦截,如果此时未登录,后台Cas拦截器会自动重定向到Cas Server的登录页面,而后台的重定向对ajax请求来说无效;
- 登录成功后跳转的第一个地址必须是可以被后台拦截到的地址(因为要通过Cas Client后台去Cas Server验证ST,并且在Cas Client后台生成session),因此不能是首页;
- 前端发起的后续请求必须与第一个地址属于同一个域(否则后续请求不能自动带上sessionid,Cas Client后台无法通过验证,导致死循环);
再说说思路吧:
- 修改Cas拦截器源码,检测到未认证时,不进行重定向,而是返回一个可以被前端捕获到的状态401;
- 前端要对ajax请求进行封装,所有ajax请求都通过封装的方法执行,方便进行统一处理,只要捕获到后台接口返回了401状态,就重定向到Cas Server的登录页面,url中带上登录成功后需跳转的第一个可以被Cas客户端拦截器拦截到的接口;
- 当在Cas Server的登录页面登录成功后,携带ticket(ST)跳转到该地址(接口),这时候Cas Client后台拦截到该请求,发现携带了ST,则带上该ST到Cas Server去验证ST的有效性,验证通过后生成session,然后就可以进入到该接口了,在该接口里可以做一些客户端的特殊处理,处理完后重定向到项目首页即可;
- 该接口最后只用来做重定向。
大致的思路如上,根据这个思路可以实现前后端分离项目的客户端单点登陆,这里贴出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,未登出;
- 排查过程:
- 修改logoutType为显式登出:
"logoutType" : "FRONT_CHANNEL",
(logoutType:分为FRONT_CHANNEL、BACK_CHANNEL,FRONT_CHANNEL:显式登出.cas直接通过前端发送http post请求到已认证服务;BACK_CHANNEL:隐式登出.cas发送异步请求到已认证服务.通过cas客户端使应用会话失效)这样可以登出,但这需要跳转到cas server的一个特定页面,不符合要求; - 开启调试,跟踪源码,
DefaultSingleLogoutServiceMessageHandler.handle()
、DefaultSingleLogoutServiceMessageHandler.performBackChannelLogout()
、SimpleHttpClient.sendMessageToEndPoint(final HttpMessage message)
、SimpleHttpClientFactoryBean
等,最后定位到SimpleHttpClientFactoryBean
的buildHttpClient()
方法。
- 修改logoutType为显式登出:
- 原因分析: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;
}
}