基于SpringBoot和Vue实现CAS单点登录

目录

1.项目概述

2.单点登录的原理

[2.1 CAS 核心架构](#2.1 CAS 核心架构)

[2.1.1 角色定义](#2.1.1 角色定义)

[2.2 CAS 核心流程(基于 CAS 3.0 协议,最主流实现)](#2.2 CAS 核心流程(基于 CAS 3.0 协议,最主流实现))

[2.2.1 登录流程(首次访问应用)](#2.2.1 登录流程(首次访问应用))

[2.2.2 跨应用免登流程(已登录应用 A,访问应用 B)](#2.2.2 跨应用免登流程(已登录应用 A,访问应用 B))

[2.2.3 注销流程(单点注销)](#2.2.3 注销流程(单点注销))

[2.3 核心票据与组件详解](#2.3 核心票据与组件详解)

[2.3.1 核心票据](#2.3.1 核心票据)

[TGT 存在服务端,CASTGC 是 "TGT 的浏览器索引"](#TGT 存在服务端,CASTGC 是 “TGT 的浏览器索引”)

[ST 与 TGT/CASTGC 的核心区别(避免混淆)](#ST 与 TGT/CASTGC 的核心区别(避免混淆))

3.单点登录的实现步骤

3.1引入CAS需要的maven依赖

3.2配置好CAS的配置类

3.3配置文件编写认证中心的地址和登录地址以及客户端地址

3.4单点登录的后端业务逻辑代码

4.加入单点登录之后的登录流程演示

4.1从网上办事大厅进入

4.2直接访问单点登录地址

4.3普通的常规登录

5.本网站单点登录的业务流程

6.单点登录实现过程中遇到的问题

6.1重定向的问题

[6.2 为什么不能重定向到前端,然后让前端拿到票据去后端登录呢](#6.2 为什么不能重定向到前端,然后让前端拿到票据去后端登录呢)

[6.2.1 先明确CAS的核心角色与票据本质](#6.2.1 先明确CAS的核心角色与票据本质)

[6.2.2 核心矛盾:破坏CAS的三大验证规则](#6.2.2 核心矛盾:破坏CAS的三大验证规则)

[6.2.3 配置文件层面的双重不匹配:雪上加霜](#6.2.3 配置文件层面的双重不匹配:雪上加霜)

[6.2.4 正确方案:遵循"后端接票据,前端拿凭证"原则](#6.2.4 正确方案:遵循“后端接票据,前端拿凭证”原则)


1.项目概述

本处的单点登录基于本人的参与的学校项目科研管理系统 进行分析实现,该项目是一个主要基于SpringBoot和Vue实现的项目(SpringBoot的版本是2.7.0,Vue的版本是Vue2,jdk的版本是11 ),项目原来的登录是基于JWT令牌 实现的无状态登录,现在由于项目需要上线,所以需要通过CAS单点登录对接学校的网上办事大厅,大家类似的前后端分离项目都可以参考这个。

目标要实现的效果就是:

①只要学校的教职工在这个办事大厅的认证中心部分实现了登录,

②那么他进入我们的科研管理系统就无需进行登录,

③直接进入这位教职工的科研管理系统主界面

湖北师范大学网上办事大厅

认证中心界面

2.单点登录的原理

原理这部分其实网上资源很成熟,讲得也很好,这里引用几篇文章,大家可以自行观看,我自己也给出一些自己的理解

CAS单点登录原理(包含详细流程,讲得很透彻,耐心看下去一定能看明白!)_java cas 登出逻辑-CSDN博客

CAS单点登录(一)------初识SSO-CSDN博客

CAS(Central Authentication Service,中央认证服务)是一种基于票据验证 的单点登录协议,核心目标是让用户在多个相互信任的应用系统中,仅需一次身份认证即可访问所有系统,无需重复登录。其设计遵循 "集中认证、分散授权" 原则,分为 CAS Server(认证中心)和 CAS Client(应用客户端)两大核心组件。

2.1 CAS 核心架构

CAS 架构由 3 个关键角色构成,各角色职责明确、协同工作:

2.1.1 角色定义

CAS Server(中央认证服务器):核心认证组件,负责用户身份校验(如用户名密码验证、OAuth2.0 集成等)、票据(Ticket)生成与管理、会话维护,是整个 SSO 体系的信任核心。

CAS Client(应用客户端):嵌入在各业务应用中的代理组件,负责拦截用户访问请求、跳转至 CAS Server 认证、验证票据有效性,无需存储用户身份信息。

用户(User):终端访问者,通过浏览器与应用系统、CAS Server 交互。

2.2 CAS 核心流程(基于 CAS 3.0 协议,最主流实现)

CAS 的核心流程围绕 "票据申请 - 验证 - 授权" 展开,分为登录流程票据验证流程注销流程三部分,以下结合实际场景详细说明:

2.2.1 登录流程(首次访问应用)

假设用户访问应用 A(CAS Client),未登录状态下的认证流程:

① 用户通过浏览器访问应用 A 的受保护资源(如http://appA.com/home);

② 应用 A 的 CAS Client 拦截请求,检测到用户未登录(无本地会话),生成服务地址(Service URL)(即应用 A 的回调地址,用于认证后跳转);

③ CAS Client 将用户请求重定向至 CAS Server 的登录页面,携带 Service URL 参数(如http://cas-server.com/login?**service=http://appA.com/cas/callback**);

④ CAS Server 接收请求后,检测自身是否存在用户登录会话(TGT 会话):

  • 若不存在,展示登录页面,要求用户输入用户名、密码;
  • 若已存在(用户之前已通过其他应用认证),直接跳过登录步骤,进入票据发放环节

⑤ 用户输入正确 **credentials (就是用户账号密码)**后,CAS Server 验证身份合法性(可对接数据库、LDAP、OAuth2.0 等认证源);

⑥ 身份验证通过后,CAS Server 生成两种核心票据:

  • TGT(Ticket Granting Ticket,票据授予票据) :存储在 CAS Server 端的用户会话凭证,关联用户身份信息,有效期内可用于申请其他应用的访问票据(ST),通常通过 Cookie(如CASTGC)与用户浏览器绑定;
  • ST(Service Ticket,服务票据):一次性有效的访问票据(短期有效,通常 1 分钟内),与步骤②的 Service URL 绑定,仅可用于申请该应用的访问权限;

⑦ CAS Server 将用户浏览器重定向至应用 A 的回调地址(Service URL),并携带 ST 参数(如http://appA.com/cas/callback?ticket=ST-xxx-xxxx);

⑧ 应用 A 的 CAS Client 提取 ST,通过后台 HTTP 请求 (而非浏览器跳转)向 CAS Server 发送票据验证请求(如**http://cas-server.com/serviceValidate?service=http://appA.com/cas/callback&ticket=ST-xxx-xxxx**),同时携带 Client 与 Server 约定的密钥(或通过公钥签名);

⑨ CAS Server 验证 ST 的有效性(包括是否存在、是否与 Service URL 匹配、是否过期、是否已使用);

⑩ 验证通过后,CAS Server 向应用 A 返回用户身份信息(如用户名、角色等,可通过属性释放策略配置);

⑪ 应用 A 的 CAS Client 接收用户信息,在本地创建应用会话(如JSESSIONID),并授予用户访问受保护资源的权限;

⑫ 浏览器展示应用 A 的目标页面,用户无需再次登录。

这里也放张网上的图片便于理解

2.2.2 跨应用免登流程(已登录应用 A,访问应用 B)

① 用户已通过应用 A 完成 CAS 认证,CAS Server 端存在有效 TGT 会话,应用 A 本地存在会话;

② 用户通过同一浏览器访问应用 B(另一个 CAS Client)的受保护资源;

③ 应用 B 的 CAS Client 拦截请求,检测到本地无会话,重定向至 CAS Server 的登录页面,携带应用 B 的 Service URL;

④ CAS Server 检测到用户浏览器已携带有效CASTGC Cookie(关联 TGT 会话),无需展示登录页面;

⑤ CAS Server 为应用 B 生成新的 ST(与应用 B 的 Service URL 绑定),并重定向至应用 B 的回调地址,携带 ST;

⑥ 应用 B 的 CAS Client 提取 ST,后台请求 CAS Server 验证票据;

⑦ 验证通过后,应用 B 创建本地会话,授予用户访问权限;

⑧ 用户成功访问应用 B,实现跨应用免登。

这个免登录流程感觉有点抽象,这里我来换一个生动的例子来讲解一下

用 "公司登记处(CAS Server)、部门前台(CAS Client)、访客(你) " 的场景,把这个流程拆成真人办事的步骤,你就能秒懂啦:

跨应用免登流程(已进 OA,再进财务)

先明确前提:你已经在 OA 部门(应用 A)完成了登记 ------登记处给了你 "长期出入卡(TGT)",OA 前台也给了你 "OA 部门门禁条(本地会话)"

现在你要去财务部门(应用 B),流程是这样的:

你带着 "长期出入卡",走到财务前台你已经在 OA 部门待着了(有 OA 的门禁条),现在打开浏览器访问财务系统(应用 B)------ 相当于你从 OA 部门走到了财务部门的前台。

② **财务前台:"你没财务的门禁条,去登记处补一个"**财务前台(应用 B 的 CAS Client)查了下,你身上没有 "财务部门的门禁条(本地会话)",于是告诉你:"你得去公司登记处,补一个财务的临时门禁条",同时把 "你是来财务部门的" 这个信息(Service URL)告诉你,让你带着去登记处。

你到登记处,登记处:"你有长期卡,直接给你财务的临时条" 你带着 "访问财务部门" 的信息到了登记处(CAS Server),登记处扫了一眼你的浏览器 ------ 发现你带着 "长期出入卡的标识(CASTGC Cookie)",知道你是刚办过卡的合法员工,不用再让你掏身份证核对了

登记处发 "财务临时门禁条",让你回财务前台登记处直接给你打印了一张 "财务部门专用的临时门禁条(新的 ST)",然后把你送回财务前台,同时把 "门禁条 + 你要进财务部门" 的信息一起给了前台。

⑤ **财务前台验条:"是真的,进来吧"**财务前台拿着这张临时门禁条,偷偷给登记处打了个电话:"这张条是你发的不?是给财务部门的不?" 登记处回复 "是真的",同时告诉前台 "这是咱们公司的 XXX(你的身份信息)"。

你进财务部门,全程没再掏身份证 财务前台给你开了门,同时给你发了 "财务部门的门禁条(本地会话)"------ 你从进财务部门到进门,没再输入过账号密码,这就是 "免登"。

简单总结:你只要在登记处办过一次 "长期卡(TGT)",后续去任何备案的部门,都只用拿 "长期卡" 换 "部门临时条",不用再重复 "查身份证" 的步骤~

2.2.3 注销流程(单点注销)

CAS 支持 "单点注销",即用户在一个应用中注销,所有已登录的关联应用均同步注销:

① 用户在应用 A 中发起注销请求(如点击 "退出登录");

② 应用 A 的 CAS Client 销毁本地会话,并重定向至 CAS Server 的注销接口(如http://cas-server.com/logout),携带应用 A 的 Service URL;

③ CAS Server 接收注销请求,销毁服务器端的 TGT 会话,同时清除CASTGC Cookie;

④ CAS Server 遍历所有与该 TGT 关联的已登录应用(通过票据发放记录),向各应用的注销接口发送注销通知(如 HTTP 请求);

⑤ 各应用(如应用 A、应用 B)接收注销通知,销毁本地会话;

⑥ CAS Server 将用户浏览器重定向至预设页面(如应用 A 的登录页、CAS Server 的首页),注销完成。

这个理解起来其实也是一样,只要长期卡被注销了,则进其他部分就需要再用身份信息进行申请

2.3 核心票据与组件详解

CAS 的安全性和可用性依赖于核心票据的设计,各票据各司其职、严格控制生命周期:

2.3.1 核心票据

TGT(Ticket Granting Ticket)

  • 本质:用户在 CAS Server 的登录会话凭证,是获取 ST 的 "凭证";
  • 存储位置:CAS Server 端(如内存、Redis、数据库),与用户身份信息绑定;
  • 生命周期:可配置(如 30 分钟无操作过期),支持滑动过期(用户持续操作时刷新有效期);
  • 关联方式:通过浏览器 **Cookie(CASTGC)**与用户绑定,Cookie 通常设置为HttpOnly(防止 XSS 攻击)、Secure(HTTPS 环境下传输)。

TGT 存在服务端,CASTGC 是 "TGT 的浏览器索引"

名称 存储位置 作用 场景类比
TGT(长期凭证) 公司登记处(CAS Server 服务端) 实际记录你的身份、有效期的 "长期出入卡档案",是你能免登的核心凭证 登记处柜子里存着的你的 "长期卡实体档案"
CASTGC Cookie 你的浏览器里 一串随机字符串,相当于 "取卡凭证"------ 登记处通过这串字符,能找到对应的 TGT 档案 你口袋里装的 "长期卡取卡小票",上面印着档案编号

ST(Service Ticket)

  • 本质:应用访问的一次性授权票据,用于向 CAS Client 证明用户已通过认证;
  • 特性:短期有效(通常 1 分钟)、一次性使用(验证后立即失效)、与 Service URL 强绑定(仅可用于对应应用);
  • 安全性:采用随机字符串生成(如 32 位 UUID),避免被猜测,仅通过 HTTPS 传输。

ST 与 TGT/CASTGC 的核心区别(避免混淆)

凭证类型 存储位置 生命周期 核心作用 类比
TGT CAS Server 端 较长(如 30 分钟) 全局认证凭证,用于生成 ST 登记处存档的 "长期出入卡档案"
CASTGC 浏览器 Cookie 与 TGT 同步 索引 TGT 的 "取卡小票",证明你有 TGT 你口袋里的 "长期卡编号小票"
ST 临时携带 / 暂存 极短(1 分钟内) 单个应用的一次性访问凭证 某部门的 "单次门禁条"

3.单点登录的实现步骤

3.1引入CAS需要的maven依赖

复制代码
    <dependency>
      <groupId>org.jasig.cas.client</groupId>
      <artifactId>cas-client-core</artifactId>
      <version>3.5.0</version>
    </dependency>

3.2配置好CAS的配置类

这里我选择不让CAS自带的拦截器不拦截任何请求,是因为我们的系统本来的JWT登录也需要使用到,已经有了一个登录拦截器,如果让CAS的这个拦截器拦截请求的话,就会造成一个问题,网站的原始的JWT登录将无法使用。所以这里的拦截器我选择不拦截任何的请求,让后端的业务方法返回相应的状态码401,前端实现跳转。
注意注意:你只要是前后端分离SpringBoot+Vue的项目,跳转就不能够使用这个CAS配置类自带的跳转

java 复制代码
package com.hbnu.system.config;

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.HttpServletRequestWrapperFilter;
import org.jasig.cas.client.validation.Cas30ProxyReceivingTicketValidationFilter;
import org.springframework.beans.factory.annotation.Value;
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 org.springframework.core.Ordered;

import java.util.HashMap;
import java.util.Map;


/**
 * 功能描述:
 * 修改记录:
 * <pre>
 * 修改时间:
 * 修改人:
 * 修改内容:
 * </pre>
 *
 * @title CasAutoConfig
 * @Author: jxl
 * @Date: 2025/10/15
 */
@Configuration
public class CasAutoConfig {

    private final static String URL_PATTERN = "/*";

    @Value("${cas.server-url-prefix}")
    private String serverUrlPrefix;
    @Value("${cas.server-login-url}")
    private String serverLoginUrl;
    @Value("${cas.client-host-url}")
    private String clientHostUrl;

    @Bean
    public ServletListenerRegistrationBean servletListenerRegistrationBean() {
        ServletListenerRegistrationBean listenerRegistrationBean = new ServletListenerRegistrationBean();
        listenerRegistrationBean.setListener(new SingleSignOutHttpSessionListener());
        listenerRegistrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE);
        return listenerRegistrationBean;
    }

    /**
     * 单点登录退出
     *
     * @return
     */
    @Bean
    public FilterRegistrationBean singleSignOutFilter() {
        FilterRegistrationBean registrationBean = new FilterRegistrationBean();
        registrationBean.setFilter(new SingleSignOutFilter());
        registrationBean.addUrlPatterns(URL_PATTERN);
        registrationBean.addInitParameter("casServerUrlPrefix", serverUrlPrefix);
        registrationBean.setName("CAS Single Sign Out Filter");
        registrationBean.setOrder(2);
        return registrationBean;
    }

    /**
     * 单点登录认证
     *
     * @return
     */
    @Bean
    public FilterRegistrationBean AuthenticationFilter() {
        FilterRegistrationBean registrationBean = new FilterRegistrationBean();
        registrationBean.setFilter(new AuthenticationFilter());
        registrationBean.addUrlPatterns(URL_PATTERN);
        registrationBean.setName("CAS Filter");
        Map<String, String> initParameters = new HashMap<String,String>();
        initParameters.put("casServerLoginUrl", serverLoginUrl);
        initParameters.put("serverName", clientHostUrl);

        //可以配置不需要单点登录拦截url(例子是如果url中包含/logout或者/index,则不需要经过单点登录验证)
        //没有可以注释
        initParameters.put("ignorePattern", ".*");
        //==配置不需要单点登录拦截url结束

        //只拦截/sso/**,其他的都不拦截
        //initParameters.put("prefix", "/sso/user-info");

        registrationBean.setInitParameters(initParameters);
        registrationBean.setOrder(3);
        return registrationBean;
    }

    /**
     * 单点登录校验
     *
     * @return
     */
    @Bean
    public FilterRegistrationBean Cas30ProxyReceivingTicketValidationFilter() {
        FilterRegistrationBean registrationBean = new FilterRegistrationBean();
        registrationBean.setFilter(new Cas30ProxyReceivingTicketValidationFilter());
        registrationBean.addUrlPatterns(URL_PATTERN);
        registrationBean.setName("CAS Validation Filter");
        registrationBean.addInitParameter("casServerUrlPrefix", serverUrlPrefix);
        registrationBean.addInitParameter("serverName", clientHostUrl);
        registrationBean.setOrder(4);
        return registrationBean;
    }

    /**
     * 单点登录请求包装
     *
     * @return
     */
    @Bean
    public FilterRegistrationBean httpServletRequestWrapperFilter() {
        FilterRegistrationBean registrationBean = new FilterRegistrationBean();
        registrationBean.setFilter(new HttpServletRequestWrapperFilter());
        registrationBean.addUrlPatterns(URL_PATTERN);
        registrationBean.setName("CAS HttpServletRequest Wrapper Filter");
        registrationBean.setOrder(5);
        return registrationBean;
    }
}

3.3配置文件编写认证中心的地址和登录地址以及客户端地址

这里的客户端一定是后端的地址加端口,因为我们的配置都是写在后端的,前端只做跳转和登录

java 复制代码
cas:
  # CAS服务端地址------认证中心地址
  server-url-prefix: https://authserver.hbnu.edu.cn/authserver
  # CAS服务端登录地址------认证中心登录地址
  server-login-url: https://authserver.hbnu.edu.cn/authserver/login
  # CAS服务端回调地址------服务端地址
  client-host-url: http://xkygl.hbnu.edu.cn:9528

front:
  # 前端服务地址
  url: http://xkygl.hbnu.edu.cn
back:
  # 后端服务地址
  url: http://xkygl.hbnu.edu.cn:9528

3.4单点登录的后端业务逻辑代码

3.4.1业务代码

java 复制代码
package com.hbnu.system.controller;


import com.hbnu.system.core.base.Rest;
import com.hbnu.system.model.entity.User;
import com.hbnu.system.model.entity.UserInfo;
import com.hbnu.system.service.IUserService;
import com.hbnu.system.utils.JwtUtils;
import lombok.extern.slf4j.Slf4j;
import org.jasig.cas.client.validation.Assertion;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

import static com.hbnu.system.core.base.RestCode.NOT_SSO_LOGIN;

/**
 * 前后端分离模式下的CAS单点登录控制器
 */
@RestController
@RequestMapping("/sso")
@Slf4j
public class SSOController {

    @Value("${cas.server-url-prefix}")
    private String serverUrlPrefix;

    @Value("${cas.client-host-url}")
    private String clientHostUrl;

    @Value("${cas.server-login-url}")
    private String casServerUrl;

    @Value("${front.url}")
    private String frontUrl;

    @Value("${back.url}")
    private String serverUrl;

    private static final Logger LOGGER = LoggerFactory.getLogger(SSOController.class);
    /**
     * cas client 默认的session key
     */
    public static final String CAS_ASSERTION_KEY = "_const_cas_assertion_";

    @Autowired
    private IUserService userService;

    /**
     * 获取当前登录用户信息
     */
    @GetMapping("/user-info")
    public Rest getUserInfo(HttpServletRequest request)   {
        UserInfo userInfo = getUserInfoFromCas(request);
        if (userInfo == null) {
            return Rest.failed(NOT_SSO_LOGIN);
        }
        //查找用户信息
        User user = userService.getUserByAccount(userInfo.getUid());
        //构建token返回
        Map<String, String> result = new HashMap<>();
        result.put("token",JwtUtils.getJwtToken(user.getUid(), user.getRole()));
        //构建用户工号
        result.put("username", user.getAccount());
        //构建密码
        return Rest.success(result);
    }


    /**
     * 回调地址
     * @param response
     * @throws IOException
     */
    @GetMapping("/checkTicket")
    public void index(HttpServletRequest request,HttpServletResponse response) throws IOException {
        UserInfo userInfo = getUserInfoFromCas(request);
        log.info("用户信息: {}", userInfo);
        log.info("用户信息: {}", userInfo.getUid());
        //查找用户信息
        User user = userService.getUserByAccount(userInfo.getUid());
        // 前端页面地址
        response.sendRedirect(frontUrl+"?token="+JwtUtils.getJwtToken(user.getUid(), user.getRole()));
    }

    /**
     * 登出接口
     */
    @GetMapping("/logout")
    public void logout(HttpServletRequest request, HttpServletResponse response) throws IOException {
        // 1. 清除本地会话
        request.getSession().invalidate();

        // 2. 构建CAS登出地址,登出后重定向到前端首页
        String logoutUrl = serverUrlPrefix+"/logout?service=" + frontUrl;

        // 3. 重定向到CAS服务器执行全局登出
        response.sendRedirect(logoutUrl);
    }


    /**
     * 从CAS会话中获取用户信息
     */
    private UserInfo getUserInfoFromCas(HttpServletRequest request) {
        Object object = request.getSession().getAttribute(CAS_ASSERTION_KEY);
        if (object == null) {
            LOGGER.warn("未从会话中获取到CAS认证信息");
            return null;
        }

        try {
            Assertion assertion = (Assertion) object;
            return buildUserInfoByCas(assertion);
        } catch (ClassCastException e) {
            LOGGER.error("CAS认证信息类型转换失败", e);
            return null;
        }
    }

    /**
     * 根据CAS断言构建用户信息
     */
    private UserInfo buildUserInfoByCas(Assertion assertion) {
        UserInfo userInfo = new UserInfo();
        String userName = assertion.getPrincipal().getName();
        LOGGER.info("CAS登录用户: {}", userName);

        //用户的工号
        userInfo.setUid(userName);

        return userInfo;
    }
}

3.4.2如果单点登录成功获取到的教职工实体(这个是学校那边提供的)

java 复制代码
package com.hbnu.system.model.entity;

import java.util.Map;

/**
 * 功能描述:
 * 修改记录:
 * <pre>
 * 修改时间:
 * 修改人:
 * 修改内容:
 * </pre>
 *
 * @title UserInfo
 * @Author: de
 * @Date: 2020/11/10
 */
public class UserInfo {
    private String uid;
    private String userName;
    private Map map;

    public String getUid () {
        return uid;
    }

    public void setUid (String uid) {
        this.uid = uid;
    }

    public String getUserName () {
        return userName;
    }

    public void setUserName (String userName) {
        this.userName = userName;
    }

    public Map getMap () {
        return map;
    }

    public void setMap (Map map) {
        this.map = map;
    }
}

3.4.3枚举类

java 复制代码
package com.hbnu.system.core.base;

import lombok.AllArgsConstructor;
import lombok.Getter;

/**
 * 返回码实现
 *
 * @author qxq
 */

@Getter
@AllArgsConstructor
public enum RestCode implements IErrorCode {

    /**
     * 操作成功
     */
    SUCCESS(200, "操作成功"),
    /**
     * 业务异常
     */
    FAILURE(400, "业务异常"),

    /**
     * 用户未通过单点登录认证
     */
    NOT_SSO_LOGIN(401, "用户未通过单点登录认证"),

    /**
     * 服务未找到
     */
    NOT_FOUND(404, "服务未找到"),
    /**
     * 服务异常
     */
    ERROR(500, "服务异常"),
    /**
     * Too Many Requests
     */
    TOO_MANY_REQUESTS(429, "Too Many Requests"),
    /**
     * 参数错误
     */
    GLOBAL_PARAM_ERROR(4000, "参数错误"),
    /**
     * 获取当前用户失败
     */
    CURRENT_USER_FAIL(10001, "获取当前用户失败"),
    /**
     * 用户是超级管理员,不可以修改状态
     */
    UPDATE_USER_STATUS(10002, "用户是超级管理员,不可以修改状态"),
    /**
     * 用户是超级管理员,不可以修改密码
     */
    UPDATE_USER_PASSWORD(10003, "用户是超级管理员,不可以修改密码"),
    /**
     * 用户未登录,请登陆后进行访问
     */
    USER_NEED_LOGIN(11001, "用户未登录,请登陆后进行访问"),
    /**
     * 该用户已在其它地方登录
     */
    USER_MAX_LOGIN(11002, "该用户已在其它地方登录"),
    /**
     * 长时间未操作,自动退出
     */
    USER_LOGIN_TIMEOUT(11003, "长时间未操作,自动退出"),
    /**
     * 用户被禁11005用
     */
    USER_DISABLED(11004, "用户被禁11005用"),
    /**
     * 用户被锁定
     */
    USER_LOCKED(11005, "用户被锁定"),
    /**
     * 用户名或密码错误
     */
    USER_PASSWORD_ERROR(11006, "密码错误"),
    /**
     * 用户密码过期
     */
    USER_PASSWORD_EXPIRED(11007, "用户密码过期"),
    /**
     * 用户账号过期
     */
    USER_ACCOUNT_EXPIRED(11008, "用户账号过期"),
    /**
     * 没有该用户
     */
    USER_NOT_EXIST(11009, "没有该用户"),
    /**
     * 用户登录失败
     */
    USER_LOGIN_FAIL(11010, "用户登录失败"),
    /**
     * 验证码错误
     */
    VERIFY_CODE_ERROR(11011, "验证码错误"),
    /**
     * 用户已存在
     */
    USER_IS_EXIST(11012, "用户已存在"),
    /**
     * 无权访问
     */
    NO_AUTHENTICATION(1003006, "无权访问"),
    /**
     * 角色ID无效
     */
    ROLE_IS_NOT_EXIST(13001, "角色ID无效"),
    /**
     * 角色代码已存在
     */
    ROLE_IS_EXIST(13002, "角色代码已存在"),
    /**
     * 配置信息为空
     */
    CONFIG_ID_IS_NOT_EXIST(14001, "配置信息为空"),
    /**
     * 配置ID无效
     */
    CONFIG_IS_NOT_EXIST(14002, "配置ID无效"),
    /**
     * 配置ID已存在
     */
    CONFIG_IS_EXIST(14002, "配置ID已存在"),
    /**
     * 系统配置不允许修改
     */
    CONFIG_IS_SYSTEM(14003, "系统配置不允许修改"),
    /**
     * 系统配置不允许删除
     */
    CONFIG_IS_NOT_DELETE(14003, "系统配置不允许删除"),
    /**
     * 文件不存在
     */
    FILE_DOES_NOT_EXIST(16001, "文件不存在"),
    /**
     * 文件上传异常
     */
    FILE_UPLOAD_EXCEPTION(16002, "文件上传异常"),
    /**
     * 文件下载异常
     */
    FILE_DOWNLOAD_ABNORMAL(16003, "文件下载异常"),
    /**
     * 无效的资源ID
     */
    RESOURCE_NOT_FIND(12001, "无效的资源ID"),
    /**
     * 资源ID已存在
     */
    RESOURCE_IS_EXIST(12001, "资源ID已存在"),
    /**
     * 无效资源父节点ID
     */
    RESOURCE_PARENT_NOT_FIND(12002, "无效资源父节点ID"),
    /**
     * 无效资源父节点ID
     */
    RESOURCE_PARENT_INVALID(12003, "无效资源父节点ID"),
    /**
     * 该资源下有子资源,不能删除
     */
    RESOURCE_HAVE_SUB(12004, "该资源下有子资源,不能删除"),
    /**
     * 机构已存在
     */
    ORG_IS_EXIST(17001, "机构已存在"),
    /**
     * 机构不存在
     */
    ORG_NOT_EXIST(17002, "机构不存在"),
    /**
     * 机构下存在用户
     */
    ORG_HAVE_USER(17003, "机构下存在用户"),
    /**
     * 无效机构父节点ID
     */
    ORG_PID_ERROR(17004, "无效机构父节点ID"),
    /**
     * 父级节点禁止删除
     */
    ORG_TOP_FORBID(17005, "父级节点禁止删除"),
    /**
     * 机构下存在子机构
     */
    ORG_HAVE_BRANCH(17006, "机构下存在子机构"),
    /**
     * 停用原因不能为空
     */
    ORG_STOP_REASON(17007, "停用原因不能为空"),

    //字典管理
    /**
     * 父级ID无效
     */
    DICT_PID_ERROR(18001, "父级ID无效"),
    /**
     * ID无效
     */
    DICT_ID_ERROR(18002, "ID无效"),
    /**
     * 字典code已存在
     */
    DICT_CODE_EXIST(18003, "字典code已存在"),
    /**
     * 字典name已存在
     */
    DICT_NAME_EXIST(18004, "字典name已存在"),
    /**
     * 字典下存在数据
     */
    DICT_HAVE_DATA(18005, "字典下存在数据"),
    /**
     * 字典不存在
     */
    DICT_NOT_EXIST(18006, "字典不存在"),
    /**
     * 存在子节点
     */
    DICT_HAVE_SON(18007, "存在子节点"),
    //数据组
    /**
     * 数据组信息不存在
     */
    GROUP_ID_ERROR(19001, "数据组信息不存在"),
    /**
     * 数据组初始化无机构信息
     */
    GROUP_INIT_DATA_ERROR(19002, "数据组初始化无机构信息");

    /**
     * 状态码
     */
    final int code;
    /**
     * 消息内容
     */
    final String message;

}

3.5单点登录前端业务代码

javascript 复制代码
<template>
  <div class="login-container">
    <el-form ref="loginForm" :model="loginForm" :rules="loginRules" class="login-form" auto-complete="on" label-position="left">

      <div class="title-container">
        <div class="title">
          <img src="@/assets/logo.png" alt="">
        </div>
        <h3 class="title">科研管理系统登录</h3>
      </div>

      <el-form-item prop="username">
        <span class="svg-container">
          <svg-icon icon-class="user" />
        </span>
        <el-input
          ref="username"
          v-model="loginForm.username"
          placeholder="Username"
          name="username"
          type="text"
          tabindex="1"
          auto-complete="on"
        />
      </el-form-item>

      <el-form-item prop="password">
        <span class="svg-container">
          <svg-icon icon-class="password" />
        </span>
        <el-input
          :key="passwordType"
          ref="password"
          v-model="loginForm.password"
          :type="passwordType"
          placeholder="Password"
          name="password"
          tabindex="2"
          auto-complete="on"
          @keyup.enter.native="handleLogin"
        />
        <span class="show-pwd" @click="showPwd">
          <svg-icon :icon-class="passwordType === 'password' ? 'eye' : 'eye-open'" />
        </span>
      </el-form-item>

      <el-button :loading="loading" type="primary" style="width:100%;margin-bottom:30px;" @click.native.prevent="handleLogin">登录</el-button>

    </el-form>
  </div>
</template>

<script>
// import { validUsername } from '@/utils/validate'
import { ssoUserInfo } from '@/api/user'

export default {
  name: 'Login',
  data() {
    const validateUsername = (rule, value, callback) => {
      // if (!validUsername(value)) {
      //   callback(new Error('Please enter the correct user name'))
      // } else {
      //   callback()
      // }
      if (value.length < 5) {
        callback(new Error('The username can not be less than 5 digits'))
      } else {
        callback()
      }
    }
    const validatePassword = (rule, value, callback) => {
      if (value.length < 5) {
        callback(new Error('The password can not be less than 5 digits'))
      } else {
        callback()
      }
    }
    return {
      loginForm: {
        username: '',
        password: ''
      },
      loginRules: {
        username: [{ required: true, trigger: 'blur', validator: validateUsername }],
        password: [{ required: true, trigger: 'blur', validator: validatePassword }]
      },
      loading: false,
      passwordType: 'password',
      redirect: undefined
    }
  },
  watch: {
    $route: {
      handler: function(route) {
        this.redirect = route.query && route.query.redirect
      },
      immediate: true
    }
  },
  mounted() {
    this.performSSOLogin()
  },
  methods: {

    showPwd() {
      if (this.passwordType === 'password') {
        this.passwordType = ''
      } else {
        this.passwordType = 'password'
      }
      this.$nextTick(() => {
        this.$refs.password.focus()
      })
    },
    handleLogin() {
      this.$refs.loginForm.validate(valid => {
        if (valid) {
          this.loading = true
          this.$store.dispatch('user/login', this.loginForm).then(() => {
            // 使用replace方式跳转,确保清除URL中的token参数
            this.$router.replace({ path: this.redirect || '/' })
            this.loading = false
          }).catch(() => {
            this.loading = false
          })
        } else {
          this.$message.error('error submit!!')
          return false
        }
      })
    },
    // 执行SSO登录
    performSSOLogin() {
      console.log('performSSOLogin')
      this.loading = true
      // 1. 首先检查URL中是否有token参数(如:http://xkygl.hbnu.edu.cn/?token=xxx#/ssoLogin?redirect=%2Fdashboard)
      const urlParams = new URLSearchParams(window.location.search)
      const tokenFromUrl = urlParams.get('token')
      if (tokenFromUrl) {
        // 如果URL中有token,直接使用token登录
        console.log('从URL获取到token,使用token登录')
        this.$store.dispatch('user/loginBySSO', tokenFromUrl).then(() => {
          // 使用replace方式跳转,清除URL中的token参数
          this.$router.replace({ path: this.redirect || '/' })
          this.loading = false
        }).catch(error => {
          console.error('token登录失败:', error)
          this.loading = false
          // token登录失败,继续尝试其他登录方式
          this.trySSOLoginFlow()
        })
      } else {
        // URL中没有token,尝试SSO接口登录
        this.trySSOLoginFlow()
      }
    },

    // 尝试SSO接口登录流程
    trySSOLoginFlow() {
      ssoUserInfo().then(response => {
        if (response.code === 200) {
          // 接口返回200,使用返回的token登录
          const { token } = response.data
          this.$store.dispatch('user/loginBySSO', token).then(() => {
            // 使用replace方式跳转,确保清除URL中的token参数
            this.$router.replace({ path: this.redirect || '/' })
          })
        } else {
          // 接口返回非200,进入重定向到CAS登录页面的逻辑
          this.redirectToCasLogin()
        }
      }).catch(error => {
        // 接口调用失败,进入重定向到CAS登录页面的逻辑
        console.error('SSO接口调用失败:', error)
        this.redirectToCasLogin()
      })
    },

    // 重定向到CAS登录页面
    redirectToCasLogin() {
      // https://authserver.hbnu.edu.cn/authserver/login?service=http://xkygl.hbnu.edu.cn/
      const url = 'https://authserver.hbnu.edu.cn/authserver/login?service=http://xkygl.hbnu.edu.cn:9528/sso/checkTicket'
      const link = document.createElement('a')
      link.href = url
      link.rel = 'noreferrer'
      link.click()
      this.loading = false
    }
  }
}
</script>

<style lang="scss">
/* 修复input 背景不协调 和光标变色 */
/* Detail see https://github.com/PanJiaChen/vue-element-admin/pull/927 */

$bg:#283443;
$light_gray:#fff;
$cursor: #fff;

@supports (-webkit-mask: none) and (not (cater-color: $cursor)) {
  .login-container .el-input input {
    color: $cursor;
  }
}

/* reset element-ui css */
.login-container {
  .el-input {
    display: inline-block;
    height: 47px;
    width: 85%;

    input {
      background: transparent;
      border: 0px;
      appearance: none;
      border-radius: 0px;
      padding: 12px 5px 12px 15px;
      color: $light_gray;
      height: 47px;
      caret-color: $cursor;

      &:-webkit-autofill {
        box-shadow: 0 0 0px 1000px $bg inset !important;
        -webkit-text-fill-color: $cursor !important;
      }
    }
  }

  .el-form-item {
    border: 1px solid rgba(255, 255, 255, 0.1);
    background: rgba(0, 0, 0, 0.1);
    border-radius: 5px;
    color: #454545;
  }
}
</style>

<style lang="scss" scoped>
$bg:#2d3a4b;
$dark_gray:#889aa4;
$light_gray:#eee;

.login-container {
  min-height: 100%;
  width: 100%;
  background-color: $bg;
  overflow: hidden;

  .login-form {
    position: relative;
    width: 520px;
    max-width: 100%;
    padding: 160px 35px 0;
    margin: 0 auto;
    overflow: hidden;
  }

  .tips {
    font-size: 14px;
    color: #fff;
    margin-bottom: 10px;

    span {
      &:first-of-type {
        margin-right: 16px;
      }
    }
  }

  .svg-container {
    padding: 6px 5px 6px 15px;
    color: $dark_gray;
    vertical-align: middle;
    width: 30px;
    display: inline-block;
  }

  .title-container {
    position: relative;

    .title {
      font-size: 26px;
      color: $light_gray;
      margin: 0px auto 40px auto;
      text-align: center;
      font-weight: bold;
    }
  }

  .show-pwd {
    position: absolute;
    right: 10px;
    top: 7px;
    font-size: 16px;
    color: $dark_gray;
    cursor: pointer;
    user-select: none;
  }
}
</style>

4.加入单点登录之后的登录流程演示

4.1从网上办事大厅进入

4.1.1进入办事大厅的认证中心界面------湖北师范大学

4.1.2进行登录到办事大厅

4.1.3点击科研管理系统,自动登录到了首页,且是本人账号

4.2直接访问单点登录地址

单点登录地址------http://xkygl.hbnu.edu.cn/#/ssoLogin

4.2.1访问网址,如果没有登录会重定向到认证中心

4.2.2登录后还是和刚才一样

4.2.3如果该浏览器的办事大厅已经登录,访问这个网址则直接会进入科研管理系统首页,也就是免登录那一步

4.3普通的常规登录

普通登录地址------http://xkygl.hbnu.edu.cn/#/login

常规登录就是登录这个网址,然后登录即可

5.本网站单点登录的业务流程

6.单点登录实现过程中遇到的问题

6.1重定向的问题

如果你使用的是前后端不分离的那种项目,就是前端不占用端口的那种,那么网上有很多可以参考的,你在认证中心登录后再重定向到后端的那个url即可,因为后端会返回前端的页面视图,

但是如果你使用的是前后端分离要各占端口的那种,那么你的重定向就只会返回后端返回的JSON数据,无法重定向到前端

比如重定向到单点登录的这个接口:(这个也是我最初的设计方案)

所以这里我们的后端就不能使用请求响应返回JSON风格的那种,要使用Servlet里面的重定向前端页面,并且携带token在前端的URL后面,让前端解析(放在响应体里面应该也可以,但是我没试过),这样就能实现,单点登录后回调后端接口,后端的CAS配置类获取到票据解析到用户信息,然后封装成JWT令牌返回给前端。

其实这里就是让后端充当重定向和token生成的两个作用,避免了上面的那种后端只用于**token生成的一个作用,**却无法实现跳转

6.2 为什么不能重定向到前端,然后让前端拿到票据去后端登录呢

首先我们要明确,单点登录成功后,CAS服务中心会通过302状态码回调我们指定的地址,并且会把登录成功后的票据(ST)拼接在URL后的service客户端地址中。很多开发者会想到第一种方案:让认证中心直接重定向到前端,由前端拿到票据后再访问后端完成登录,比如配置这样的CAS登录跳转地址:

java 复制代码
https://authserver.hbnu.edu.cn/authserver/login?service=http://xkygl.hbnu.edu.cn

但这种方案完全不可行,核心原因是它违背了CAS的设计原则,破坏了CAS Service Ticket(ST)的三大核心验证规则------service绑定规则、会话一致性规则、一次性有效性规则,导致CAS服务端判定票据非法,无法完成验证。其中最核心的原则是:验证票据的service = 生成票据的service ,也就是说,CAS服务端返回的票据仅能被生成时指定的service回调地址使用,其他地址均无法正常解析。

6.2.1 先明确CAS的核心角色与票据本质

在CAS单点登录架构中,存在三个核心角色,明确角色边界是理解问题的关键:

① CAS服务端:即认证中心,核心职责是校验用户身份(账号密码)、生成一次性票据(ST);

② CAS客户端:需要接入单点登录的后端应用(而非前端),负责接收CAS的回调票据、向CAS服务端验证票据有效性,是票据验证的核心主体;

③ 前端应用:仅负责页面展示、引导用户跳转CAS登录页、解析后端传递的最终凭证(如JWT),不参与CAS票据的直接验证流程。

这里的Service Ticket(ST)是CAS服务端生成的一次性、服务绑定型临时凭证,并非通用令牌------它从生成那一刻起,就与指定的service地址绑定,仅允许该地址使用。

6.2.2 核心矛盾:破坏CAS的三大验证规则

当我们把service参数配置为前端地址时,会同时打破CAS的三大核心验证规则,导致票据验证必然失败。

① 规则1:service绑定规则(最关键)

CAS服务端验证票据的核心逻辑是"身份绑定":生成票据时记录的service地址,必须与验证票据时使用的service地址完全一致。具体到错误方案中:

CAS服务端会直接判定:"该ST票据归属前端地址,当前验证的后端地址无权使用",随即返回"票根不符合目标服务"错误。

② 规则2:会话一致性规则

CAS验证票据时,会隐性校验"生成票据的客户端会话"与"验证票据的客户端会话"是否一致,这种一致性依赖浏览器中CAS服务端种下的CASTGC Cookie(会话凭证):

  • 生成票据的会话:用户在CAS登录页输入账号密码时,浏览器会与CAS服务端建立会话,CAS服务端会向浏览器写入CASTGC Cookie;

  • 验证票据的会话:前端拿着ST调用后端接口时,后端服务器无法获取CASTGC Cookie(Cookie是浏览器与CAS服务端的专属会话凭证,不会随前端请求传递给后端)。

CAS服务端发现验证请求未携带合法的会话Cookie,会判定为"非法跨会话请求",直接拒绝验证。

③ 规则3:ST票据的一次性有效性规则

ST票据有两个关键特性:一是有效期极短(默认10~60秒),二是一次性使用------只要被CAS服务端处理过一次(无论验证成功或失败),就会立即销毁失效。

让前端解析ST再传递给后端的流程,会触发两个致命问题:

  • 超时风险:前端解析URL中的ST、拼接参数、发起后端请求的过程,很容易超过ST的有效期;

  • 重复验证风险:若前端因网络波动重复发起请求,第一次请求已让ST失效,后续请求会直接报"票据无效"。

6.2.3 配置文件层面的双重不匹配:雪上加霜

结合实际项目中的CAS客户端配置(后端配置),这种错误方案还会出现"配置层面的双重不匹配",进一步导致验证失败。以下是项目中的典型配置:

java 复制代码
cas:
  # CAS服务端地址------认证中心地址
  server-url-prefix: https://authserver.hbnu.edu.cn/authserver
  # CAS服务端登录地址------认证中心登录地址
  server-login-url: https://authserver.hbnu.edu.cn/authserver/login
  # CAS客户端地址------后端应用根地址
  client-host-url: http://xkygl.hbnu.edu.cn:9528

其中client-host-url的核心作用是:CAS过滤器会基于该地址(后端应用根地址)自动拼接"验证票据用的service参数",拼接规则为:

service = client-host-url + 后端回调接口路径

示例:http://xkygl.hbnu.edu.cn:9528/sso/checkTicket

而错误方案中,跳转CAS时的service是"前端地址http://xkygl.hbnu.edu.cn",与过滤器自动拼接的service存在两处关键不匹配:

① 地址类型不匹配:前端页面地址 vs 后端接口地址;

② 端口不匹配:前端地址无端口(默认80) vs 后端地址带端口:9528。

双重不匹配叠加,票据验证必然失败。

6.2.4 正确方案:遵循"后端接票据,前端拿凭证"原则

CAS的设计逻辑是"票据验证由后端完成,前端仅处理最终登录凭证",正确流程如下:

① 配置service为后端回调接口

跳转CAS的URL需指定为后端回调接口地址(确保与CAS过滤器拼接的service一致):

https://authserver.hbnu.edu.cn/authserver/login?service=http://xkygl.hbnu.edu.cn:9528/sso/checkTicket

② CAS回调后端,完成票据验证

用户在CAS登录成功后,CAS服务端会带着ST票据自动回调后端/sso/checkTicket接口,后端CAS过滤器会主动向CAS服务端验证票据有效性,验证通过后获取用户信息。

③ 后端生成JWT,重定向到前端

后端基于CAS返回的用户信息生成JWT令牌(最终登录凭证),通过response.sendRedirect()重定向到前端页面,并将JWT拼接在URL中:

http://xkygl.hbnu.edu.cn?t=JWT_TOKEN

④ 前端解析JWT,完成自动登录

前端从URL中解析出JWT令牌,调用后端普通登录接口验证令牌合法性,验证通过后保存令牌、更新登录状态,最终进入系统首页。

感兴趣的宝子可以关注一波,后续会更新更多有用的知识!!!

相关推荐
军军君012 小时前
Three.js基础功能学习五:雾与渲染目标
开发语言·前端·javascript·学习·3d·前端框架·three
程序员爱钓鱼2 小时前
Node.js 编程实战:RESTful API 设计
前端·后端·node.js
程序员爱钓鱼2 小时前
Node.js 编程实战:GraphQL 简介与实战
前端·后端·node.js
罗技1233 小时前
Easysearch 集群监控实战(下):线程池、索引、查询、段合并性能指标详解
前端·javascript·算法
利刃大大3 小时前
【SpringBoot】validation参数校验 && JWT鉴权实现 && 加密/加盐
java·spring boot·jwt·加密
XiaoYu20023 小时前
第3章 Nest.js拦截器
前端·ai编程·nestjs
千寻girling3 小时前
面试官 : “ 说一下 Map 和 WeakMap 的区别 ? ”
前端·javascript·面试
2501_924064113 小时前
2025年主流Web自动化测试工具功能与适用场景对比
前端·测试工具·自动化
郑泰科技3 小时前
SpringBoot项目实践:之前war部署到服务器好用,重新打包部署到服务器报404
服务器·spring boot·后端