Spring Boot 3 + Spring Authorization Server 构建企业级 SSO:多端互通、会话共享与

1. 为什么传统 Session 方案撑不住现在的业务形态

早期做单体应用时,一套 HttpSession 加个 Redis 共享就能跑通登录。但业务一旦拆成微服务,客户端又多了 App、小程序、H5 和开放 API,传统方案就开始捉襟见肘:跨域 Cookie 带不过去、移动端后台保活差、网关层鉴权逻辑重复写。更麻烦的是,权限模型稍微一复杂,Session 里塞满数据,序列化体积直接拖垮网络。

现在的企业级 SSO 核心就三件事:身份怎么认、权限怎么发、状态怎么管 。OAuth 2.0 管的是授权(能不能拿资源),不管身份(你是谁)。所以生产环境基本都走 OAuth 2.0 + OIDC 组合:OIDC 用 ID Token/userinfo 端点把身份声明标准化,OAuth 2.0 负责发 Access Token 控访问。Spring 官方早就废弃了老旧的 spring-security-oauth2,现在 Spring Boot 3.x / Spring Security 6.x 时代,唯一正统的选型就是 Spring Authorization Server。下面这套方案是我们线上跑了一年多、踩过坑后沉淀下来的实现路径。

2. 架构拓扑与令牌流转

架构设计别搞太复杂,职责分清就行。认证中心只干认证和发令牌的活,业务服务只做无状态验签,网关负责路由和透传。

复制代码
[PC BFF]      [Mobile App]      [小程序]      [Open API]
    |                |               |               |
    +-------+--------+-------+-------+-------+-------+
            |  HTTPS + PKCE + OIDC Flow     |
            v                               v
    +-------------------------------------------+
    |         认证中心 (Auth Server)            |
    | • Spring Authorization Server 1.2        |
    | • JWKS / OIDC Discovery / Introspection  |
    +-------------------------------------------+
            |  Bearer Token / Secure Cookie    |
            v
    +-------------------------------------------+
    |       API 网关 / 业务资源服务            |
    | • Spring Security Resource Server        |
    | • 租户路由 / 数据过滤 / 本地验签         |
    +-------------------------------------------+
            |  Redis (Blocklist / 会话状态)     |

流转逻辑要点

  • 授权码流 + PKCE:所有公共客户端(Web、App)强制上 PKCE,防中间人截获授权码。别信那些说"内网不用 PKCE"的,安全策略必须一刀切。
  • Client Credentials:内部服务间调用、定时任务走这个流,直接拿 Token,不绑用户上下文。
  • ID Token :OIDC 签发,含 subissaudexpsid。前端拿它做路由守卫和基础信息展示,别用它调业务接口
  • Access Token :JWT 格式,塞 scopetenant_id、粗粒度角色。有效期压到 15~30 分钟,短命才安全。
  • Refresh Token:长期有效,必须绑设备指纹。支持单设备独立刷新,管理员一键吊销。

网关层统一拦截请求,透传 Authorization: Bearer <token>。资源服务本地验签,不查库,水平扩容不依赖会话粘性。

3. Spring Authorization Server 核心配置

Spring AS 1.2 的配置已经高度声明式,别再去硬凑旧的 HttpSecurity 链了。核心就三块:安全链配置、客户端注册、Token 扩展。

java 复制代码
@Configuration
public class AuthorizationServerConfig {

    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public SecurityFilterChain authServerSecurityFilterChain(HttpSecurity http) throws Exception {
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
        http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
            .oidc(Customizer.withDefaults()) // 开启 OIDC,自动挂载 .well-known 和 /userinfo
            .tokenEndpoint(token -> token
                .accessTokenRequestConverter(new DelegatingAuthenticationConverter(
                    List.of(new OAuth2AuthorizationCodeRequestAuthenticationConverter(),
                            new OAuth2RefreshTokenRequestAuthenticationConverter())))
            );
        // 关闭 CSRF,AS 本身走 API 交互,不需要表单防重放
        http.csrf(AbstractHttpConfigurer::disable);
        return http.build();
    }

    @Bean
    public OAuth2TokenCustomizer<JwtEncodingContext> jwtTokenCustomizer() {
        return context -> {
            if (context.getTokenType().equals(OAuth2TokenType.ACCESS_TOKEN)) {
                Authentication principal = context.getPrincipal();
                // 注意:claims() 返回的是 Consumer<JwtClaimsSet.Builder>
                context.getClaims().claims(claims -> {
                    claims.claim("tenant_id", extractTenantId(principal));
                    claims.claim("roles", extractRoleNames(principal));
                    // sid 应该在认证阶段生成一次并绑定到会话,不要每次发 AT 都重新生成
                    claims.claim("sid", context.getAuthorization().getPrincipalName() + ":" + extractSessionId(context));
                });
            }
        };
    }

