为什么 SSO 的方案会从 Session 共享一路演进到 OAuth2+JWT?这不是技术炫技,而是一条被"分布式"逼出来的逻辑主线。
环境说明 :本文基于 Spring Boot 3.2.x + Java 17;JWT 部分使用 JJWT 0.12.x;CAS 部分使用 Apereo CAS Client 4.x(Jakarta 兼容版),如使用 Spring Boot 2.x,请将org.apereo换回org.jasig并使用 3.6.x。
引子:一个真实的问题
假设你所在的公司,三年内陆续上线了 OA、CRM、HR 三个系统,分别由三个团队开发:
- OA 用 Spring Boot + Session
- CRM 用 Spring Boot + Session
- HR 用 Spring Boot + Session
三个系统都跑得好好的,直到老板问了一句: "为什么员工要登录三次?"
这个问题的本质是:用户身份本应是一份,但被三个系统各自复制了一份。 SSO 要做的,就是把这份身份从"每个系统各存一份",逐步演进成"全局一份、集中管理"。
下面我们沿着这条主线,一层一层往下走。每走一层,都会遇到新问题,也都会催生下一个方案。
第一层:最朴素的解法------Session 共享
问题起点
单个系统里,用户登录后服务端把身份信息放进 HttpSession,浏览器拿着 JSESSIONID 每次请求带回来,服务端就能认出你。
但问题是:OA 的 Session 存在 OA 的 JVM 内存里,CRM 根本访问不到。

三份独立身份,这就是要打破的第一堵墙。
第一层解法:把 Session 从"内存"搬到"公共仓库"
既然每个系统的内存互相隔离,那就把 Session 统一存到一个所有系统都能访问的地方------Redis。

SpringSession 干的就是这件事:用 Redis 接管 HttpSession 的读写,同时把 Cookie 的 Domain 设为顶级域名,让浏览器访问任意子域都带同一个 SessionID。
核心配置就两处:
xml
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
yml
spring:
session:
store-type: redis
redis:
host: localhost
port: 6379
java
@Configuration
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 1800) // 超时统一在此设置
public class SessionConfig {
@Bean
public CookieSerializer cookieSerializer() {
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
serializer.setDomainName("mall.com"); // 关键:顶级域名
serializer.setCookiePath("/");
serializer.setUseHttpOnlyCookie(true);
return serializer;
}
}
注意:
maxInactiveIntervalInSeconds与spring.session.timeout二者只需保留一处,重复配置时以注解为准,容易踩坑。
第一层留下的问题
Session 共享解决了"同域多系统"的登录问题,但它暴露了两个更深的矛盾:
- 跨域失效 :如果系统是
oa.company.com和crm.partner.com,Cookie 根本带不过去。 - 有状态瓶颈:所有请求都要查 Redis,Redis 一旦抖动,全站登录态崩盘。
逻辑推进: "身份必须放在公共仓库"这个思路是对的,但"以 Cookie + Session 为载体"这条路走到了头。 要突破域名边界,就必须把身份从 Cookie 里解放出来------这引出了下一层。
第二层:把认证"抽出去"------CAS 中央认证
思路跃迁
既然每个系统都要自己认证太麻烦,那就在它们之上放一个唯一的认证中心:
- 所有系统不再自己处理登录
- 登录这件事,统一交给认证中心
- 认证成功后,认证中心发一个"凭证"给业务系统
这就是 CAS(Central Authentication Service)的核心思想。
流程图

