Java 单点登录实战:一条主线看懂 Session 共享、CAS 与 OAuth2+JWT

为什么 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;
    }
}

注意:maxInactiveIntervalInSecondsspring.session.timeout 二者只需保留一处,重复配置时以注解为准,容易踩坑。

第一层留下的问题

Session 共享解决了"同域多系统"的登录问题,但它暴露了两个更深的矛盾:

  1. 跨域失效 :如果系统是 oa.company.comcrm.partner.com,Cookie 根本带不过去。
  2. 有状态瓶颈:所有请求都要查 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 跨域了、集中了,但新的矛盾又出现了:

  1. 业务系统仍需维护本地 Session ,本质上还是有状态的------每次会话都要在业务端存一份。
  2. 前后端分离架构不友好:重定向 + Cookie 这套东西,在 App、小程序、SPA 里非常别扭。
  3. 协议重、定制难: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 差异较大(parserBuilderparsersetSubjectsubjectSignatureAlgorithmJwts.SIG),照抄旧教程会直接编译失败。

到这一步,业务系统只要拿到 Token,验签通过就等于"认证通过"------彻底无状态。

第三层留下的问题

JWT 很美好,但它的"无状态"是把双刃剑:

  1. Token 一旦签发,过期前无法撤销------用户登出、改密码、被封号,旧 Token 依然有效。
  2. 有效期两难:设短了,用户频繁重登;设长了,泄露风险大。
  3. 密钥管理: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,认证中心与业务系统分属不同端口启动即可跑通。

相关推荐
xiaoqiMikko1 小时前
Jetty 又出一条走私(CVE-2026-19203):官方叫升 9.4.64,而 9.4.64 在 Central 上是 404
java·安全
Web4Browser1 小时前
浏览器指纹逆向到底在分析什么:从 JavaScript 采集、Canvas 与 WebGL 到环境一致性校验
java·服务器·前端
卷无止境1 小时前
Jev来了,一个不会说话的AI模型正在改写自动化的规则
人工智能·后端
AKA__Zas1 小时前
跨网段主机的文件传输过程
java·网络·网络协议·学习方法
Wang's Blog1 小时前
Java 接入Redis: Redis简介与NoSQL定位
java·服务器·redis
IT_陈寒1 小时前
React的useEffect依赖项居然骗了我三年
前端·人工智能·后端
程序猿乐锅1 小时前
【黑马点评 | 第四篇】Redis缓存雪崩
java·数据库·spring boot·redis·缓存
小尹哥-程序员1 小时前
第4集:让AI学会看文档:Spring AI RAG入门实战
java·人工智能·spring
AINative软件工程2 小时前
LLM 应用的 Bulkhead 隔离工程实践:用舰壁模式防止一个功能的过载拖左整个 AI 系统
后端·llm·ai编程