基于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令牌,调用后端普通登录接口验证令牌合法性,验证通过后保存令牌、更新登录状态,最终进入系统首页。

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

相关推荐
于慨21 小时前
Lambda 表达式、方法引用(Method Reference)语法
java·前端·servlet
石小石Orz21 小时前
油猴脚本实现生产环境加载本地qiankun子应用
前端·架构
swg32132121 小时前
Spring Boot 3.X Oauth2 认证服务与资源服务
java·spring boot·后端
从前慢丶21 小时前
前端交互规范(Web 端)
前端
像我这样帅的人丶你还21 小时前
别再让JS耽误你进步了。
css·vue.js
gelald1 天前
SpringBoot - 自动配置原理
java·spring boot·后端
@yanyu6661 天前
07-引入element布局及spring boot完善后端
javascript·vue.js·spring boot
CHU7290351 天前
便捷约玩,沉浸推理:线上剧本杀APP功能版块设计详解
前端·小程序
GISer_Jing1 天前
Page-agent MCP结构
前端·人工智能
王霸天1 天前
💥别再抄网上的Scale缩放代码了!50行源码教你写一个永不翻车的大屏适配
前端·vue.js·数据可视化