关键机制:Ticket
CAS 的凭证有两种:
| Ticket 类型 | 作用 | 有效期 |
|---|---|---|
| TGT(Ticket Granting Ticket) | 存在 CAS Server,代表"用户已登录" | 长(会话级) |
| ST(Service Ticket) | 发给业务系统,一次性使用 | 短(秒级) |
用户第一次登录后拿到 TGT,之后访问任何接入 CAS 的系统,CAS 都直接用 TGT 换一个新的 ST 回去------用户感受不到第二次登录。
Java 客户端集成
Spring Boot 3 使用 Jakarta 命名空间,因此必须选用 Apereo 的 4.x 版本(包名由 org.jasig 变更为 org.apereo):
xml
<dependency>
<groupId>org.apereo.cas.client</groupId>
<artifactId>cas-client-core</artifactId>
<version>4.0.4</version>
</dependency>
CAS 客户端的 AuthenticationFilter 是抽象类,需要先继承一个子类:
java
public class CasAuthenticationFilter extends AuthenticationFilter {
public CasAuthenticationFilter(String casServerLoginUrl, String serverName) {
setCasServerLoginUrl(casServerLoginUrl);
setServerName(serverName);
}
}
再注册两个 Filter(顺序不能错):
java
@Configuration
public class CasConfig {
@Value("${cas.server-login-url}")
private String casServerLoginUrl;
@Value("${cas.client-host-url}")
private String clientHostUrl;
@Bean
public FilterRegistrationBean<SingleSignOutFilter> singleSignOutFilter() {
FilterRegistrationBean<SingleSignOutFilter> reg = new FilterRegistrationBean<>();
reg.setFilter(new SingleSignOutFilter());
reg.addUrlPatterns("/*");
reg.setOrder(Ordered.HIGHEST_PRECEDENCE); // 必须排在最前
return reg;
}
@Bean
public FilterRegistrationBean<AuthenticationFilter> casAuthenticationFilter() {
FilterRegistrationBean<AuthenticationFilter> reg = new FilterRegistrationBean<>();
reg.setFilter(new CasAuthenticationFilter(casServerLoginUrl, clientHostUrl));
reg.addUrlPatterns("/*");
reg.setOrder(Ordered.HIGHEST_PRECEDENCE + 1);
return reg;
}
}
SingleSignOutFilter负责接收 CAS Server 的登出回调,将 ST 与当前 Session 绑定,实现单点登出;顺序上必须早于AuthenticationFilter,否则登出会失败。
第二层留下的问题
CAS 跨域了、集中了,但新的矛盾又出现了:
- 业务系统仍需维护本地 Session ,本质上还是有状态的------每次会话都要在业务端存一份。
- 前后端分离架构不友好:重定向 + Cookie 这套东西,在 App、小程序、SPA 里非常别扭。
- 协议重、定制难:CAS 是老协议,对接现代开放平台(微信、GitHub)几乎无法复用。
逻辑推进: "认证集中"是对的,但"凭证必须依赖服务端状态"是根本束缚。 要想彻底解耦,凭证本身必须"自证身份"------这就是 Token 的思路。
第三层:让凭证自己说话------JWT
思路跃迁
CAS 的 ST 是"一个随机字符串",业务系统拿到它之后还得回头问 CAS:"这个字符串代表谁?"
而 JWT(JSON Web Token)直接把用户信息装进令牌本身,并用签名保证它无法被伪造 。业务系统拿到 Token 后,自己验签就能确认身份,不需要回头问任何人。

| 部分 | 内容 | 是否加密 |
|---|---|---|
| Header | 算法类型、令牌类型 | 否(Base64) |
| Payload | userId、username、exp 等 | 否(Base64,千万别放敏感信息) |
| Signature | 对前两段的签名 | --- |
关键认知:Payload 只是 Base64 编码,不是加密。 任何人都能解开看,但改一位就会导致签名校验失败。所以 JWT 保的是"防篡改",不是"防窥探"。
Java 实现(JJWT 0.12.x)
xml
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
java
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
private static final long ACCESS_EXPIRE = 30 * 60 * 1000L; // 30min
private SecretKey key() {
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
public String generate(String userId, String username) {
return Jwts.builder()
.id(UUID.randomUUID().toString()) // JTI,为后续黑名单预留
.subject(userId)
.claim("username", username)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + ACCESS_EXPIRE))
.signWith(key(), Jwts.SIG.HS256)
.compact();
}
public Claims parse(String token) {
return Jwts.parser()
.verifyWith(key())
.build()
.parseSignedClaims(token)
.getPayload();
}
public boolean isExpired(String token) {
try {
return parse(token).getExpiration().before(new Date());
} catch (ExpiredJwtException e) {
return true;
}
}
}
注意:0.12.x 的 API 与 0.11.x 差异较大(
parserBuilder→parser、setSubject→subject、SignatureAlgorithm→Jwts.SIG),照抄旧教程会直接编译失败。
到这一步,业务系统只要拿到 Token,验签通过就等于"认证通过"------彻底无状态。
第三层留下的问题
JWT 很美好,但它的"无状态"是把双刃剑:
- Token 一旦签发,过期前无法撤销------用户登出、改密码、被封号,旧 Token 依然有效。
- 有效期两难:设短了,用户频繁重登;设长了,泄露风险大。
- 密钥管理:HS256 是对称加密,所有资源服务器都持有同一把密钥,一处泄露全盘皆输。
逻辑推进: "无状态"给了我们跨域和性能,也拿走了"控制权"。 如何在保留无状态优势的同时,重新获得对身份的部分控制?这就要靠"协议 + 工程手段"补齐。
第四层:标准化 + 工程补丁------OAuth2 与 JWT 的组合拳
OAuth2 解决的是"授权流程",JWT 解决的是"凭证载体"
很多人把 OAuth2 和 JWT 混为一谈,其实它们分工明确:
- OAuth2 是一套授权协议,规定了"客户端如何向认证中心申请令牌"的标准流程。
- JWT 是令牌的一种实现格式,负责装用户信息、防篡改。
把它们组合起来,就得到了现在 SSO 的标准形态:

