目录
[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 的核心区别(避免混淆))
[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(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地址完全一致。具体到错误方案中:
生成票据的service:配置的service=http://xkygl.hbnu.edu.cn(前端地址),CAS服务端会将该地址与ST票据永久绑定,标记"此票据专属前端地址使用";
实际验证票据的service:前端拿到ST后,调用后端接口**(如http://xkygl.hbnu.edu.cn:9528/sso/user-info)**,此时后端的CAS过滤器会自动以"后端接口地址"作为service,向CAS服务端发起验证。
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一致):

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


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