自研鉴权撑不住了
公司原来的登录是各个系统各写一套 Session,互不相通。用户在我们三个产品里要登三次,运营吐槽了半年。我们决定用 Spring Authorization Server(基于 Spring Security 6)搭一个统一认证,做 OAuth2 授权中心,让所有子系统一处登录、处处通行。选它而非自己撸,是因为 OAuth2 那套授权码、令牌吊销的细节太多,自己写容易出安全漏洞。
核心配置
用 @Bean 声明授权Ubuntu 服务器和客户端:
@Bean
public RegisteredClientRepository clients() {
RegisteredClient c = RegisteredClient.withId("web")
.clientId("web-client")
.clientSecret("{bcrypt}" + pw)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("https://app/callback")
.scope("read")
.build();
return new InMemoryRegisteredClientRepository(c);
}
这里密码用 {bcrypt} 前缀,Spring Security 会自动用 BCrypt 校验。生产我们换成 JDBC 存储 RegisteredClient,支持动态注册,不用改代码加客户端。
四种授权模式
我们实际用到的:
- 授权码(Authorization Code):Web 前端主力,带 PKCE 防拦截,最适合有后端的 Web 应用;
- 客户端凭证(Client Credentials):服务间机对机调用,没有用户概念,直接拿 client 级别的 token;
- 密码模式:遗留系统过渡用,新系统不推荐,因为它要把用户名密码给客户端;
- 刷新令牌:延长登录态,access token 短命(30 分钟),refresh token 长命(7 天)换新的。
我们用授权码 + PKCE 覆盖了所有前端,客户端凭证覆盖了内部服务调用,密码模式只在两个老系统迁移期开了三个月就关了。
JWT 还是 Opaque Token
默认发的是 Opaque Token(不透明,需 introspection 查校验)。我们改成 JWT 自包含,资源服务器本地验签即可,少一次网络往返:
@Bean
public JwtEncoder jwtEncoder() {
return new NimbusJwtEncoder(
new ImmutableSecret<>(secret.getBytes()));
}
| 维度 | JWT | Opaque |
|---|---|---|
| 校验 | 本地验签 | 查授权服务器 |
| 吊销 | 难(靠短过期) | 易(删记录即失效) |
| 适合 | 高并发读 | 强管控场景 |
我们选 JWT,因为资源服务器有 20 多个,每次 introspection 都打授权服务器,那它就成了瓶颈。代价是令牌难即时吊销,靠缩短 access token 有效期 + 黑名单兜底解决。
踩过的坑
第一版资源服务器没配 issuer-uri,验签时每次去授权服务器拉 JWK Set,授权服务器一抖资源服务器全挂。改成本地缓存 JWK 后稳了。另一个坑是时钟偏移:JWT 的 exp 校验依赖各机时钟一致,有台机器 NTP 没同步,慢了 2 分钟,导致它认为所有 token 已过期。统一 NTP 后才正常。
还有 scope 粒度问题。一开始只分了 read/write,结果一个内部后台拿到了 write 也能调用户接口。后来按资源细分 scope(user.read、order.write),在资源服务器用 @PreAuthorize("hasAuthority('user.read')") 收紧。
迁移策略
老系统不能一刀切。我们让授权服务器同时支持新旧两种会话,老系统逐步改造成用 token 调用,改完一个切一个,灰度了三周。期间用同一个用户体系打通,用户无感。
令牌吊销的实战方案
选了 JWT 就面临"怎么踢人"。我们做了两层:access token 有效期压到 15 分钟,就算被盗窗口也短;refresh token 存在授权服务器的库里,用户登出或风控触发就删记录,refresh 时直接拒。还加了一个全局黑名单(Redis 存被踢的 jti),资源服务器本地缓存这份黑名单,校验时先查黑名单再验签。黑名单量很小(只有被踢的),对性能几乎无感,却补上了 JWT 难吊销的短板。
多端登录与会话管理
一个用户可能在 Web、App、小程序三端同时登录。我们用 refresh token 绑定设备标识,登出某设备只删那台的 refresh token,不影响其他端。还做了"踢旧设备"功能:同一账号在异地新设备登录,旧设备的 refresh token 标记为失效,下次刷新就被拦。这套逻辑放在授权服务器,各端无感,比当年每个系统各管 Session 清爽太多。
性能压测数据
授权服务器本身成了流量入口,得扛住。我们压了令牌端点:单机(4C8G)授权码换 token 约 1800 QPS,P99 35ms;introspection 因为我们改 JWT 本地验签,资源服务器不再打授权服务器,授权服务器压力小了一截。但授权码流程第一步的 /oauth2/authorize 涉及登录页和跳转,QPS 只有 600 左右,这块我们加了页面级缓存和连接池才顶住大促。
一个教训:授权服务器挂了等于全公司登不上。我们给它做了多副本 + 独立数据库,并且资源服务器对 JWT 做了离线容错——授权服务器短暂不可用时,已签发的 JWT 在有效期内仍能验签(因为本地有 JWK 缓存),只是不能发新 token。这样授权服务器故障不会瞬间雪崩全站。
单点登录与跨域
统一认证的核心是 SSO:用户在 A 系统登录后,进 B 系统不再输密码。靠的是授权服务器发的 SSO session(存在授权服务器端),B 系统走授权码流程时,授权服务器发现已登录就直接发 code,不用重新认证。跨域靠 callback 回调地址白名单,我们在 RegisteredClient 里登记了每个系统的合法 redirect_uri,非白名单一律拒,防授权码被截到钓鱼站。这块配置错一个字符,要么登录不了要么有安全风险,我们专门写了校验脚本扫白名单格式。
安全审计要查的几项
授权服务器是安全命门,上线前我们过了一遍:token 签名算法必须是非对称(RS256)且私钥不落应用机;授权码必须单次有效、用完即废,防重放;PKCE 的 code_challenge 必填,防拦截;refresh token 绑定 client 和 device,防盗用。每一项都有对应配置,我们做成了安全检查表,每次发版前打勾。授权服务器出事就是全公司出事,这块不能省。
压测之外的稳定性设计
我们还做了降级:授权服务器的数据库挂了,已签发的 JWT 靠本地 JWK 缓存仍能验签,资源服务器不中断,只是不能发新 token(影响新登录,不影响已登录用户)。这个"部分降级"设计让我们在一次数据库抖动时,核心交易没受影响,只是登录入口短暂不可用。授权服务器自身用多副本 + 独立库,不和业务库混用,避免被业务流量拖垮。
令牌续期与前端处理
access token 15 分钟过期,前端不能让用户每 15 分钟重登。我们用 silent refresh:前端在 token 快过期时用 refresh token 静默换新的,用户无感。实现上前端存 refresh token 在 httpOnly cookie(防 XSS 窃取),过期前 1 分钟发 refresh 请求。后端校验 refresh token 有效就发新 access token。这套在 App 端用类似机制,只是 token 存安全存储。续期链路是登录体验的关键,我们专门压了 refresh 接口的并发,确保大批用户同时续期不挂。
迁移后的真实收益
上线统一认证三个月,用户投诉"要登好几次"归零;安全侧,弱密码、明文 session 这类老问题随着老系统下线消失;研发侧,新服务接登录从"写一套"变成"配一个 client",半天搞定。最让我满意的是"一处登出全站失效"——以前各系统 session 独立,登出 A 还在 B 登录,现在 refresh token 一删全清。这套投入在用户体验和安全上都很值,是我们今年做对的一个架构决策。
技术选型的回看
回头看,选 Spring Authorization Server 而不是继续自研或接第三方,是对的:它把 OAuth2 那套我们不想自己维护的细节封装好,又给我们留足定制空间(JWT 形态、scope、吊销)。唯一代价是早期版本文档少,有些坑得读源码,但比起自研一个授权中心踩的安全坑,这点学习成本划算得多。统一认证这种安全命门,用成熟框架比自己造轮子稳。
小结
统一认证不是接个库那么简单,授权模式选型、Token 形态直接决定安全和性能。JWT 省 RT、Opaque 易管控,按场景取舍。Spring Authorization Server 把 OAuth2 的繁琐封装得相当到位,但 JWK 缓存、时钟同步、scope 粒度这些坑,得自己踩过才记得住。建议新系统直接上,老系统按资源粒度慢慢迁移。