上图为了突出主线做了简化:完整授权码流程中,SPA 会先拿到 authorization code,再由后端或 PKCE 换取 Token。这里只保留"客户端 - 认证中心 - 资源服务器"的三方关系。
OAuth2 的授权码模式(Authorization Code + PKCE)是 SSO 场景下最推荐的流程,原因是授权码通过后端换取 Token,避免了 Token 直接暴露在前端 URL 中。
认证中心搭建(Spring Boot 3.x)
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
</dependency>
java
@Configuration
@EnableWebSecurity
public class AuthServerConfig {
@Bean
@Order(1)
public SecurityFilterChain authServerChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.oidc(Customizer.withDefaults());
return http.build();
}
@Bean
@Order(2)
public SecurityFilterChain defaultChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(a -> a.anyRequest().authenticated())
.formLogin(Customizer.withDefaults());
return http.build();
}
@Bean
public RegisteredClientRepository clients() {
RegisteredClient c = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("business-a")
.clientSecret("{noop}secret-a")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri("http://localhost:8081/login/oauth2/code/sso")
.scope(OidcScopes.OPENID)
.scope(OidcScopes.PROFILE)
.build();
// 生产环境请替换为 JdbcRegisteredClientRepository,并配合数据库持久化
return new InMemoryRegisteredClientRepository(c);
}
}
业务系统接入
yml
spring:
security:
oauth2:
client:
registration:
sso:
client-id: business-a
client-secret: secret-a
scope: openid,profile
authorization-grant-type: authorization_code
provider:
sso:
issuer-uri: http://localhost:9000
业务系统此时只是一个"资源服务器":收到请求 → 解析 JWT → 验签 → 取出用户身份。不查数据库、不调认证中心、不存 Session。
第五层:把"控制权"补回来------生产环境三大补丁
到第四层,SSO 的主干已经完成。但真实生产环境里,还需要处理 JWT "无法撤销"的副作用。这三个补丁,是让方案真正落地的关键。
补丁一:双 Token 机制(解决有效期两难)

- AccessToken:短有效期(15-30 分钟),泄露影响小
- RefreshToken:长有效期(7-14 天),仅用于换新,不在业务请求中携带
补丁二:Redis 黑名单(解决主动注销)
用户登出时,把当前 Token 的 JTI 塞进 Redis,过期时间设为 Token 的剩余寿命:
java
public void logout(String token) {
Claims claims = jwtUtil.parse(token);
long ttl = claims.getExpiration().getTime() - System.currentTimeMillis();
if (ttl > 0) {
redisTemplate.opsForValue().set(
"jwt:blacklist:" + claims.getId(), "1",
ttl, TimeUnit.MILLISECONDS);
}
}
网关侧校验时先查黑名单,命中即拒绝。这样既保留了 JWT 的无状态优势,又补上了"可撤销"的能力。
补丁三:RS256 非对称签名(解决密钥泄露)
| 算法 | 密钥 | 风险 | 适用 |
|---|---|---|---|
| HS256 | 对称(一把) | 任一资源服务器泄露即全盘失守 | 单体、内部服务少 |
| RS256 | 非对称(公/私钥) | 资源服务器只持公钥,泄露无危害 | 多系统、生产推荐 |
认证中心用私钥签发,资源服务器用公钥验签,私钥永远不出认证中心。
收束:主线的终点与选型

我们把这条主线重新串一遍:
每一层都不是推翻上一层,而是继承其核心思想、解决其遗留矛盾:
- Session 共享:继承"身份集中",解决"内存隔离"
- CAS:继承"认证集中",解决"跨域"
- JWT:继承"集中认证",解决"有状态"
- OAuth2+JWT+补丁:继承"无状态",补回"可控性"
最终选型建议
| 你的场景 | 推荐方案 | 核心理由 |
|---|---|---|
| 同域/子域内部系统,快速上线 | SpringSession + Redis | 改造成本极低,几行配置 |
| 已有 CAS Server 的企业 | CAS 客户端集成 | 复用现有基础设施 |
| 前后端分离 + 微服务 | OAuth2 + JWT | 标准化、无状态、天然跨域 |
| 需要对接第三方登录 | OAuth2 + OIDC | 微信/GitHub 的标准协议 |
如果只记一句话: 现代 Java SSO = OAuth2 管流程 + JWT 管凭证 + Redis 管黑名单 + RS256 管密钥。
你所在的系统目前停在哪一层?踩过哪一层的坑?欢迎在评论区聊聊。
示例代码基于 Spring Boot 3.2.x + Java 17;CAS 部分使用 Apereo CAS Client 4.x,认证中心与业务系统分属不同端口启动即可跑通。