前言

        因为最近业务需求要使前后端分离项目集成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,且对代码改动小,较容易实现。