文章目录
- 前言
- 一、先把四种凭证分清楚
- [二、一次完整的 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、
userIdHeader 同时保存用户信息,却没人能说清哪个才可信; -
前端长期把 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 和数据库。

看起来步骤很多,实际上可以归纳为五件事:
-
前端发现用户未登录,发起浏览器顶层 SSO 跳转;
-
后端生成一次性 state,记录用户登录前访问的页面;
-
SSO 登录成功后,带着一次性 Ticket 回调控制面;
-
控制面在服务端验证 Ticket,并把域账号映射为平台账号和角色;
-
控制面创建 Spring Security 登录状态,并保存到 Redis Session。之后每次请求都不再访问集团 SSO。Spring Security 根据
SESSIONCookie 从 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 地址必须显式配置,不能根据 Host 或 X-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。
然后创建 Authentication 和 SecurityContext:
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。真实的 SecurityContext 和 PortalPrincipal 都在 Redis 中。
这也意味着控制面可以水平扩容。请求落到任意实例,都能从同一个 Redis 恢复用户身份,不需要粘性会话。
七、后续请求怎样获取当前用户
登录完成后,一次普通请求的认证过程是:
context
SESSION Cookie
-> Spring Session 从 Redis 恢复 HttpSession
-> Spring Security 恢复 SecurityContext
-> 账号状态过滤器检查用户是否仍为 ACTIVE
-> URL 规则或 @PreAuthorize 校验权限
-> 业务代码取得当前 Principal
业务代码不再读取客户端传来的 userId。Controller 可以直接注入 Principal、Authentication 或使用 @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