「这是我参与2022首次更文挑战的第20天,活动详情查看:2022首次更文挑战」。
CAS
1. 简介
cas 是一套单点登录的具体实现,耶鲁大学开发, 目前最高版本为6.x (6.x使用gradle编译) 此处使用5.x版本(maven编译)
分为两部分,服务端和客户端
服务端就是统一的认证中心,客户端就是所有需要接入单点登录的系统
2.服务端
2.1 下载
cas不建议直接在源码上修改,提供了一个模板项目,在模板项目上做扩展
2.2 部署
项目拉下来之后,导入idea进行编译打包
或者直接使用
mvn clean package
打成war包 部署到tomcat下
启动Tomcat cas服务端就部署好了
3.客户端
3.1简单接入
-
增加依赖
-
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-test</artifactId> </dependency> <dependency> <groupId>net.unicon.cas</groupId> <artifactId>cas-client-autoconfig-support</artifactId> </dependency> </dependencies>
-
-
配置过滤器
-
package com.zhangyao.cas.config; import org.apache.tomcat.util.net.openssl.ciphers.Authentication; import org.jasig.cas.client.authentication.AuthenticationFilter; import org.jasig.cas.client.session.SingleSignOutFilter; import org.jasig.cas.client.session.SingleSignOutHttpSessionListener; import org.jasig.cas.client.util.AssertionThreadLocalFilter; import org.jasig.cas.client.util.HttpServletRequestWrapperFilter; import org.jasig.cas.client.validation.Cas30ProxyReceivingTicketValidationFilter; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.boot.web.servlet.ServletListenerRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.util.ArrayList; import java.util.HashMap; import java.util.List; /** * @author: zhangyao * @create:2020-05-08 22:52 **/ @Configuration public class CasConfig { @Bean public SingleSignOutHttpSessionListener singleSignOutHttpSessionListener(){ return new SingleSignOutHttpSessionListener(); } /** * 监听退出 * @return */ @Bean public ServletListenerRegistrationBean<SingleSignOutHttpSessionListener> singleSignOutHttpSessionListenerServletListenerRegistrationBean(){ ServletListenerRegistrationBean<SingleSignOutHttpSessionListener> listenerServletListenerRegistrationBean = new ServletListenerRegistrationBean<>(); listenerServletListenerRegistrationBean.setEnabled(true); listenerServletListenerRegistrationBean.setListener(singleSignOutHttpSessionListener()); listenerServletListenerRegistrationBean.setOrder(1); return listenerServletListenerRegistrationBean; } /** * 登出拦截器 * @return */ @Bean public FilterRegistrationBean<SingleSignOutFilter> singleSignOutFilterBean(){ FilterRegistrationBean<SingleSignOutFilter> filterRegistrationBean = new FilterRegistrationBean(); filterRegistrationBean.setFilter(new SingleSignOutFilter()); filterRegistrationBean.setEnabled(true); filterRegistrationBean.addUrlPatterns("/*"); filterRegistrationBean.setOrder(1); HashMap<String,String> map = new HashMap<>(); map.put("casServerUrlPrefix","http://127.0.0.1:8085/cas_overlay_template_war/"); filterRegistrationBean.setInitParameters(map); return filterRegistrationBean; } /** * 授权 * @return */ @Bean public FilterRegistrationBean filterRegistrationBean(){ FilterRegistrationBean registrationBean = new FilterRegistrationBean(); registrationBean.setFilter(new AuthenticationFilter()); registrationBean.addUrlPatterns("/*"); HashMap<String,String> map = new HashMap<>(); map.put("casServerLoginUrl","http://127.0.0.1:8085/cas_overlay_template_war/login"); map.put("casServerUrlPrefix","http://127.0.0.1:8085/cas_overlay_template_war/"); map.put("serverName", "http://127.0.0.1:8086"); map.put("ignorePattern", "/index"); registrationBean.setInitParameters(map); registrationBean.setOrder(2); return registrationBean; } /** * 验证票据 * @return */ @Bean public FilterRegistrationBean validationFilter(){ FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean(); filterRegistrationBean.setFilter(new Cas30ProxyReceivingTicketValidationFilter()); HashMap<String,String> map = new HashMap<>(); map.put("casServerUrlPrefix","http://127.0.0.1:8085/cas_overlay_template_war/"); map.put("serverName", "http://127.0.0.1:8086"); map.put("ignorePattern", "/cas_overlay_template_war/*,/index"); filterRegistrationBean.setInitParameters(map); filterRegistrationBean.setOrder(1); filterRegistrationBean.addUrlPatterns("/*"); return filterRegistrationBean; } // 取用户信息 @Bean public FilterRegistrationBean casHttpServletRequestWrapperFilter() { FilterRegistrationBean authenticationFilter = new FilterRegistrationBean(); authenticationFilter.setFilter(new HttpServletRequestWrapperFilter()); authenticationFilter.setOrder(1); List<String> urlPatterns = new ArrayList<String>(); urlPatterns.add("/*");// 设置匹配的url authenticationFilter.setUrlPatterns(urlPatterns); return authenticationFilter; } // 取用户信息 @Bean public FilterRegistrationBean casAssertionThreadLocalFilter() { FilterRegistrationBean authenticationFilter = new FilterRegistrationBean(); authenticationFilter.setFilter(new AssertionThreadLocalFilter()); authenticationFilter.setOrder(1); List<String> urlPatterns = new ArrayList<String>(); urlPatterns.add("/*");// 设置匹配的url authenticationFilter.setUrlPatterns(urlPatterns); return authenticationFilter; } }
-
-
开启注解
@SpringBootApplication @EnableCasClient public class TestApplication { public static void main(String[] args) { SpringApplication.run(TestApplication.class, args); } }
4.单点登出
实现的效果是: 每一个客户端登出的时候,cas服务端也需要登出,从而达到一端登出,多端登出的效果
实现方法: 客户端发送登出请求时,后台跳转cas服务端登出路径
package com.zhangyao.cas.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
import javax.servlet.http.HttpSession;
/**
* @author: zhangyao
* @create:2020-05-08 22:58
**/
@Controller
@RequestMapping("")
public class LoginController {
@Autowired
RestTemplate restTemplate;
@GetMapping("/loginOut")
public String test(HttpSession session){
session.invalidate();
// String forObject = restTemplate.getForObject("http://127.0.0.1:8085/cas_overlay_template_war/logout", String.class);
return "redirect:http://127.0.0.1:8085/cas_overlay_template_war/logout?service=http://127.0.0.1:8086/index";
}
}
5.cas服务端overlays 打包方式
上文中说到下载后直接mvn clean package即可打包部署,但是当我们想要对服务端进行一些扩展,比如修改默认登录页,修改登录的验证方式等
有两种方法:
- 在package后的war包中直接修改对应的文件,部署后可直接生效 缺点是每次重新打包修改的文件都会被覆盖
- 使用cas官方提供的cas_overlay_template项目,也就是我们上文中下载的项目,使用maven 的overlay方式进行合并打包
具体分析overlays
pom.xml中的配置
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-war-plugin</artifactId>
<version>2.6</version>
<configuration>
<warName>cas</warName>
<failOnMissingWebXml>false</failOnMissingWebXml>
<recompressZippedFiles>false</recompressZippedFiles>
<archive>
<compress>false</compress>
<manifestFile>${manifestFileToUse}</manifestFile>
</archive>
<overlays>
<overlay>
<groupId>org.apereo.cas</groupId>
<artifactId>cas-server-webapp${app.server}</artifactId>
</overlay>
</overlays>
</configuration>
</plugin>
这里的overlay配置的意思是使用我们主项目中的同名同路径文件覆盖cas-server-webapp${app.server}下的文件
具体操作方式如上图
在根项目下新建 src/main/resources作为资源目录
src/main/java作为根目录
官网中也有介绍
修改配置文件时从war包中沾出来再修改 比如修改cas服务端支持http访问
复制出这两个文件
并修改HTTPSandIMAPS-10000001.json
{
"@class" : "org.apereo.cas.services.RegexRegisteredService",
"serviceId" : "^(http|https|imaps)://.*", #增加http
"name" : "HTTPS and IMAPS",
"id" : 10000001,
"description" : "This service definition authorizes all application urls that support HTTPS and IMAPS protocols.",
"evaluationOrder" : 10000
}
修改application.properties
在最后加上
#支持http访问
cas.tgc.secure=false
cas.serviceRegistry.initFromJson=true
#支持退出自定义跳转页面
cas.logout.followServiceRedirects=true
cas.logout.redirectParameter=service
cas.logout.confirmLogout=false
6.源码原理分析
cas官网流程图
6.1客户端流程图解析:
ST:service-ticket cas服务端签发的票据,用于浏览器访问不同系统时携带,cas服务端使用此票据验证是否有效用户
TGC:全局session的key,存放于浏览器的cookie中,访问cas服务端时使用
TGT:全局session 存放于cas服务端中
- 第一次访问第一个客户端,客户端校验请求中是否含有ST(service_ticket,全局session标识),没有此标识就返回302重定向请求(重定向到cas服务端)给浏览器
- 浏览器访问cas服务端,服务端校验是否有TGC,如果没有,返回登录页给浏览器
- 浏览器展示cas服务端登录页,登录后发送post登录请求给cas服务端,服务端校验登录通过后,创建全局session TGT并签发ST,浏览器写入TGC 并返回302重定向请求(之前访问第一个客户端的路径)给浏览器
- 浏览器携带ST请求再次请求第一个客户端,客户端再次校验是否含有ST,并发送请求到cas服务端验证ST是否有效,如果有效,就创建局部session;至此浏览器与第一个客户端建立session会话
- 再次访问第一个客户端,这个时候客户端已经存在session会话,会直接验证会话的有效性,不再跳转登录页
- 此时浏览器再次访问第二个客户端,由于此时第二个客户端还没有与浏览器建立局部会话,所以会将请求重定向至cas服务端,但是浏览器访问cas服务端时会携带TGC,cas服务端校验通过后签发ST再302返回至浏览器,浏览器再次访问第二个客户端建立连接
6.2客户端源码分析:
6.2.1 授权过滤器
验证是否授权的过滤器,也即上文中配置的授权过滤器
org.jasig.cas.client.authentication.AuthenticationFilter
进入doFilter
public final void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest)servletRequest;
HttpServletResponse response = (HttpServletResponse)servletResponse;
//判断是否是不拦截请求,如果是不拦截请求直接放行
if (this.isRequestUrlExcluded(request)) {
this.logger.debug("Request is ignored.");
filterChain.doFilter(request, response);
} else {
//判断是否与浏览器建立有会话,如果有直接放行
HttpSession session = request.getSession(false);
Assertion assertion = session != null ? (Assertion)session.getAttribute("_const_cas_assertion_") : null;
if (assertion != null) {
filterChain.doFilter(request, response);
} else {
//如果既不是不拦截请求,也没有session,就需要重定向至cas服务端登录
String serviceUrl = this.constructServiceUrl(request, response);
String ticket = this.retrieveTicketFromRequest(request);
boolean wasGatewayed = this.gateway && this.gatewayStorage.hasGatewayedAlready(request, serviceUrl);
if (!CommonUtils.isNotBlank(ticket) && !wasGatewayed) {
this.logger.debug("no ticket and no assertion found");
String modifiedServiceUrl;
if (this.gateway) {
this.logger.debug("setting gateway attribute in session");
modifiedServiceUrl = this.gatewayStorage.storeGatewayInformation(request, serviceUrl);
} else {
modifiedServiceUrl = serviceUrl;
}
this.logger.debug("Constructed service url: {}", modifiedServiceUrl);
String urlToRedirectTo = CommonUtils.constructRedirectUrl(this.casServerLoginUrl, this.getProtocol().getServiceParameterName(), modifiedServiceUrl, this.renew, this.gateway);
this.logger.debug("redirecting to \"{}\"", urlToRedirectTo);
//此处重定向至登录请求
this.authenticationRedirectStrategy.redirect(request, response, urlToRedirectTo);
} else {
filterChain.doFilter(request, response);
}
}
}
}
6.2.2 验证ticket过滤器
org.jasig.cas.client.validation.AbstractTicketValidationFilter
进入doFilter
public final void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
//preFilter 执行过滤器前可以进行一些操作 源码中直接返回true了
if (this.preFilter(servletRequest, servletResponse, filterChain)) {
HttpServletRequest request = (HttpServletRequest)servletRequest;
HttpServletResponse response = (HttpServletResponse)servletResponse;
//获取ticket
String ticket = this.retrieveTicketFromRequest(request);
if (CommonUtils.isNotBlank(ticket)) {
this.logger.debug("Attempting to validate ticket: {}", ticket);
try {
//发送请求到cas服务端验证ticket是否有效
Assertion assertion = this.ticketValidator.validate(ticket, this.constructServiceUrl(request, response));
this.logger.debug("Successfully authenticated user: {}", assertion.getPrincipal().getName());
//验证通过后设置属性
request.setAttribute("_const_cas_assertion_", assertion);
//如果是已经登录过有会话,更新session中的属性
if (this.useSession) {
request.getSession().setAttribute("_const_cas_assertion_", assertion);
}
//验证成功进行一些操作,源码中为空
this.onSuccessfulValidation(request, response, assertion);
//redirectAfterValidation 源码中定义为true
if (this.redirectAfterValidation) {
this.logger.debug("Redirecting after successful ticket validation.");
//重定向至之前的访问请求
response.sendRedirect(this.constructServiceUrl(request, response));
return;
}
} catch (TicketValidationException var8) {//如果有异常 则返回错误403
this.logger.debug(var8.getMessage(), var8);
this.onFailedValidation(request, response);
if (this.exceptionOnValidationFailure) {
throw new ServletException(var8);
}
response.sendError(403, var8.getMessage());
return;
}
}
filterChain.doFilter(request, response);
}
}
6.2.3 获取用户信息过滤器
org.jasig.cas.client.util.HttpServletRequestWrapperFilter
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
//从session/request中获取Principal用户信息
AttributePrincipal principal = this.retrievePrincipalFromSessionOrRequest(servletRequest);
//CasHttpServletRequestWrapper是此类中定义的内部类,继承了HttpServletRequestWrapperFilter 增加了获取用户的方法 之后获取用户信息可以直接通过 request.getUserPrincipal()获取用户信息
filterChain.doFilter(new HttpServletRequestWrapperFilter.CasHttpServletRequestWrapper((HttpServletRequest)servletRequest, principal), servletResponse);
}
6.3 服务端源码分析
7.自定义操作
7.1 数据库查询用户登录
-
修改application.properties,增加如下配置
#查询用户密码的sql cas.authn.jdbc.query[0].sql=SELECT * FROM cof_user WHERE name=? #数据库连接 cas.authn.jdbc.query[0].url=jdbc:mysql://122.51.97.53:3306/coframe?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&useSSL=false #数据库用户 cas.authn.jdbc.query[0].user=root #数据库密码 cas.authn.jdbc.query[0].password=tlqaz1234 #数据库驱动 cas.authn.jdbc.query[0].driverClass=com.mysql.jdbc.Driver #标识sql查出来的哪个字段是password cas.authn.jdbc.query[0].fieldPassword=PASSWORD -
pom.xml增加jar包
<dependency> <groupId>org.apereo.cas</groupId> <artifactId>cas-server-support-jdbc</artifactId> <version>${cas.version}</version> </dependency> <dependency> <groupId>org.apereo.cas</groupId> <artifactId>cas-server-support-jdbc-drivers</artifactId> <version>${cas.version}</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>6.0.6</version> </dependency> <dependency> <groupId>org.apereo.cas</groupId> <artifactId>cas-server-support-jdbc-authentication</artifactId> <version>5.2.6</version> <type>pom</type> </dependency>7.2 自定义加密类
#自定义加密类 cas.authn.jdbc.query[0].passwordEncoder.type=com.zhangyao.cas.CustomPasswordEncoder定义加密类 并实现import org.springframework.security.crypto.password.PasswordEncoder
此处我采用的是 org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder spring security默认的加密类