    // 省略 RegisteredClientRepository、JwkSource 等基础设施 Bean
}

线上容易踩的坑

  • sid 生成逻辑:很多教程里直接 UUID.randomUUID(),这会导致每次刷新 Access Token 都变一次 sid,会话绑定直接失效。正确做法是用户登录认证成功时生成一次 sid,存入 OAuth2Authorization 上下文,后续发 AT/RT 时直接复用。
  • 客户端存储:测试用 InMemoryRegisteredClientRepository 没问题,生产必须上 JDBC 或 Redis。开放平台场景最好实现动态注册,别把密钥写死在代码里。
  • 开了 .oidc(Customizer.withDefaults()) 后,.well-known/openid-configuration 自动就绪。前端或第三方应用直接请求这个端点就能拿到所有协议地址,自适应对接。

4. 会话管理:无状态与可撤销的平衡

纯 JWT 无状态性能高,但踢人、登出、改权限时无法实时生效。我们线上用的是 JWT 验签 + Redis Blocklist 兜底 的混合方案。

  1. 跨域 Cookie 处理 :前端直连认证中心的话,Cookie 跨域基本没戏。现在标准做法是上 BFF 层,同域下发 HttpOnly; Secure; SameSite=Lax。如果非要跨域,只能 SameSite=None; Secure,且全站 HTTPS 不能省。老版本 iOS/Android WebView 对 None 支持很烂,遇到兼容问题直接降级走 BFF 代理或 URL 一次性 Ticket。
  2. 会话绑定与互斥 :JWT 里带了 sid。资源服务拿到 Token 后,拿 sid 去 Redis 查设备指纹和最后活跃时间。单点登录互斥逻辑很简单:SETNX session:{sid} {deviceId},设个合理过期时间。新设备登录成功就把旧 sid 写进黑名单。
  3. 主动吊销 :用户登出或管理员踢人时,把 jtisid 扔进 Redis blacklist,TTL 设成 Token 剩余有效期。资源服务拦截器验签通过后,顺手 GET blacklist:{jti},命中直接抛 401。这招兼顾了无状态的高性能和强管控的灵活性,实际排查时比清 Session 表好用得多。

5. 多租户与数据权限隔离

SaaS 或多组织架构下,认证中心只管"你是谁"和"属于哪个租户",细粒度权限必须下沉到业务侧。

  • JWT 别塞太满tenant_id 和顶层角色(如 ADMINUSER)可以放 Token 里。别把菜单权限、按钮权限全塞进去,Token 一膨胀,网关解析慢,数据库还没查完网络先超时了。

  • 租户上下文透传 :网关验签后,把 tenant_id 塞进 MDC 或 ThreadLocal。业务层通过 MyBatis 拦截器或 JPA @Filter 自动拼 WHERE tenant_id = ?。这比每个 Service 手动传参干净得多,也不容易漏。

  • 动态权限计算 :用 Spring Security 的 @PreAuthorize 结合自定义表达式。比如:

    java 复制代码
    @PreAuthorize("@tenantScopeChecker.hasDataAccess(authentication, #resourceId)")
    public OrderDTO getOrder(String resourceId) { ... }

    tenantScopeChecker 内部走本地缓存(Redis + Caffeine),查租户-数据域映射关系。行级权限判定控制在毫秒级,不拖慢接口响应。

6. 安全加固:不是堆组件,是卡点设计

安全策略得嵌在架构里,事后打补丁往往漏风。

  1. 防重放与 OIDC 校验 :JWT 本身不防重放。OIDC 授权请求必须带 nonce,认证中心签发 ID Token 时原样回传 c_hash,前端或 BFF 拿到先校验哈希,不一致直接阻断。业务层关键操作(支付、改密)要求带 X-Request-Nonce,服务端校验一次性。
  2. Refresh Token 轮转:生产环境建议开启 RT Rotation。每次用旧 RT 换 AT 时,服务端签发新 RT,旧 RT 立即失效。一旦 RT 被盗刷,攻击者换到的是废的,正常客户端拿新 RT 继续走。配合设备指纹校验,基本能拦下大部分盗刷。
  3. 异常登录拦截:别等被刷库了才加验证码。接入 Bucket4j 或 Redis 令牌桶,单 IP 连续 5 次失败直接锁 15 分钟。异地登录、非工作时间高危操作,强制 Step-up 认证(弹 MFA 或重输密码)。这些逻辑放在认证中心统一拦截,业务服务不用管。

7. 生产调优与部署经验

