
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 签发,含
sub、iss、aud、exp、sid。前端拿它做路由守卫和基础信息展示,别用它调业务接口。 - Access Token :JWT 格式,塞
scope、tenant_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 兜底 的混合方案。
- 跨域 Cookie 处理 :前端直连认证中心的话,Cookie 跨域基本没戏。现在标准做法是上 BFF 层,同域下发
HttpOnly; Secure; SameSite=Lax。如果非要跨域,只能SameSite=None; Secure,且全站 HTTPS 不能省。老版本 iOS/Android WebView 对None支持很烂,遇到兼容问题直接降级走 BFF 代理或 URL 一次性 Ticket。 - 会话绑定与互斥 :JWT 里带了
sid。资源服务拿到 Token 后,拿sid去 Redis 查设备指纹和最后活跃时间。单点登录互斥逻辑很简单:SETNX session:{sid} {deviceId},设个合理过期时间。新设备登录成功就把旧sid写进黑名单。 - 主动吊销 :用户登出或管理员踢人时,把
jti或sid扔进 Redisblacklist,TTL 设成 Token 剩余有效期。资源服务拦截器验签通过后,顺手GET blacklist:{jti},命中直接抛 401。这招兼顾了无状态的高性能和强管控的灵活性,实际排查时比清 Session 表好用得多。
5. 多租户与数据权限隔离
SaaS 或多组织架构下,认证中心只管"你是谁"和"属于哪个租户",细粒度权限必须下沉到业务侧。
-
JWT 别塞太满 :
tenant_id和顶层角色(如ADMIN、USER)可以放 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. 安全加固:不是堆组件,是卡点设计
安全策略得嵌在架构里,事后打补丁往往漏风。
- 防重放与 OIDC 校验 :JWT 本身不防重放。OIDC 授权请求必须带
nonce,认证中心签发 ID Token 时原样回传c_hash,前端或 BFF 拿到先校验哈希,不一致直接阻断。业务层关键操作(支付、改密)要求带X-Request-Nonce,服务端校验一次性。 - Refresh Token 轮转:生产环境建议开启 RT Rotation。每次用旧 RT 换 AT 时,服务端签发新 RT,旧 RT 立即失效。一旦 RT 被盗刷,攻击者换到的是废的,正常客户端拿新 RT 继续走。配合设备指纹校验,基本能拦下大部分盗刷。
- 异常登录拦截:别等被刷库了才加验证码。接入 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. 线上踩坑实录
协议细节和环境差异最容易让人掉坑里,记录几个高频排查点:
- 移动端 Token 刷新死循环 :App 切后台被杀,SDK 自动重试逻辑容易陷入
401 -> 刷新 -> 401的死循环。正确做法:捕获 401 先判断本地 RT 是否有效,有效才调/oauth2/token,拿到新 Token 后静默重试原请求。Token 存储必须上 Android Keystore / iOS Keychain,明文存 SharedPreferences 或明文 Keychain 等于是裸奔。 - 跨域预检失败 :前端带
Authorization发请求,浏览器先发 OPTIONS。资源服务 CORS 配置漏了allowedHeaders("Authorization", "Content-Type"),或者allowCredentials(true)时allowedOriginPatterns配了*(规范不允许),直接 403。排查时先看 Network 里的 OPTIONS 响应头,别光盯着 POST 请求。 - Token 时区过期误判 :JWT 的
exp是 UTC 秒级时间戳。有些前端用旧版 js-jwt 库按本地时区解析,国内开发者经常遇到"明明没过期但库说 expired"的情况。统一用标准库(如jose或jwt-decode)处理,或者后端签发时多给 30 秒缓冲期。 - 网关透传 Header 丢失 :Spring Cloud Gateway 或 Zuul 默认过滤了一些敏感头。网关配置里必须显式放行
sensitive-headers: ""或RemoveHopByHopHeadersFilter调整,不然资源服务永远拿不到 Bearer Token,排查日志里看着Authentication is null干着急。
9. 结语
企业级 SSO 不是配几个 Bean 就能上线的玩具工程。协议栈只是底座,真正的难点在于:怎么在不侵入业务代码的前提下透传租户上下文?怎么在 JWT 的无状态优势和安全吊销的管控需求之间找平衡?怎么把风控策略前置而不是等被攻击了再补救?
这套方案我们在线上跑过几个千万级用户的 SaaS 项目,稳定性和扩展性都经得起考验。未来身份认证肯定会往 Passkey(FIDO2)、零信任自适应评估和去中心化身份方向走,但底层依然是"认证标准化、授权最小化、状态可观测"这几条铁律。先把基础打牢,后续演进才不会推倒重来。