什么是单点登录?单点登录全称Single Sign On(以下简称SSO),是指在多系统应用群中登录一个系统,便可在其他所有系统中得到授权而无需再次登录,包括单点登录与单点注销两部分.我们常见的实现框架有OAuth2和cas. 我下面的例子是基于其他博客的作者发给我的一个可运行示例进行一些说明, 实际上也就一个关键点, 一直困扰我们.
cas服务5.2
springboot2.0.3
vue2.0
Tomcat8.5.39
这里需要注意, 我在内网运行cas服务端的时候,发现Tomcat8.0.59跑不起来, 换到8.5就可以了, 我猜想可能是低版本Tomcat没法运行CAS5.x, 这里还需要去证实
不想配置https
我在网上看了很多人例子, 里面都有配置https, 这比较麻烦还要去生成证书再去配置到Tomcat里, 其实在CAS的服务里修改一个配置加上http就可以了
解压他的war包(或者你用Tomcat启动一下他自己就会解压了), 然后进入WEB-INF\classes\services找到HTTPSandIMAPS-10000001.json
{
"@class" : "org.apereo.cas.services.RegexRegisteredService",
"serviceId" : "^(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
}
“serviceId” : “^(https|imaps|http)/.*”,
<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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0modelVersion>
<parent>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-parentartifactId>
<version>2.0.3.RELEASEversion>
<relativePath/>
parent>
<groupId>com.casclient1groupId>
<artifactId>casartifactId>
<version>0.0.1-SNAPSHOTversion>
<name>casname>
<description>Demo project for Spring Bootdescription>
<properties>
<java.version>1.8java.version>
properties>
<dependencies>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-thymeleafartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>org.mybatis.spring.bootgroupId>
<artifactId>mybatis-spring-boot-starterartifactId>
<version>2.1.4version>
dependency>
<dependency>
<groupId>org.jasig.cas.clientgroupId>
<artifactId>cas-client-coreartifactId>
<version>3.3.2version>
dependency>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
<optional>trueoptional>
dependency>
dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-maven-pluginartifactId>
plugin>
plugins>
build>
project>
这里我只贴核心代码和对代码进行具体说明, 如有需要请看文尾说明
cas因为不支持前后端分离,所以需要重写里面的配置
MyAuthenticationFilter
import org.jasig.cas.client.authentication.*;
import org.jasig.cas.client.util.AbstractCasFilter;
import org.jasig.cas.client.util.CommonUtils;
import org.jasig.cas.client.util.ReflectUtils;
import org.jasig.cas.client.validation.Assertion;
import javax.servlet.FilterConfig;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
/**
*@author 胡百成 2021/6/10
* @description
*/
public class MyAuthenticationFilter extends AbstractCasFilter {
private String casServerLoginUrl;
private boolean renew = false;
private boolean gateway = false;
private GatewayResolver gatewayStorage = new DefaultGatewayResolverImpl();
private AuthenticationRedirectStrategy authenticationRedirectStrategy = new DefaultAuthenticationRedirectStrategy();
private UrlPatternMatcherStrategy ignoreUrlPatternMatcherStrategyClass = null;
private static final Map<String, Class<? extends UrlPatternMatcherStrategy>> PATTERN_MATCHER_TYPES = new HashMap<>();
public MyAuthenticationFilter() {
}
@Override
protected void initInternal(FilterConfig filterConfig) throws ServletException {
if (!this.isIgnoreInitConfiguration()) {
super.initInternal(filterConfig);
this.setCasServerLoginUrl(this.getPropertyFromInitParams(filterConfig, "casServerLoginUrl", (String)null));
this.logger.trace("Loaded CasServerLoginUrl parameter: {}", this.casServerLoginUrl);
this.setRenew(this.parseBoolean(this.getPropertyFromInitParams(filterConfig, "renew", "false")));
this.logger.trace("Loaded renew parameter: {}", this.renew);
this.setGateway(this.parseBoolean(this.getPropertyFromInitParams(filterConfig, "gateway", "false")));
this.logger.trace("Loaded gateway parameter: {}", this.gateway);
String ignorePattern = this.getPropertyFromInitParams(filterConfig, "ignorePattern", (String)null);
this.logger.trace("Loaded ignorePattern parameter: {}", ignorePattern);
String ignoreUrlPatternType = this.getPropertyFromInitParams(filterConfig, "ignoreUrlPatternType", "REGEX");
this.logger.trace("Loaded ignoreUrlPatternType parameter: {}", ignoreUrlPatternType);
if (ignorePattern != null) {
Class ignoreUrlMatcherClass = PATTERN_MATCHER_TYPES.get(ignoreUrlPatternType);
if (ignoreUrlMatcherClass != null) {
this.ignoreUrlPatternMatcherStrategyClass = ReflectUtils.newInstance(ignoreUrlMatcherClass.getName(), new Object[0]);
} else {
try {
this.logger.trace("Assuming {} is a qualified class name...", ignoreUrlPatternType);
this.ignoreUrlPatternMatcherStrategyClass = ReflectUtils.newInstance(ignoreUrlPatternType, new Object[0]);
} catch (IllegalArgumentException var6) {
this.logger.error("Could not instantiate class [{}]", ignoreUrlPatternType, var6);
}
}
if (this.ignoreUrlPatternMatcherStrategyClass != null) {
this.ignoreUrlPatternMatcherStrategyClass.setPattern(ignorePattern);
}
}
String gatewayStorageClass = this.getPropertyFromInitParams(filterConfig, "gatewayStorageClass", (String)null);
if (gatewayStorageClass != null) {
this.gatewayStorage = ReflectUtils.newInstance(gatewayStorageClass, new Object[0]);
}
String authenticationRedirectStrategyClass = this.getPropertyFromInitParams(filterConfig, "authenticationRedirectStrategyClass", (String)null);
if (authenticationRedirectStrategyClass != null) {
this.authenticationRedirectStrategy = ReflectUtils.newInstance(authenticationRedirectStrategyClass, new Object[0]);
}
}
}
@Override
public void init() {
super.init();
CommonUtils.assertNotNull(this.casServerLoginUrl, "casServerLoginUrl cannot be null.");
}
@Override
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 {
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 xRequested =request.getHeader("x-requested-with");
if("XMLHttpRequest".equals(xRequested)){
response.getWriter().write("{\"code\":202, \"msg\":\"no ticket and no assertion found\", \"url\":\"http://localhost:1234/checkTicket\"}");
}else{
String urlToRedirectTo = CommonUtils.constructRedirectUrl(this.casServerLoginUrl, this.getServiceParameterName(), modifiedServiceUrl, this.renew, this.gateway);
this.logger.debug("redirecting to \"{}\"", urlToRedirectTo);
this.authenticationRedirectStrategy.redirect(request, response, urlToRedirectTo);
}
} else {
filterChain.doFilter(request, response);
}
}
}
}
public final void setRenew(boolean renew) {
this.renew = renew;
}
public final void setGateway(boolean gateway) {
this.gateway = gateway;
}
public final void setCasServerLoginUrl(String casServerLoginUrl) {
this.casServerLoginUrl = casServerLoginUrl;
}
public final void setGatewayStorage(GatewayResolver gatewayStorage) {
this.gatewayStorage = gatewayStorage;
}
private boolean isRequestUrlExcluded(HttpServletRequest request) {
if (this.ignoreUrlPatternMatcherStrategyClass == null) {
return false;
} else {
StringBuffer urlBuffer = request.getRequestURL();
if (request.getQueryString() != null) {
urlBuffer.append("?").append(request.getQueryString());
}
String requestUri = urlBuffer.toString();
return this.ignoreUrlPatternMatcherStrategyClass.matches(requestUri);
}
}
static {
PATTERN_MATCHER_TYPES.put("CONTAINS", ContainsPatternUrlPatternMatcherStrategy.class);
PATTERN_MATCHER_TYPES.put("REGEX", RegexUrlPatternMatcherStrategy.class);
PATTERN_MATCHER_TYPES.put("EXACT", ExactUrlPatternMatcherStrategy.class);
}
}
FilterConfig
import org.jasig.cas.client.session.SingleSignOutFilter;
import org.jasig.cas.client.util.HttpServletRequestWrapperFilter;
import org.jasig.cas.client.validation.Cas20ProxyReceivingTicketValidationFilter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.web.filter.CharacterEncodingFilter;
import javax.servlet.Filter;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Configuration
public class FilterConfig implements Serializable, InitializingBean {
private static final Logger LOGGER = LoggerFactory.getLogger(FilterConfig.class);
public static final String CAS_SIGNOUT_FILTER_NAME = "CAS Single Sign Out Filter";
public static final String CAS_AUTH_FILTER_NAME = "CAS Filter";
public static final String CAS_IGNOREL_SSL_FILTER_NAME = "CAS Ignore SSL Filter";
public static final String CAS_FILTER_NAME = "CAS Validation Filter";
public static final String CAS_WRAPPER_NAME = "CAS HttpServletRequest Wrapper Filter";
public static final String CAS_ASSERTION_NAME = "CAS Assertion Thread Local Filter";
public static final String CHARACTER_ENCODING_NAME = "Character encoding Filter";
private static String casSigntouServerUrlPrefix = "http://localhost/cas/logout";
private static String casServerLoginUrl = "http://localhost:8045/cas/login";
private static String clienthosturl="http://localhost:1234";
private static String casValidationServerUrlPrefix = "http://localhost:8045/cas";
public FilterConfig() {
}
/**
* 单点登出功能,放在其他filter之前
* casSigntouServerUrlPrefix为登出前缀:https://123.207.122.156:8081/cas/logout
*
* @return
*/
@Bean
@Order(0)
public FilterRegistrationBean getCasSignoutFilterRegistrationBean() {
FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setFilter(getCasSignoutFilter());
Map<String, String> initParameters = new HashMap<String, String>();
initParameters.put("casServerLoginUrl", casServerLoginUrl);
initParameters.put("serverName", clienthosturl);
//忽略的url,"|"分隔多个url
initParameters.put("ignorePattern", "/logout/success|/index|/test|/login");
registration.setInitParameters(initParameters);
registration.addUrlPatterns("/*", "*.html");
registration.addInitParameter("casServerUrlPrefix", casSigntouServerUrlPrefix);
registration.setName(CAS_SIGNOUT_FILTER_NAME);
registration.setEnabled(true);
return registration;
}
@Bean(name = CAS_SIGNOUT_FILTER_NAME)
public Filter getCasSignoutFilter() {
return new SingleSignOutFilter();
}
/**
* 忽略SSL认证
*
* @return
*/
@Bean
@Order(1)
public FilterRegistrationBean getCasSkipSSLValidationFilterRegistrationBean() {
FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setFilter(getCasSkipSSLValidationFilter());
registration.addUrlPatterns("/*", "*.html");
registration.setName(CAS_IGNOREL_SSL_FILTER_NAME);
registration.setEnabled(true);
return registration;
}
@Bean(name = CAS_IGNOREL_SSL_FILTER_NAME)
public Filter getCasSkipSSLValidationFilter() {
return new IgnoreSSLValidateFilter();
}
/**
* 负责用户的认证
* casServerLoginUrl:https://123.207.122.156:8081/cas/login
* casServerName:https://123.207.122.156:8080/tdw/alerts/
*
* @return
*/
@Bean
@Order(2)
public FilterRegistrationBean getCasAuthFilterRegistrationBean() {
FilterRegistrationBean registration = new FilterRegistrationBean();
final Filter casAuthFilter = getCasAuthFilter();
registration.setFilter(casAuthFilter);
registration.addUrlPatterns("/*", "*.html");
registration.addInitParameter("casServerLoginUrl", casServerLoginUrl);
registration.addInitParameter("serverName", clienthosturl);
registration.setName(CAS_AUTH_FILTER_NAME);
registration.setEnabled(true);
return registration;
}
@Bean(name = CAS_AUTH_FILTER_NAME)
public Filter getCasAuthFilter() {
return new MyAuthenticationFilter();
}
/**
* 对Ticket进行校验
* casValidationServerUrlPrefix要用内网ip
* casValidationServerUrlPrefix:https://123.207.122.156:8081/cas
* casServerName:https://123.207.122.156:8080/tdw/alerts/
*
* @return
*/
@Bean
@Order(3)
public FilterRegistrationBean getCasValidationFilterRegistrationBean() {
FilterRegistrationBean registration = new FilterRegistrationBean();
final Filter casValidationFilter = getCasValidationFilter();
registration.setFilter(casValidationFilter);
registration.addUrlPatterns("/*", "*.html");
registration.addInitParameter("casServerUrlPrefix", casValidationServerUrlPrefix);
registration.addInitParameter("serverName", clienthosturl);
registration.setName(CAS_FILTER_NAME);
registration.setEnabled(true);
return registration;
}
@Bean(name = CAS_FILTER_NAME)
public Filter getCasValidationFilter() {
return new Cas20ProxyReceivingTicketValidationFilter();
}
/**
* 设置response的默认编码方式:UTF-8。
*
* @return
*/
@Bean
@Order(4)
public FilterRegistrationBean getCharacterEncodingFilterRegistrationBean() {
FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setFilter(getCharacterEncodingFilter());
registration.addUrlPatterns("/*", "*.html");
registration.setName(CHARACTER_ENCODING_NAME);
registration.setEnabled(true);
return registration;
}
@Bean(name = CHARACTER_ENCODING_NAME)
public Filter getCharacterEncodingFilter() {
CharacterEncodingFilter characterEncodingFilter = new CharacterEncodingFilter();
characterEncodingFilter.setEncoding("UTF-8");
return characterEncodingFilter;
}
@Bean
public FilterRegistrationBean casHttpServletRequestWrapperFilter(){
FilterRegistrationBean authenticationFilter = new FilterRegistrationBean();
authenticationFilter.setFilter(new HttpServletRequestWrapperFilter());
authenticationFilter.setOrder(6);
List<String> urlPatterns = new ArrayList<>();
urlPatterns.add("/*");
authenticationFilter.setUrlPatterns(urlPatterns);
return authenticationFilter;
}
@Override
public void afterPropertiesSet() throws Exception {
}
}
这里有个最关键的地方就是通过拦截请求头, 给前端发送url跳转地址
if("XMLHttpRequest".equals(xRequested)){
response.getWriter().write("{\"code\":202, \"msg\":\"no ticket and no assertion found\", \"url\":\"http://localhost:1234/checkTicket\"}");
}
看下前端:
mounted() {
let _this = this;
this.$http.get('/test', {headers: {'x-requested-with': 'XMLHttpRequest'}})
.then(function (response) {
if (response.data.code === 202) {
//console.log(response);
window.location.href = response.data.url
} else if (response.data.code === 200) {
_this.name = response.data.data.name
}
console.log(response);
})
.catch(function (error) {
console.log(error);
});
},
拦截器拦截到请求头之后,通过状态码, 进行请求跳转到http://localhost:1234/checkTicket
控制器:
import com.casclient1.cas.domain.UserDomain;
import com.casclient1.cas.tools.Result;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* @author 胡百成 2021/6/10
*/
@Controller
public class TestController
{
/**
* 测试
*
* @return
*/
@GetMapping("/test")
@ResponseBody
public Result<UserDomain> login(HttpServletRequest httpServletRequest)
{
return new Result<>(new UserDomain(httpServletRequest.getRemoteUser()));
}
@GetMapping("/checkTicket")
public void index(HttpServletResponse response, HttpServletRequest httpServletRequest) throws IOException
{
// 前端页面地址
response.sendRedirect("http://localhost:8088/Home");
}
/**
* 注销
*
* @return
*/
@RequestMapping("/logout")
public String logout()
{
return "redirect:http://localhost:8045/cas/logout";
}
/**
* 测试
*
* @return
*/
@GetMapping("/cache")
@ResponseBody
public Result<UserDomain> cache(HttpServletRequest httpServletRequest)
{
return new Result<>(new UserDomain(httpServletRequest.getRemoteUser()));
}
}
我们自己的项目是结合security, 但是在我们做出来之后发现, 其实结合不结合security与整合单登根本不重要, CAS最大的问题是本身不支持前后端分离, 所以我们根据上面这个项目得了一个灵感 就是在前端的请求里定义一个头, 我们在后台写一个拦截器@Order(1) 优先级一定要最高, 拦截下这个请求头然后给前端response数据, 前端获得到ticket的url进行跳转. 而后端随即做地址响应.
我们之前一直遇到无限跳转/什么重定向次数过多和跨域, 都是请求跳转没有控制好!
第二就是我们前端有路由守卫, 其次是页面的菜单是动态加载的. 这个问题其实也很好解决, 我们在前端页面上加了一个authorize.vue, 我们再把跳转的首页转到这个authorize页面, 这个页面做三件事 第一是加载用户数据缓存到本地, 第二是加载菜单数据放到router里, 第三是跳转到首页
然后在路由守卫里单独的对这个路由进行判断, 如果是这个地址则next()放行就可以.
我们知道CAS自带的一个退出页, 但是实际项目中我们并不想跳转到那个页面中而是想跳转回登陆页, 这个其实也很好解决
首先要去CAS服务的配置文件application.properties
里加一个配置
cas.logout.followServiceRedirects=true
退出控制器就可以重新拼接地址了:
/**
* 注销
*
* @return
*/
@RequestMapping("/logout")
public String logout()
{
return "redirect:http://localhost:8045/cas/logout?service=http://localhost:8045/cas/login?service=http%3A%2F%2Flocalhost%3A1234%2FcheckTicket";
}
我这里直接提供环境和代码
CAS server:https://wws.lanzoui.com/iQeyXqy01hi
密码:eb0n
服务端代码:https://wws.lanzoui.com/i9xWUqy01ub
前端代码: https://wws.lanzoui.com/iGRcoqy01ta
密码:fpsv
上面的环境包下载下来之后按需修改, CAS的serverhttp 我已经配好了 logout也配好了
服务端代码你需要配置自己的ip, 如果使用localhost或者域名的话, 需要全部都配置, 不要有些是localhost有些是ip会出现问题
前端的话最好在vue.config.js里配置代理