前言
因为最近业务需求要使前后端分离项目集成cas实现单点登录,网上找了许多,关于前后端分离情况下集成cas的文章,但感觉对我帮助并不大且实现起来较为复杂,下面浅浅记录一下我对该问题的解决方案。对于传统springboot项目前后端一体集成cas比较容易需要的朋友可以看我的另一篇文章。
项目前端使用Vue2,后端使用springboot。
解决思路
对于前后端分离情况下集成cas网上找的方法分为两种:1、在Vue中通过cas-authentication
库实现(尝试过拦截不到cas登录)。2、在后端改写cas过滤器,将重定向改为返回401状态码(改改写后仍是重定向,未实现返回状态码,重定向会报跨域问题)
由于前两种方式都未成功,我自己尝试了另外的方法,不需要修改cas过滤器源码。也更贴近与前后端一体集成cas,比较简单实现。
具体步骤
前端
新建一个Vue文件(只用实现进入页面loading效果自动跳转到cas登录页面即可`http://192.168.3.96:8199/cas/login?service=http://192.168.3.96:9018/index`根据实际情况修改地址),将路由中/login指向该文件。
cas登录成功后跳转至该service请求该API,该API携带用户userid通过配置文件中的LoginSuccessUrl`http://192.168.3.96:11000/#/waitingToLogin?userId=`路径返回至前端页面,该前端页面也实现loading效果,mounted触发去后端查询token的方法,查询成功后存储token(该出具体如何存储存储在哪参考原登录系统),跳转至主页面,前端配置完成。
首次进入的页面如下:
<template>
<div></div>
</template>
<script>
import { baseURL, ipURL } from '../../config';
export default {
mounted(){
const loading = this.$loading({
lock: true,
text: '正在加载中请稍等...',
spinner: 'el-icon-loading',
});
location.href=ipURL+"/cas/login?service="+baseURL+"/index"
loading.close();
},
methods:{
}
}
</script>
<style>
</style>
LoginSuccessUrl返回的页面如下:该页面较为重要,需要处理存储token、获取用户信息等工作,由于我是改造原登录功能,cas登录后需要与原登录后获取的数据保持一直,避免出现一些不必要的错误。
<template>
<div>
<!--此页面用于获取登录 token 和获取用户信息的 token-->
等待登录中......
</div>
</template>
<script>
import { queryToken, getUserInfo } from '@/api/login'
import { mapActions } from 'vuex'
export default {
name: 'GetTokens',
data() {
return {}
},
mounted() {
const loading = this.$loading({
lock: true,
text: '正在加载中请稍等...',
spinner: 'el-icon-loading',
})
this.getToken()
loading.close()
},
methods: {
...mapActions({
setToken: 'user/setToken',
}),
async getToken() {
let id = this.$route.query.userId
let data = await queryToken(id)
this.$store.commit('user/setToken', data.data)
let userinfo = await getUserInfo(id)
let userinfoId = userinfo.data.id
let userinfoUserId = userinfo.data.userid
let userinfoUserName = userinfo.data.name
let userinfodata = { id: userinfoId, userName: userinfoUserName, userId: userinfoUserId }
this.$store.commit('user/setUserInfor', JSON.stringify(userinfodata))
await this.$router.push('/')
},
},
}
</script>
<style>
</style>
后端
1、配置文件新增cas相关配置
#CAS服务器的URL前缀。指定CAS服务器的地址和端口号。
cas.server-url-prefix=http://192.168.3.96:8199/cas
#CAS服务器的登录页面URL。指定CAS登录页面的完整地址。
cas.server-login-url=http://192.168.3.96:8199/cas/login
# CAS客户端的主机URL。指定CAS客户端的地址和端口号。
cas.client-host-url=http://localhost:9003
#:CAS验证URL的模式。指定需要进行CAS认证的URL模式,多个模式之间使用逗号分隔。
cas.authentication-url-patterns=/,/index,/base/login,/index/
#CAS客户端的本地URL。在CAS登录成功后,会跳转回CAS客户端的这个地址。service参数用来指定登录成功后要返回的URL。
local.url=http://192.168.3.96:8199/cas/login?service=http://localhost:9003/cas/index
#应用程序的HTTP端口号。指定应用程序运行的HTTP端口。
server.httpPort= 9003
2、Pom文件增加cas依赖。
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.7</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>net.unicon.cas</groupId>
<artifactId>cas-client-autoconfig-support</artifactId>
<version>2.3.0-GA</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.jasig.cas.client</groupId>
<artifactId>cas-client-core</artifactId>
<version>3.6.2</version>
<scope>compile</scope>
</dependency>
3、修改启动类
(1)添加注解@EnableCasClient
(2)添加如下代码,不修改main方法,在main下写以下代码
@Autowired
private ICapUserService capUserService;
@Autowired
private ICapRoleService capRoleQueryService;
@Autowired
private IEmployeeQueryService employeeQueryService;
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private SecretConfig secretConfig;
@Override
public void configureValidationFilter(FilterRegistrationBean validationFilter) {
validationFilter.setFilter(new CasUserReceivingTicketValidationFilter(capUserService, capRoleQueryService,employeeQueryService,redisTemplate,secretConfig));
super.configureValidationFilter(validationFilter);
}
4、编写cas相关类
indexController(核心部分:/index根据getLoginSuccessUrl跳转到前端页面)
package com.xk.web;
import com.xk.wtbu.config.SecretConfig;
import net.unicon.cas.client.configuration.CasClientConfigurationProperties;
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.RequestMapping;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.util.Enumeration;
@Controller("webIndexController")
public class IndexController {
@Autowired
private SecretConfig secretConfig;
@Autowired
private CasClientConfigurationProperties casClientConfigurationProperties;
@RequestMapping("/")
public void toIndexDefault(HttpServletRequest request, HttpServletResponse resp) throws IOException {
String userId = (String) request.getSession().getAttribute("userId");
String redirectUri = secretConfig.getLoginSuccessUrl()+userId;
resp.sendRedirect(redirectUri);
}
@GetMapping("/index")
public void toIndex(HttpServletRequest request,HttpServletResponse resp) throws IOException {
// 获取当前的会话对象
HttpSession session = request.getSession();
// 获取所有会话属性名称
Enumeration<String> attributeNames = session.getAttributeNames();
// 遍历会话属性名称并打印属性值
while (attributeNames.hasMoreElements()) {
String attributeName = attributeNames.nextElement();
Object attributeValue = session.getAttribute(attributeName);
System.out.println(attributeName + ": " + attributeValue);
}
String userId = (String) request.getSession().getAttribute("userId");
String redirectUri = secretConfig.getLoginSuccessUrl()+userId;
resp.sendRedirect(redirectUri);
}
@GetMapping("/portal/logout")
public String toLogout(HttpServletRequest request) {
request.getSession().invalidate();
String logouturl = secretConfig.getLogoutSuccessUrl();
return "redirect:" + casClientConfigurationProperties.getServerUrlPrefix() + "/logout?service=" + logouturl ;
}
}
CasUserReceivingTicketValidationFilter(该出可以做cas登录成功后相关操作,如存redis数据、存储token等,关于token也可单独编写类实现)
package com.xk.wtbu.cas;
import com.google.common.collect.ImmutableMap;
import com.xk.common.core.organize.model.dto.EmployeeDto;
import com.xk.common.core.user.model.dto.CapRoleDto;
import com.xk.common.core.user.model.dto.CapUserDto;
import com.xk.platform.core.organize.service.IEmployeeQueryService;
import com.xk.platform.security.jwt.TokenAuthenticationService;
import com.xk.platform.security.user.service.ICapRoleService;
import com.xk.platform.security.user.service.ICapUserService;
import com.xk.wtbu.config.SecretConfig;
import com.xk.wtbu.constant.PortalConstants;
import com.xk.wtbu.constant.RedisKeyConstants;
import org.jasig.cas.client.authentication.AttributePrincipal;
import org.jasig.cas.client.validation.Assertion;
import org.jasig.cas.client.validation.Cas20ProxyReceivingTicketValidationFilter;
import org.springframework.data.redis.core.StringRedisTemplate;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
public class CasUserReceivingTicketValidationFilter extends Cas20ProxyReceivingTicketValidationFilter {
public CasUserReceivingTicketValidationFilter() {
super();
}
private ICapUserService capUserService;
private ICapRoleService capRoleQueryService;
private IEmployeeQueryService employeeQueryService;
private StringRedisTemplate redisTemplate;
private SecretConfig secretConfig;
public CasUserReceivingTicketValidationFilter(ICapUserService capUserService, ICapRoleService capRoleQueryService, IEmployeeQueryService employeeQueryService, StringRedisTemplate redisTemplate,SecretConfig secretConfig) {
super();
this.capUserService = capUserService;
this.capRoleQueryService = capRoleQueryService;
this.employeeQueryService = employeeQueryService;
this.redisTemplate = redisTemplate;
this.secretConfig = secretConfig;
}
@Override
public void onSuccessfulValidation(HttpServletRequest request,
HttpServletResponse response, Assertion assertion) {
AttributePrincipal principal = assertion.getPrincipal();
String roleCode = "";
CapUserDto capUserDto = capUserService.getUserByUserid(principal.getName());
if (capUserDto == null) {
logger.error(">>>>>>>>>>>>>>>>>>>单点登录成功后的门户无此人信息,登录账号:{}<<<<<<<<<<<<<<", principal.getName());
} else {
// 根据学号/工号,查询所属的员工,再查员工授予的角色
EmployeeDto empByUserId = employeeQueryService.getEmpByUserId(capUserDto.getId());
// 查询员工授予的角色
List<CapRoleDto> capRoleDtos = capRoleQueryService.findRolesByPartyIdAndPartyType(empByUserId.getId(), "EMP");
if (capRoleDtos != null && capRoleDtos.size() > 0) {
roleCode = capRoleDtos.get(0).getRoleCode();
logger.info(">>>>>>>查询到角色id:{}",roleCode);
}
Map<String, Object> map = ImmutableMap.of(
"userid", principal.getName(),
"roleCode", roleCode,
"appCodes", PortalConstants.AUTH_APP);
String jwt = TokenAuthenticationService.genAuthentication(principal.getName(),map);
redisTemplate.opsForValue().set(RedisKeyConstants.HALL_TOKEN+principal.getName(),jwt,2, TimeUnit.MINUTES);
String userJwt = TokenAuthenticationService.genAuthentication(principal.getName(), map, secretConfig.getUserSecret());
redisTemplate.opsForValue().set(RedisKeyConstants.USER_TOKEN+principal.getName(),userJwt,2,TimeUnit.MINUTES);
// request.getSession().setAttribute("userId","");
request.getSession().setAttribute("userId",principal.getName());
}
}
@Override
public void onFailedValidation(HttpServletRequest request, HttpServletResponse response) {
logger.info("Failed to validate cas ticket");
}
}
casConfig
package com.xk.wtbu.cas;
import net.unicon.cas.client.configuration.CasClientConfigurationProperties;
import org.apache.coyote.http11.AbstractHttp11Protocol;
import org.jasig.cas.client.session.SingleSignOutFilter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.embedded.tomcat.TomcatConnectorCustomizer;
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import javax.annotation.Resource;
import java.util.HashMap;
import java.util.Map;
/**
* 注册单点退出拦截
* 更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B。
* Synchronized属于悲观锁,悲观地认为程序中的并发情况严重,所以严防死守。CAS属于乐观锁,乐观地认为程序中的并发情况不那么严重,所以让线程不断去尝试更新。
* CAS只能保证一个共享变量的原子操作
* @author fang
*/
//@Configuration
public class CasConfig {
@Value("${server.httpPort}")
int httpPort;
@Value("${server.port}")
int httpsPort;
@Resource
private CasClientConfigurationProperties casClientConfigurationProperties;
@Bean
public FilterRegistrationBean filterRegistrationBean() {
final FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setFilter(new SingleSignOutFilter());
// 设定匹配的路径
registration.addUrlPatterns("/*");
Map<String, String> initParameters = new HashMap<String, String>();
initParameters.put("casServerUrlPrefix", casClientConfigurationProperties.getClientHostUrl());
registration.setInitParameters(initParameters);
// 设定加载的顺序
registration.setOrder(1);
return registration;
}
@Bean
public WebServerFactoryCustomizer<TomcatServletWebServerFactory> servletContainerCustomizer() {
return factory -> factory.addConnectorCustomizers((TomcatConnectorCustomizer) connector -> {
AbstractHttp11Protocol<?> httpHandler = ((AbstractHttp11Protocol<?>) connector.getProtocolHandler());
httpHandler.setUseServerCipherSuitesOrder(String.valueOf(true));
httpHandler.setSSLProtocol("TLSv1.2");
httpHandler.setSSLHonorCipherOrder(String.valueOf(true));
httpHandler.setCiphers("TLS_EMPTY_RENEGOTIATION_INFO_SCSV,TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,TLS_RSA_WITH_AES_256_GCM_SHA384");
});
}
}
总结
总的来说,该方法的主要流程为:访问前端页面发现未登录,跳转到cas登录页面,登录后跳转到service参数地址,该地址指向后端,在后端判断ticket有效性重定向到前端页面,该页面调取API获取token,并存储token,获取用户信息,实现登录成功,需要注意的是在IndexController中重定向到了前端页面,并携带了用户账号,用户获取token时需要注意产品秘钥的有效性,用户退出登录时还需与前后端一体的退出登录方式一样,访问url+/cas/logout来实现退出cas登录。
这样也实现了前后端分离集成cas,且对代码改动小,较容易实现。