之前我在项目中使用过CAS作为单点登录服务,不过那些项目,不管是asp.net MVC项目,还是java的spring boot项目,是前后端不分的,只要使用CAS的客户端(对于asp.net mvc项目来说,cas的客户端就是DotNetCasClient.dll,而java项目,cas客户端就是一些java包),然后配置一下就好了。现在普遍前后端分离,该如何使用CAS呢?
一、工作原理及流程
简单而言,就是:前端的数据,和对数据的操作,来源于后端,前端不需要单点登录,真正使用单点登录的是后端。前端不需要考察登录状态,只需访问后端,后端自然会处理,仅仅只需要考察到后端返回202编码时跳转到单点登录页面。具体流程如下:
前端向后端请求或提交数据,后端发现前端尚未登录,于是返回一个202状态码;前端接收到此状态码后,转向单点登录;登录成功后,跳转到后端;后端之后再跳转到前端。
下面以实例做详细说明。
二、运行环境
我的前后端分离项目,后端是Spring Boot程序,以此为例做说明。
单点登录服务:CAS 5.18
CAS客户端:cas-client-core 3.3.2
后端框架:Spring Boot2
前端框架:VUE 3
三、后端
后端主要是设置一个过滤器,对来自前端的请求进行是否已登录检查。过滤器基本来自于CAS客户端。但这个客户端需要稍为改写。原因据说是CAS无法将来自前端的AJAX请求直接重定向到单点登录服务器,所以改为向前端返回202状态码,然后前端接收到此状态码后,自行重定向到单点登录。这个我目前还不是很理解,需要进一步了解的同学可自行查阅附录参考文章。
1、pom.xml
<!-- 单点登录相关 -->
<dependency><groupId>org.jasig.cas.client</groupId><artifactId>cas-client-core</artifactId><version>3.3.2</version>
</dependency>
<dependency><groupId>joda-time</groupId><artifactId>joda-time</artifactId><version>2.10.5</version>
</dependency>
<dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-cas</artifactId>
</dependency>
<dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-taglibs</artifactId>
</dependency>
2、application.yml
cas:casSigntouServerUrlPrefix: https://192.168.10.250:11443/cas250/logoutcasServerLoginUrl: https://192.168.10.250:11443/cas250/logincasValidationServerUrlPrefix: https://192.168.10.250:11443/cas250clientBackendUrl: http://192.168.10.8:8090 #后端地址clientWebUrl: http://192.168.10.8:8080/#/afterLogin #前端地址
3、过滤器1,禁用ssl认证
不知为啥要这么做,照抄参考文章。反正我的前端和后端都不支持https。
import javax.net.ssl.*;
import javax.servlet.*;
import java.io.IOException;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;public class IgnoreSSLValidateFilter implements Filter {static {//执行设置,禁用ssl认证try {TrustManager[] trustAllCerts = {new X509TrustManager() {@Overridepublic X509Certificate[] getAcceptedIssuers() {return null;}@Overridepublic void checkClientTrusted(X509Certificate[] arg0, String arg1)throws CertificateException {}@Overridepublic void checkServerTrusted(X509Certificate[] arg0, String arg1)throws CertificateException {}}};SSLContext sc = SSLContext.getInstance("SSL");sc.init(null, trustAllCerts, new SecureRandom());HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory());HostnameVerifier allHostsValid = new HostnameVerifier() {@Overridepublic boolean verify(String hostname, SSLSession session) {return true;}};HttpsURLConnection.setDefaultHostnameVerifier(allHostsValid);} catch (NoSuchAlgorithmException e) {e.printStackTrace();} catch (KeyManagementException e) {e.printStackTrace();}}@Overridepublic void init(javax.servlet.FilterConfig filterConfig) throws ServletException {Filter.super.init(filterConfig);}@Overridepublic void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {chain.doFilter(request, response);}@Overridepublic void destroy() {}}
4、过滤器2,改写重定向为返回202状态码
其实就是String xRequested =request.getHeader(“x-requested-with”); if(“XMLHttpRequest”.equals(xRequested){…}这里做了更改。不明觉厉。
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 org.slf4j.Logger;
import org.slf4j.LoggerFactory;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;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();private static final Logger LOGGER = LoggerFactory.getLogger(MyAuthenticationFilter.class);public MyAuthenticationFilter() {}@Overrideprotected void initInternal(FilterConfig filterConfig) throws ServletException {if (!this.isIgnoreInitConfiguration()) {super.initInternal(filterConfig);this.setCasServerLoginUrl(this.getPropertyFromInitParams(filterConfig, "casServerLoginUrl", (String)null));LOGGER.trace("Loaded CasServerLoginUrl parameter: {}", this.casServerLoginUrl);this.setRenew(this.parseBoolean(this.getPropertyFromInitParams(filterConfig, "renew", "false")));LOGGER.trace("Loaded renew parameter: {}", this.renew);this.setGateway(this.parseBoolean(this.getPropertyFromInitParams(filterConfig, "gateway", "false")));LOGGER.trace("Loaded gateway parameter: {}", this.gateway);String ignorePattern = this.getPropertyFromInitParams(filterConfig, "ignorePattern", (String)null);LOGGER.trace("Loaded ignorePattern parameter: {}", ignorePattern);String ignoreUrlPatternType = this.getPropertyFromInitParams(filterConfig, "ignoreUrlPatternType", "REGEX");LOGGER.trace("Loaded ignoreUrlPatternType parameter: {}", ignoreUrlPatternType);if (ignorePattern != null) {Class<? extends UrlPatternMatcherStrategy> ignoreUrlMatcherClass = (Class)PATTERN_MATCHER_TYPES.get(ignoreUrlPatternType);if (ignoreUrlMatcherClass != null) {this.ignoreUrlPatternMatcherStrategyClass = (UrlPatternMatcherStrategy) ReflectUtils.newInstance(ignoreUrlMatcherClass.getName(), new Object[0]);} else {try {LOGGER.trace("Assuming {} is a qualified class name...", ignoreUrlPatternType);this.ignoreUrlPatternMatcherStrategyClass = (UrlPatternMatcherStrategy)ReflectUtils.newInstance(ignoreUrlPatternType, new Object[0]);} catch (IllegalArgumentException var6) {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 = (GatewayResolver)ReflectUtils.newInstance(gatewayStorageClass, new Object[0]);}String authenticationRedirectStrategyClass = this.getPropertyFromInitParams(filterConfig, "authenticationRedirectStrategyClass", (String)null);if (authenticationRedirectStrategyClass != null) {this.authenticationRedirectStrategy = (AuthenticationRedirectStrategy)ReflectUtils.newInstance(authenticationRedirectStrategyClass, new Object[0]);}}}@Overridepublic void init() {super.init();CommonUtils.assertNotNull(this.casServerLoginUrl, "casServerLoginUrl cannot be null.");}@Overridepublic final void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {HttpServletRequest request = (HttpServletRequest)servletRequest;HttpServletResponse response = (HttpServletResponse)servletResponse;if (this.isRequestUrlExcluded(request)) {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) {LOGGER.debug("no ticket and no assertion found");String modifiedServiceUrl;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);String xRequested =request.getHeader("x-requested-with");if("XMLHttpRequest".equals(xRequested)){response.getWriter().write("{\"code\":202, \"msg\":\"no ticket and no assertion found\"}");}else{String urlToRedirectTo = CommonUtils.constructRedirectUrl(this.casServerLoginUrl, this.getServiceParameterName(), modifiedServiceUrl, this.renew, this.gateway);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);}
}
5、注册配置
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.beans.factory.annotation.Value;
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.List;@Configuration
public class SsoFilterConfig implements Serializable, InitializingBean {private static final Logger LOGGER = LoggerFactory.getLogger(SsoFilterConfig.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";//CAS服务器退出地址@Value("${cas.casSigntouServerUrlPrefix:https://127.0.0.1:8443/cas/logout}")String casSigntouServerUrlPrefix;//CAS服务器登录地址@Value("${cas.casServerLoginUrl:https://127.0.0.1:8443/cas/login}")String casServerLoginUrl;//CAS服务器地址@Value("${cas.casValidationServerUrlPrefix:https://127.0.0.1:8443/cas}")String casValidationServerUrlPrefix;//客户端地址(即本系统地址?)@Value("${cas.clientBackendUrl:http://127.0.0.1:1234}")String clientBackendUrl;public SsoFilterConfig() {}/*** 单点登出功能,放在其他filter之前* casSigntouServerUrlPrefix为登出前缀:https://123.207.122.156:8081/cas/logout** @return*/@Bean@Order(0)public FilterRegistrationBean getCasSignoutFilterRegistrationBean() {FilterRegistrationBean registration = new FilterRegistrationBean();registration.setFilter(getCasSignoutFilter());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", clientBackendUrl);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", clientBackendUrl);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;}@Beanpublic 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;}@Overridepublic void afterPropertiesSet() throws Exception {}
}
6、给前端的相关接口
//import 自定义的 巴拉巴拉.server.modules.utils.Result;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Value;
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.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;@Api(tags = "cas单点登录接口")
@Controller
@RequestMapping("/cas")
public class LoginController {@Value("${cas.clientWebUrl}")String webUrl;@ApiOperation(value = "测试接口")@GetMapping("/test")@ResponseBodypublic String login(HttpServletRequest request) {System.out.println(request.getRemoteUser().toString());return "success";}@ApiOperation(value = "票根验证", notes = "验证通过时转发到前端主页")@GetMapping("/checkTicket")public void index(HttpServletResponse response) throws IOException {// 前端页面地址response.sendRedirect(webUrl);}@ApiOperation(value = "获取系统当前登录的用户名与用户名白名单")@GetMapping("/getUsername")@ResponseBodypublic Result getUsername(HttpServletRequest request) {Map<String, String> map = new HashMap<>();map.put("currentUser", request.getRemoteUser());return Result.ok().put("data",map);}
}
四、前端
前端不需要进行登录状态检查。它只在收到后端返回的202状态码时才主动重定向到单点登录。除此之外,它提供了一个登录后返回页面,记录一下登录账号,并凭此到后端获取该账号的详细信息。当然专门设置一个返回页面不是必须的。可见,由始至终,前端并没有校验登录状态,也没有存储什么登录状态,它是否登录,以及是否需要登录,都是由后端控制的。在它向后端请求数据、或提交数据时,由后端的过滤器处理。
前端涉及到处理的模块主要有:
1)请求处理程序
2)路由卫士
3)登录后返回页面
1、请求处理程序request/index.js
import axios from "axios";let redirectFlag = false;
const onResponse = (response) => {const code = response.data.code;if (code === 202 && !redirectFlag) {//转向单点的登录redirectFlag = true;window.location.href = appConfig.cas.casRedirectUrl;} else {。。。}
};。。。const getService = (config) => {const service = axios.create(config);// 添加请求拦截器service.interceptors.request.use(onRequest, onRequestError);// 添加响应拦截器service.interceptors.response.use(onResponse, onResponseError);return service;
};const DEFAULTCONFIG = {baseURL: "/api", // 所有的请求地址前缀部分timeout: 60000, // 请求超时时间毫秒withCredentials: true, // 异步请求携带cookieheaders: {"X-Requested-With": "XMLHttpRequest",},
};const service = getService(DEFAULTCONFIG);export default service;
2、路由卫士router/index.js
import { createRouter, createWebHashHistory } from "vue-router";const router = createRouter({history: createWebHashHistory(),routes,//一些路由规则
});// 路由守卫
const normalRouter = (to, from, next) => {if (to.path === "/afterLogin") {//必须保证登录返回页面顺利加载next();} else {//这部分代码与结合单点登录无关,由前端项目按照自己的逻辑自行定义myRouterDo(to, from, next);}
};router.beforeEach(normalRouter);export default router;//自定义逻辑
function myRouterDo(to, from, next) {。。。
}
3、登录后返回页面
以下代码中,getUserNameCAS()向后端获取登录账号;而在loginDo方法中,我会凭登录后账号向后端请求该账号的详细信息,做一些。
1)页面afterLogin.vue
<template><div><h1>===== 欢迎使用VUE3 =====</h1></div><div v-if="state.userName === null"><span>未登录</span></div><div v-if="state.userName !== null"><div class="flex-box navis"><div>框架首页</div><div><el-link type="primary" underline="true" href="/#/business">业务首页</el-link></div></div><div class="flex-box user-info"><div><span>用户名:</span><span>{{ state.userName }}</span></div><div><el-button type="primary" @click="logout" class="green-button">登出</el-button></div></div></div>
</template><script setup>
import { reactive, onMounted, nextTick } from "vue";
import { getUserName as getUserNameApi } from "@/api/cas.js";
import { logout } from "@/utils/auth.js";const state = reactive({userName: null
});onMounted(() => {nextTick(() => {getUserInfo();});
});function getUserInfo() {getUserNameApi().then((res) => {state.userName = res.data.currentUser;});
}
</script><style scoped>
</style>
2)后端接口api/cas.js
import { request } from "@/request";const appConfig = require("/public/web-config.json");
const $cas = appConfig.cas;//目前没有用到
export const login = () => {return request({url: `/cas/checkTicket`,method: "get",});
};export const logout = () => {window.location.href = $cas.casLogout;
};export const getUserName = () => {return request({url: `/cas/getUsername`,method: "get",});
};
oAuth2_704">五、单点登录与oAuth2
似乎单点登录与oAuth2有许多相同之处。oAuth2,使用第三方系统作为身份识别和授权服务,主要是授权。比如,我们使用微信来授权登录。当用户访问我们系统A时,首先转到微信,微信会询问用户,你同意系统A获取你的微信身份信息吗?用户同意后,微信就将用户的一些身份信息返回到我们系统A,我们就拿到了用户的身份,然后可以认为他登录了。从无须打造自己的登录服务这点来看,单点登录和oAuth2的效果是一样的。
但是,oAuth2并没有所谓一处登录,处处可使用的效果。每个使用微信的应用都是独立的,你登录了系统A,并不能直接访问系统B。同时,单点登录有单点登出,即从一个应用登出以后,其他的应用也都登出了;而oAuth2显然也没有这种功能。
我的感觉是,局域网应用,适合使用单点登录;互联网,适合使用oAuth2。
六、小结
CAS作为单点登录服务,应该是很成熟的吧,用起来,只要配置得当,还是很丝滑的。不过,我印象中,CAS部署起来十分麻烦。调试也很麻烦,以前经常遇到重定向次数过多的问题。如果这次不是有现成的部署,我不一定会选用它来做单点服务器。
附录:参考资料
后端代码基本上抄自以下文章。
springboot+vue集成cas单点登录