10. 软件设计&架构-Spring Security 7 整合CAS SSO 单点登录

文章目录

  • 前言
  • 一、先把四种凭证分清楚
  • [二、一次完整的 SSO 登录发生了什么](#二、一次完整的 SSO 登录发生了什么)
  • [三、state 到底解决了什么问题](#三、state 到底解决了什么问题)
  • [四、Ticket 必须由后端验证](#四、Ticket 必须由后端验证)
  • [五、SSO 身份如何变成平台用户](#五、SSO 身份如何变成平台用户)
  • [六、如何把用户放进 Spring Security](#六、如何把用户放进 Spring Security)
  • 七、后续请求怎样获取当前用户
  • [八、使用 Session,为什么还要保留 CSRF](#八、使用 Session,为什么还要保留 CSRF)
  • 最后

前言


很多系统接入 SSO 时,都会走这样一条路:

context 复制代码
SSO 登录成功
  -> 获取用户信息
  -> 业务系统再生成一个 JWT
  -> 前端保存到 localStorage
  -> 每次请求携带 X-Access-Token

这套方案能用,但它很容易制造出两套认证体系:集团 SSO 有自己的登录状态,业务系统又维护一套 JWT。账号禁用、Token 过期、主动退出和权限变化,都要在两套体系之间保持一致。

只要其中一个环节没有处理好,就会出现这些问题:

  • 集团 SSO 已经退出,门户 JWT 仍然有效;

  • 平台账号已经禁用,旧 JWT 还能继续访问;

  • Redis、JWT、userId Header 同时保存用户信息,却没人能说清哪个才可信;

  • 前端长期把 Token 放在 localStorage,一旦发生 XSS,Token 可能被直接读取。因此,在当前项目中,我们没有在 SSO 登录成功后再生成门户 JWT,而是选择了:

集团 SSO 负责证明"你是谁",Spring Security 负责维护"你在本系统中的登录状态",平台账号和角色负责决定"你能做什么"。

登录成功后,后端创建 Spring Security SecurityContext,再通过 Spring Session 保存到 Redis。浏览器只持有 HttpOnly 的 SESSION Cookie。

这篇文章结合当前项目的真实实现,讲清楚这条链路是怎么完成的,以及这套方案为什么值得复用。

一、先把四种凭证分清楚

这套认证流程中,会出现四种容易被统称为"Token"的数据,但它们的职责完全不同。

凭证 作用 保存位置 生命周期
SSO Ticket 集团 SSO 证明当前用户身份 回调 URL 验证一次后立即丢弃
SSO state 绑定发起登录的浏览器与本次回调 临时 Cookie + Redis 5 分钟,只能消费一次
SESSION Cookie 维持门户控制面的登录状态 浏览器 Cookie,内容在 Redis 空闲 2 小时
OAuth2 Access Token 应用调用开放 API 调用方 Bearer Token 短时有效

真正需要记住的是这两条边界:

context 复制代码
人访问门户:SSO Ticket -> Spring Security -> Redis Session

应用调用接口:API Key/Secret -> OAuth2 Access Token -> Gateway

我们没有否定 Token,而是没有让同一个门户同时维护 SSO 和自建 JWT 两套登录状态。

二、一次完整的 SSO 登录发生了什么

整个登录过程涉及浏览器、前端、控制面、Redis、集团 SSO 和数据库。

看起来步骤很多,实际上可以归纳为五件事:

  1. 前端发现用户未登录,发起浏览器顶层 SSO 跳转;

  2. 后端生成一次性 state,记录用户登录前访问的页面;

  3. SSO 登录成功后,带着一次性 Ticket 回调控制面;

  4. 控制面在服务端验证 Ticket,并把域账号映射为平台账号和角色;

  5. 控制面创建 Spring Security 登录状态,并保存到 Redis Session。之后每次请求都不再访问集团 SSO。Spring Security 根据 SESSION Cookie 从 Redis 恢复当前用户。

三、state 到底解决了什么问题

很多人第一次看 SSO 代码时,最疑惑的就是 Redis 里的 state。

state 不是用户身份,也不是登录凭证。它是一张只能用一次的"登录流程取件码"。

用户发起登录时,系统生成一个高强度随机值:

context 复制代码
openhub:sso:state:v1:<UUID> -> /登录前访问的页面

TTL = 5 分钟

同一个 UUID 会放进浏览器的临时 HttpOnly Cookie。SSO 回调时,服务端同时拿到 Cookie 中的 state 和 Redis 中的数据,再通过 Redis GETDEL 原子读取并删除。

它解决了三个问题。

第一,确认这个回调确实对应当前浏览器刚刚发起的登录,而不是别人构造的请求。

第二,防止同一个回调被重复使用。state 一旦消费就会删除,第二次请求自然失败。

第三,保存登录前的页面。认证成功后,用户可以回到原来访问的位置,而不是永远回到首页。

这里还要限制 returnPath 只能是站内相对地址,拒绝完整 URL、双斜杠和反斜杠等内容,否则可能形成开放重定向漏洞。

四、Ticket 必须由后端验证

SSO 回调会带回一个 Ticket。这个 Ticket 不能直接当作用户信息使用,更不能相信浏览器传来的 userId Header。

正确方式是:控制面拿着 Ticket 和固定的 callback 地址,在服务端调用 SSO 的 /p3/serviceValidate

java 复制代码
URI uri = URI.create(endpoint("/p3/serviceValidate")
         + "?ticket=" + encode(ticket)
         + "&service=" + encode(callbackUrl.toString()));

这里有两个关键点。

一是登录时使用的 service 和验证 Ticket 时使用的 service 必须完全一致。CAS Ticket 与 service 绑定,不一致就无法通过验证。

二是 callback 地址必须显式配置,不能根据 HostX-Forwarded-Host 临时拼接。否则代理配置错误或请求头被篡改时,Ticket 可能绑定到错误地址。

如果 SSO 返回 XML,还需要关闭 DTD、外部实体、外部 Schema 和 XInclude,避免 XXE。Ticket、完整响应、Session ID 和用户密码也不能进入日志。

五、SSO 身份如何变成平台用户

集团 SSO 只能证明域账号是谁,却不知道这个人在当前系统里拥有什么角色。

因此,Ticket 验证成功后,系统还要完成一次本地账号映射:

  • 域账号转小写并去除首尾空格,作为平台唯一用户名;

  • 新账号自动创建为 ACTIVE,默认绑定开发者角色;

  • 管理员名单中的新账号绑定平台管理员角色;

  • 已存在账号保留原有角色,不能让 SSO 自动覆盖平台授权;

  • 每次登录同步展示姓名;

  • 已禁用账号拒绝登录;

  • 并发首次登录由数据库唯一键保证只创建一条账号记录。这一步把认证和授权明确分开了:

context 复制代码
SSO 返回的域账号:身份来源

平台数据库中的角色:权限来源

SSO 不应该直接决定业务权限,否则集团身份系统与业务授权模型会被绑死。

六、如何把用户放进 Spring Security

账号准备完成后,系统会创建类型化的登录主体:

java 复制代码
public record PortalPrincipal(
        long accountId,
        String username,
        String displayName,
        Set<String> roles) implements Principal, Serializable {
}

这里只保存后续请求真正需要的最小信息,不保存 Ticket、密码、Secret,也不把整张数据库对象塞进 Session。

然后创建 AuthenticationSecurityContext

java 复制代码
Authentication authentication =
        UsernamePasswordAuthenticationToken.authenticated(
                principal, null, authorities);

SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
securityContextRepository.saveContext(context, request, response);

当前项目启用了显式保存模式,所以只调用 SecurityContextHolder.setContext() 还不够,必须继续调用 saveContext()。否则当前请求里看似已经登录,下一次请求却无法恢复认证信息。

Spring Session 再把 HttpSession 保存到 Redis:

java 复制代码
@EnableRedisHttpSession(
        redisNamespace = "openhub:portal:sso:session",
        maxInactiveIntervalInSeconds = 7200)

浏览器拿到的 SESSION Cookie 只是 Session ID。真实的 SecurityContextPortalPrincipal 都在 Redis 中。

这也意味着控制面可以水平扩容。请求落到任意实例,都能从同一个 Redis 恢复用户身份,不需要粘性会话。

七、后续请求怎样获取当前用户

登录完成后,一次普通请求的认证过程是:

context 复制代码
SESSION Cookie
  -> Spring Session 从 Redis 恢复 HttpSession
  -> Spring Security 恢复 SecurityContext
  -> 账号状态过滤器检查用户是否仍为 ACTIVE
  -> URL 规则或 @PreAuthorize 校验权限
  -> 业务代码取得当前 Principal

业务代码不再读取客户端传来的 userId。Controller 可以直接注入 PrincipalAuthentication 或使用 @AuthenticationPrincipal

java 复制代码
public Result<?> create(
        @RequestBody CreateRequest request,
        @AuthenticationPrincipal PortalPrincipal principal) {
    return Result.success(service.create(request, principal.username()));
}

如果不想让每个接口都出现认证参数,可以在应用层封装一个很薄的 CurrentUserProvider,内部从 SecurityContextHolder 获取当前主体。这样业务方法只依赖清晰的当前用户抽象,不需要复制身份解析代码。

八、使用 Session,为什么还要保留 CSRF

浏览器会自动携带 Cookie,这是 Session 好用的原因,也是 CSRF 风险存在的原因。

HttpOnly 只能阻止 JavaScript 读取 Cookie,不能阻止浏览器自动发送 Cookie。因此控制面的 POST、PUT、DELETE 等写请求仍然需要 CSRF Token。

当前做法是:

  • SSO 登录入口和 callback 没有业务写入,可以排除 CSRF;

  • 登录恢复后,前端调用 /api/v1/auth/csrf

  • CSRF Token 只保存在前端内存,并通过请求 Header 发送;

  • 其他控制面写接口继续接受 Spring Security 的 CSRF 校验。需要强调:state 和 CSRF Token 不是一回事。

context 复制代码
state:保护 SSO 登录到 callback 的一次性流程

CSRF Token:保护登录后的业务写请求

最后

这套集成真正重要的,不是某一个 Filter,也不是用了 Redis 或 JWT,而是先把职责分清楚:

context 复制代码
集团 SSO:证明用户是谁

Spring Security + Redis Session:维护门户登录状态

平台账号和角色:决定用户能做什么

OAuth2 Access Token:解决应用调用开放 API

SSO Ticket 是一次性身份证明,state 是一次性流程凭证,SESSION 是门户会话索引,OAuth2 Access Token 是应用访问开放 API 的凭证。

只要不把这四种凭证混在一起,登录、授权、退出、账号禁用和后续微服务拆分都会清晰很多。

技术选型没有绝对先进与落后。对同源门户,选择可撤销的 Redis Session;对跨服务的应用调用,选择标准 OAuth2 Access Token。这比"所有场景都发 JWT"更简单,也更符合真实的安全边界。


延伸阅读:

  • Apereo CAS Protocol Specification

  • Spring Security:Persisting Authentication

  • Spring Session:HttpSession with Redis

  • Spring Security:CSRF


本文的引用仅限自我学习如有侵权,请联系作者删除。

参考知识

Spring Security 7 整合CAS SSO 单点登录:从 CAS Ticket 到 Redis Session


相关推荐
品牌测评1 小时前
大模型推理算力平台推荐分享|六家平台计费与架构拆解
大数据·人工智能·架构
Zane19941 小时前
单例线程安全、生产者消费者、死锁:并发面试三连问串讲
java·后端
微三云 - 廖会灵 (私域系统开发)1 小时前
智慧社区运营破局:“消费返物业费” 模式的数字化架构与落地实践
大数据·架构
云烟成雨TD2 小时前
Micrometer 系列【42】链路追踪:Span 体系 | 核心 API
java·链路追踪·micrometer
Gorway2 小时前
理解 Spring 依赖注入:从构造器注入到集合与条件 Bean
java·后端
LiLiYuan.3 小时前
【字符串常量池】
java·开发语言·面试
雨白3 小时前
面向对象六大基本原则实战:从零重构支持引擎切换的网络框架
android·架构
Sylvia33.3 小时前
从轮询到推送:足球数据API架构演进与火星数据技术拆解
java·服务器·网络·python·websocket·架构
cfm_29143 小时前
高并发系统缓存全解
java·缓存