认证中心是流量的咽喉,压测和调优得做在前面。

  • 签名性能 :RS256 是非对称加密,CPU 消耗比 HS256 高一个数量级。单机实际压测下来,RS256 在 8C16G 机器上稳定在 3000~5000 QPS 左右,瓶颈在 RSA 签名计算。优化手段:预加载 RSAKey 到内存,别每次请求重新生成 KeyPair;JWKS 端点走 CDN 或 Nginx 静态化,别打穿 AS 实例。
  • 缓存策略RegisteredClient 和 JWKS 配置用 Caffeine 做本地缓存,TTL 设 10 分钟。Redis 做二级缓存和状态同步。HikariCP 连接池别瞎调大,maximum-pool-size 保持在 CPU核数 * 2 + 磁盘数 就行,认证中心连接数多了反而上下文切换严重。
  • JIT 用户同步:对接第三方 IdP 或首次 OIDC 登录时,别同步查库写库拖慢响应。解析 ID Token 后,轻量级 Token 直接返回,用户落库和权限初始化扔给 MQ 异步处理。后续请求走本地缓存,断网或上游 IdP 抖动不影响已有用户登录。
  • 部署架构:AS 实例无状态,K8s 多副本部署没问题。JWKS 通过 LB 暴露,所有实例共享同一套 RSA Key 对(或走 KMS 定期轮换)。Redis 用 Cluster 模式防单点,数据库主从同步。监控盯紧 CPU 使用率、Token 签发延迟和 4xx 错误率,延迟超过 150ms 就该看连接池或签名逻辑了。

8. 线上踩坑实录

协议细节和环境差异最容易让人掉坑里,记录几个高频排查点:

  1. 移动端 Token 刷新死循环 :App 切后台被杀,SDK 自动重试逻辑容易陷入 401 -> 刷新 -> 401 的死循环。正确做法:捕获 401 先判断本地 RT 是否有效,有效才调 /oauth2/token,拿到新 Token 后静默重试原请求。Token 存储必须上 Android Keystore / iOS Keychain,明文存 SharedPreferences 或明文 Keychain 等于是裸奔。
  2. 跨域预检失败 :前端带 Authorization 发请求,浏览器先发 OPTIONS。资源服务 CORS 配置漏了 allowedHeaders("Authorization", "Content-Type"),或者 allowCredentials(true)allowedOriginPatterns 配了 *(规范不允许),直接 403。排查时先看 Network 里的 OPTIONS 响应头,别光盯着 POST 请求。
  3. Token 时区过期误判 :JWT 的 exp 是 UTC 秒级时间戳。有些前端用旧版 js-jwt 库按本地时区解析,国内开发者经常遇到"明明没过期但库说 expired"的情况。统一用标准库(如 josejwt-decode)处理,或者后端签发时多给 30 秒缓冲期。
  4. 网关透传 Header 丢失 :Spring Cloud Gateway 或 Zuul 默认过滤了一些敏感头。网关配置里必须显式放行 sensitive-headers: ""RemoveHopByHopHeadersFilter 调整,不然资源服务永远拿不到 Bearer Token,排查日志里看着 Authentication is null 干着急。

9. 结语

企业级 SSO 不是配几个 Bean 就能上线的玩具工程。协议栈只是底座,真正的难点在于:怎么在不侵入业务代码的前提下透传租户上下文?怎么在 JWT 的无状态优势和安全吊销的管控需求之间找平衡?怎么把风控策略前置而不是等被攻击了再补救?

这套方案我们在线上跑过几个千万级用户的 SaaS 项目,稳定性和扩展性都经得起考验。未来身份认证肯定会往 Passkey(FIDO2)、零信任自适应评估和去中心化身份方向走,但底层依然是"认证标准化、授权最小化、状态可观测"这几条铁律。先把基础打牢,后续演进才不会推倒重来。

相关推荐
马优晨17 分钟前
Spring Servlet 容器是什么、干什么用?
spring·servlet·内嵌servlet容器·java的内嵌servlet·spring内嵌servlet
咖啡八杯21 分钟前
PageHelper 分页封装:TableDataInfo 与 startPage() 的工作原理
java·spring boot·分页·若依·开源框架·pagehelper
.Hypocritical.27 分钟前
【SpringBoot】配置文件加载位置与优先级详解
java·spring boot·后端
码事漫谈10 小时前
勿删
后端
IT_陈寒13 小时前
用了Proxy才发现以前的JavaScript白写了
前端·人工智能·后端
唐青枫14 小时前
别把 ArrayList 当成会自动管理内存的 List:Zig 动态数组从入门到实战
后端
鸿蒙开发14 小时前
我为 HarmonyOS 做了一个统一大模型 SDK:@hmkit/ai 正式开源
后端
猪是念来过倒14 小时前
Semaphore 与 RateLimiter:并发控制双雄详解
后端
掘金者阿豪14 小时前
异构数据同步最怕什么?不是同步慢,而是数据对不上
后端