OAuth 2.0 与 Spring Security:从授权模型、安全机制到微服务架构实践
OAuth 2.0 真正困难的地方并不是“如何拿到一个 access_token”,而是理解用户、客户端、授权服务器和资源服务器之间的委托关系,以及授权码、Scope、PKCE、JWT、网关和业务权限分别应该解决什么问题。本文从 OAuth 2.0 的核心模型出发,串联授权码流程、安全漏洞、开放平台、微服务架构与 Spring Security 实践,并重点说明早期 Spring Security OAuth 实现与当前方案之间的版本演进。
OAuth 2.0 的核心不是 Token,而是委托
很多系统第一次引入 OAuth 2.0 时,很容易把注意力全部放在 access_token 上:
“怎么生成 Token?”
“Token 存 Redis 还是 JWT?”
“Spring Security 怎么解析 Token?”
这些当然重要,但如果只盯着 Token,很容易把 OAuth 2.0 做成一套普通的“登录后发 JWT”机制。
OAuth 2.0 更核心的思想其实是 Delegation(委托)。
假设用户拥有自己的订单、文件或者社交账号资料。某个第三方应用希望替用户读取这些数据,但用户显然不应该把自己的账号密码直接交给第三方。
OAuth 2.0 做的事情就是把:
“把账号密码交给第三方”
变成:
“由用户明确授权第三方获得一部分有限权限,再颁发一个受限制的凭据,让第三方代表用户访问资源。”
这也是为什么 OAuth 2.0 中反复出现三个关键词:
授权、代表、委托。
访问令牌只是这种委托关系的一种承载形式。
认证、授权和 OAuth 2.0 不要混为一谈
OAuth 2.0 首先是一个授权框架,而不是用户身份认证协议。
这两个概念很容易混淆。
认证(Authentication)回答的是:
你是谁?
授权(Authorization)回答的是:
你能够做什么?
OAuth 2.0 的典型问题则更具体:
某个 Client 是否被允许代表某个用户,以某个权限范围访问某个 Resource Server?
在授权过程中,Authorization Server 通常确实需要让用户登录,因为它必须知道当前授权的人是谁。但“OAuth 流程里面发生了登录”并不意味着 OAuth 2.0 本身就是用户登录协议。
如果系统真正需要完成“第三方登录”“统一身份认证”“SSO 登录身份传递”,通常应在 OAuth 2.0 基础上使用 OpenID Connect(OIDC)。OIDC 引入的 ID Token 才用于向 Client 表达最终用户身份。
可以把两者理解为:
1 | |
早期 OAuth 2.0 实践中一个非常常见的误区,就是把 access_token 当成“登录凭证”使用。这会让授权模型、身份模型和会话模型全部混在一起。
OAuth 2.0 中的四个核心角色
理解 OAuth 2.0,先不要急着看接口参数,而是先把四个角色分清。
| 角色 | 含义 | 典型例子 |
|---|---|---|
| Resource Owner | 资源所有者 | 用户、商家 |
| Client | 希望访问资源的应用 | Web 应用、移动 App、第三方 SaaS |
| Authorization Server | 认证用户并颁发授权凭据 | 企业 IDP、统一授权中心 |
| Resource Server | 真正保存并提供受保护资源 | 订单服务、用户服务、商品服务 |
例如,一个商家使用第三方打单软件读取电商平台订单:
1 | |
其中:
1 | |
开放平台往往还会提供 Developer Center,用来维护:
1 | |
这些信息解决的是“Client 是谁”,而不是“最终用户是谁”。开放平台通常再通过 API Gateway 统一接收第三方 API 请求,将通用的 Token 校验、客户端校验、Scope 校验集中处理。
OAuth 2.0 中到底有哪些凭据
OAuth 流程里经常同时出现 code、access_token、refresh_token、state、scope,如果没有先把职责分开,很快就会乱成一锅 Token 粥。
| 参数或凭据 | 主要作用 |
|---|---|
client_id |
标识 OAuth Client |
client_secret |
Confidential Client 的客户端凭据 |
redirect_uri |
Authorization Server 完成授权后允许返回的位置 |
scope |
Client 请求或最终获得的权限范围 |
state |
将授权响应与发起授权的浏览器事务关联起来,可用于 CSRF 防护 |
code |
短生命周期、一次性的授权码 |
code_verifier |
PKCE 中 Client 本地保存的随机秘密值 |
code_challenge |
由 code_verifier 派生并发送给 Authorization Server 的值 |
access_token |
调用 Resource Server 的凭据 |
refresh_token |
在允许的情况下换取新的 Access Token |
一个非常重要的区别是:
Authorization Code 不是 Access Token。
授权码只是 Client 用于后端换 Token 的一次性中间凭据。
为什么授权码流程非要“多绕一圈”
刚接触 OAuth 2.0 时,授权码流程最让人困惑的问题通常是:
Authorization Server 为什么不直接把 Access Token 返回给 Client?为什么还要先返回一个 Code?
原因在于 OAuth 流程同时存在两条通信通道。
一条是浏览器参与的 Front Channel:
1 | |
另一条是服务端到服务端的 Back Channel:
1 | |
如果直接通过浏览器重定向携带长期有效或者高价值的 Access Token,它更容易暴露在 URL、浏览器历史、代理日志、Referer 或其他前端环境中。
授权码流程把这两个阶段拆开:
sequenceDiagram
autonumber
actor U as 用户
participant B as 浏览器
participant C as Client
participant AS as Authorization Server
participant RS as Resource Server
U->>C: 访问需要授权的功能
C->>C: 生成 state、code_verifier
C->>B: 重定向到授权端点
B->>AS: response_type=code<br/>client_id<br/>redirect_uri<br/>scope<br/>state<br/>code_challenge
AS->>U: 登录并确认授权
U->>AS: 同意授权
AS-->>B: redirect_uri?code=...&state=...
B->>C: 回调
C->>C: 校验 state
C->>AS: code + code_verifier + client authentication
AS->>AS: 校验 code、PKCE、Client、redirect_uri
AS-->>C: access_token / refresh_token
C->>RS: Authorization: Bearer access_token
RS-->>C: Protected Resource
浏览器看到的是短生命周期的 code,真正的 Access Token 则通过后端 Token Endpoint 获取。
这就是授权码看似“绕了一圈”,实际却把不同安全级别的通信分开处理的原因。
今天的安全最佳实践又在授权码流程上增加了 PKCE。RFC 9700 要求 Public Client 使用 PKCE,也建议 Confidential Client 使用,并明确说明这种建议已经不限于 Native App,而适用于包括 Web 应用在内的各种 OAuth Client;S256 是当前应优先使用的 Challenge 方法。
PKCE 到底解决了什么问题
PKCE(Proof Key for Code Exchange)可以简单理解为:
不仅要求 Client 拿到 Authorization Code,还要求它证明“这个 Code 对应的授权事务确实是自己发起的”。
Client 首先生成随机值:
1 | |
然后计算:
1 | |
发起授权时只发送:
1 | |
真正交换 Token 时,再发送:
1 | |
Authorization Server 重新计算 Challenge:
1 | |
只有与最初保存的 code_challenge 一致,才允许兑换 Token。
因此,即使攻击者截获了:
1 | |
只要没有对应的:
1 | |
也无法正常兑换 Access Token。
这也是现代 OAuth 安全体系与很多十年前教程最大的区别之一。
state 和 PKCE 是什么关系
早期 OAuth 教程通常会直接记成:
防 CSRF 就使用
state。
这个结论方向没错,但今天可以理解得更精确一些。
state 的本质是:
将 Authorization Response 与 Client 发起授权时的浏览器 Session/Transaction 绑定起来。
Client 发起授权时生成随机值:
1 | |
同时在当前用户会话中保存:
1 | |
回调时必须比较:
1 | |
不一致就终止流程。
当前 OAuth 安全最佳实践进一步指出:如果 Client 已经确认 Authorization Server 正确支持并强制执行 PKCE,那么 PKCE 也可以承担 OAuth CSRF 防护;OIDC 中 nonce 同样参与相关事务绑定。不过,如果无法确认 PKCE 支持,则仍然需要正确使用 state 或 OIDC nonce。
所以工程上不要把它们机械理解成“二选一”。
更好的思路是:
1 | |
OAuth 授权模式:历史设计与今天的选择
RFC 6749 时代常见的四种 Grant 在很多 Spring Security OAuth 教程里都会出现:
| Grant | 典型场景 | 今天如何看待 |
|---|---|---|
| Authorization Code | 有最终用户参与的 Web、SPA、Native App | 主流方案,配合 PKCE |
| Client Credentials | 服务到服务、机器身份 | 继续适用 |
| Resource Owner Password Credentials | Client 直接收集用户密码 | 不应再用于新系统 |
| Implicit | 浏览器直接获得 Access Token | 不建议使用 |
很多 2018~2020 年的 Spring Security OAuth 示例会演示 Password Grant,甚至会把第一方 App 作为其典型应用场景。这些内容对于理解 OAuth 历史非常有价值,但已经不能直接作为今天的新系统设计依据。
RFC 9700 已明确规定 Resource Owner Password Credentials Grant MUST NOT 使用,因为它要求用户把凭据交给 Client,扩大了密码泄漏面,并且很难兼容 MFA、WebAuthn 等现代认证方式。Implicit Grant 同样因为 Access Token 暴露于授权响应、容易泄漏和重放而被明确建议避免。
所以今天可以粗略记成:
1 | |
Client Credentials:微服务之间最容易被误解的一种模式
假设:
1 | |
这里不是“用户张三授权订单服务访问库存服务”,而是:
Order Service 以自己的机器身份访问 Inventory Service。
这种场景通常对应 Client Credentials Grant。
sequenceDiagram
participant O as Order Service
participant AS as Authorization Server
participant I as Inventory Service
O->>AS: client_id + client authentication<br/>grant_type=client_credentials
AS-->>O: access_token
O->>I: Bearer access_token
I->>I: 校验 token / scope / audience
I-->>O: inventory data
这个 Token 通常表达的是:
1 | |
而不是:
1 | |
这就是“用户委托”和“Workload Identity”之间的重要区别。
如果一个下游接口既要知道:
1 | |
又要知道:
1 | |
就不应该简单把 Client Credentials Token 冒充成用户 Token,而需要明确设计 Token Exchange、Delegation 或用户上下文传递方式。
Scope 到底是什么
scope 表示 Client 请求并最终获得的授权范围。
例如:
1 | |
Client 注册时可能允许:
1 | |
它发起授权时可能只申请:
1 | |
而用户最终同意的权限也可能进一步缩小。
因此最终 Token 中的 Scope 可以理解为多个约束共同作用的结果:
1 | |
这也是开放平台返回的 scope 为什么可能比应用注册时声明的权限更小。
Scope 和 Spring Security Authority 有什么区别
两者不是同一个层级的概念。
scope 是 OAuth 协议里的授权范围。
GrantedAuthority 则是 Spring Security 内部进行权限判断的抽象。
当前 Spring Security Resource Server 默认会把 Token 中的 Scope 转换为带 SCOPE_ 前缀的 Authority。例如:
1 | |
进入 Spring Security 后对应:
1 | |
因此可以直接进行方法鉴权:
1 | |
Spring Security 当前的 JWT Resource Server 会校验签名以及 exp、nbf、iss 等声明,并完成 Scope 到 Authority 的转换;还可以额外配置 aud 校验。
JWT 不是 OAuth 2.0 的组成部分
另一个极其常见的误区是:
1 | |
其实不是。
OAuth 2.0 规定的是:
如何授权、如何颁发 Token、Client 如何使用 Token。
但 Access Token 的具体格式可以有很多种。
最常见的是两类。
Opaque Token
形态可能只是:
1 | |
Token 本身没有业务意义。
Resource Server 或 Gateway 需要通过:
1 | |
才能知道它代表什么。
JWT Access Token
形态是:
1 | |
Payload 中可以携带:
1 | |
Resource Server 可以利用 Authorization Server 公钥在本地完成验签和大部分校验。
早期 Spring Security OAuth 示例里常使用:
1 | |
把用户信息直接放进 JWT,然后让 Resource Server 使用公钥本地验证。这种思路本身一直延续到了今天,但具体 API 已经发生了很大变化。
JWT 是签名,不是默认加密
JWT 特别容易产生一种错觉:
Token 看起来是一长串乱码,所以里面的数据应该是加密的。
实际上普通 JWS JWT 的 Payload 只是 Base64URL 编码。
拿到 Token 的人完全可以读取其中的内容。
签名解决的是:
1 | |
而不是:
1 | |
因此不应该把下面这些敏感数据随便塞进 Access Token:
1 | |
旧版示例中把整个 userDetails 注入 JWT 很适合演示 TokenEnhancer 的能力,但生产环境更合理的做法通常是只携带稳定、必要的 Claims,例如:
1 | |
JWT 和 Opaque Token 应该怎么选
不存在“JWT 一定比普通 Token 高级”这样的结论。
| 维度 | JWT | Opaque Token |
|---|---|---|
| Resource Server 验证 | 本地验签 | 通常需要 Introspection/共享存储 |
| 网络调用 | 少 | 相对多 |
| 水平扩展 | 很方便 | 依赖中心状态 |
| 用户信息 | 可以自包含 | Token 本身通常无意义 |
| 权限变化实时性 | 较弱 | 较强 |
| 即时吊销 | 相对困难 | 容易 |
| 泄漏后的信息暴露 | Payload 可见 | Token 本身无业务信息 |
| 适合 | 高频微服务调用 | 强撤销、强集中管控 |
当前 Spring Security Authorization Server 同时支持 Self-contained JWT 和 Reference/Opaque Token,并提供 Introspection、Revocation、JWK 等相关端点,所以 Token 形式本身应该由业务的安全模型和运维需求决定,而不是被框架绑死。
一种很实用的微服务混合 Token 架构
在微服务场景里,可以采用这样一种模式:
1 | |
它的基本逻辑是:
flowchart LR
U[Browser / App / Third-party Client]
N[Nginx / Ingress]
W[Web / BFF]
G[API Gateway]
IDP[Authorization Server / IDP]
D[Developer Center]
O[Order Service]
P[Product Service]
PAY[Payment Service]
U --> N
N --> W
N --> G
W --> G
G -->|Token Introspection| IDP
G -->|读取 Client 信息| D
G -->|内部 JWT| O
G -->|内部 JWT| P
G -->|内部 JWT| PAY
对公网而言,客户端只拿到一个无业务语义的 Opaque Token。
Gateway 收到请求后:
1 | |
这种“外部 Token + 内部 JWT”的设计并不是 OAuth 标准强制要求,而是一种企业架构取舍。它的价值在于兼顾:
1 | |
相关微服务参考架构还会进一步划分:
1 | |
BFF 负责面向不同前端聚合多个领域 API,Domain Service 则承载领域业务逻辑和自己的数据。
需要强调的是,诸如“领域服务绝不能调用其他领域服务”属于具体微服务架构约束,不是 OAuth 2.0 的要求。实际系统可以根据同步调用、事件驱动、数据冗余和领域边界选择合适方案。
API Gateway 能做什么,不能做什么
开放平台和微服务实践中,经常会把 Token 校验集中到 Gateway。
这是合理的,因为否则:
1 | |
既重复,又难以统一升级。
Gateway 适合负责:
1 | |
但这并不意味着:
经过 Gateway 后,业务服务就再也不需要做任何授权。
这是微服务安全设计中极其危险的误区。
Gateway 能判断:
1 | |
但通常只有 Order Service 才知道:
1 | |
因此应该形成两层授权。
1 | |
开放平台资料里“网关负责 Token 校验,但订单服务仍要判断订单是否属于当前商家”正是这个边界。
最危险的漏洞之一:水平越权
假设:
1 | |
商家 A 有一个合法的:
1 | |
Token 里身份也是:
1 | |
然后它调用:
1 | |
如果系统只判断:
1 | |
然后执行:
1 | |
商家 A 就可能读到商家 B 的订单。
这就是典型的水平越权,也常被称为对象级授权缺失。
正确查询应该把当前安全上下文带进资源归属判断:
1 | |
在 Spring Resource Server 中,可以写成类似:
1 | |
这里的关键不是 Java 代码,而是这个安全模型:
1 | |
OAuth 只能告诉 Resource Server“这个调用者拥有哪些授权”,真正的对象归属关系必须由业务服务处理。
OAuth 2.0 的 CSRF 到底是怎么发生的
早期 OAuth 资料中的 CSRF 示例很容易把人绕晕,因为它并不是简单的:
攻击者偷了受害者的 Code。
更准确的理解是 Authorization Response Injection / Login CSRF:
攻击者把“属于攻击者授权事务的 Authorization Code”注入到受害者的 Client Session 中。
可以把过程重画成:
sequenceDiagram
actor A as 攻击者
actor V as 受害者
participant C as 合法 OAuth Client
participant AS as Authorization Server
A->>C: 发起自己的 OAuth 授权
C->>AS: Authorization Request
AS-->>A: code_attacker
Note over A: 攻击者截获自己的授权响应<br/>暂不完成回调
A->>V: 诱导访问 Client Callback<br/>?code=code_attacker
V->>C: 浏览器携带受害者自己的 Client Session
C->>AS: 使用 code_attacker 换 Token
AS-->>C: 返回攻击者授权上下文对应的 Token
Note over V,C: 受害者浏览器会话<br/>被错误绑定到攻击者授权上下文
这里的危险在于:
1 | |
被错误拼接到了一起。
例如“绑定微信账号”的功能中,就可能导致:
1 | |
攻击者以后通过自己的微信,就可能登录到受害者账号。
所以 CSRF 防护不是为了隐藏 Code,而是为了确认:
当前回调是否就是当前浏览器会话之前发起的那笔授权事务的响应。
传统方案依赖正确生成并校验 state,现代系统还应结合 PKCE;RFC 9700 对这些防护关系进行了更精确的约束。
授权码失窃为什么危险
授权码是临时凭据,但“临时”不等于“随便泄漏也没问题”。
假设攻击者截获:
1 | |
如果 Authorization Server 又存在以下设计缺陷:
1 | |
攻击者就可能拿这个 Code 去 Token Endpoint 换取 Access Token。
早期资料提出的几个基础防护至今仍然重要:
1 | |
现代安全模型则进一步通过 PKCE 将:
1 | |
和:
1 | |
绑定在一起,即使 Code 被截获,缺少 code_verifier 也无法兑换。
Redirect URI 为什么必须严格校验
假设合法回调地址是:
1 | |
如果 Authorization Server 只做非常宽松的前缀判断:
1 | |
那么类似:
1 | |
或者利用路径解析差异构造的地址,就可能成为 Authorization Code 泄漏的跳板。
所以生产系统不要写这种校验:
1 | |
也不要随意:
1 | |
当前 OAuth 安全最佳实践要求采用严格的 Redirect URI 比较,Native App 的 localhost 动态端口是其中一个特殊场景。
XSS 和 OAuth 2.0 没有“绝缘层”
OAuth 2.0 可以防授权问题,但它不能让 Web 系统自动免疫 XSS。
例如 Resource Server 返回:
1 | |
如果前端把错误信息直接作为 HTML 渲染,就可能造成脚本执行。
同理:
1 | |
只要它们最终进入 HTML,都应该按照输出上下文正确编码。
因此 OAuth 系统仍然需要普通 Web 安全措施:
1 | |
OAuth 2.0 不会替代 OWASP 那一整套 Web 安全基础设施。
Bearer Token 泄漏后为什么那么麻烦
大部分 OAuth Access Token 都是 Bearer Token。
Bearer 的含义基本可以理解成:
谁拿到它,谁就能使用它。
因此:
1 | |
这也是为什么 HTTPS 在 OAuth 系统里不是“优化项”,而是安全前提。
同时还要避免把 Access Token 放进:
1 | |
RFC 9700 还建议在合适的高安全场景使用 Sender-Constrained Token,例如 mTLS 或 DPoP,让“拿到 Token”本身不足以完成重放攻击。
Refresh Token 比 Access Token 更值得保护
Access Token 通常生命周期较短:
1 | |
Refresh Token 却可能持续:
1 | |
如果攻击者获得 Refresh Token,就可能不断换取新的 Access Token。
因此不能因为 Refresh Token“不直接调用 API”就降低安全等级。
当前最佳实践要求 Public Client 的 Refresh Token 使用 Sender Constraint 或 Refresh Token Rotation。
Rotation 的基本思想是:
1 | |
如果之后再次看到:
1 | |
就意味着可能发生了 Token Replay,可以直接使整个 Token Family 失效。
用户注销、禁用、改密之后 Token 怎么办
这也是 JWT 系统经常被忽略的问题。
假设用户:
1 | |
但它之前签发的 Access Token 仍然有效 30 分钟,那么 Resource Server 如果只做 JWT 本地验签,在这 30 分钟内仍可能继续接受请求。
开放平台实践中常见的方式是通过用户中心产生安全事件:
flowchart LR
U[User Center]
MQ[Message Queue]
AS[Authorization Server]
TS[(Token / Authorization Store)]
U -->|用户禁用 / 撤销授权 / 高风险凭据变化| MQ
MQ --> AS
AS -->|Revoke / Cleanup| TS
对于 Opaque Token,由于通常需要中心查询,立即撤销比较自然。
对于纯 JWT,则需要在:
1 | |
之间做取舍。
所以“JWT 无状态”不是免费午餐。越追求完全本地验证,就越要接受授权状态传播存在延迟。开放平台使用消息事件清理关联 Token 的设计,非常值得企业内部统一身份系统借鉴。
SSO 为什么经常和 OAuth 2.0 混在一起
典型 Web SSO 流程如下:
1 | |
真正让用户“不用再次输入密码”的关键,不是 App A 把 Access Token 给 App B,而是:
1 | |
App B 仍然会重新发起自己的 Authorization/OIDC Flow,只是到 IdP 时发现:
1 | |
于是授权流程可以快速完成。
因此正确的 SSO 边界应该是:
1 | |
而不是多个业务系统互相复制 Cookie 或共享 Access Token。
如果需求是用户登录身份,应进一步使用 OIDC,而不是只依靠 OAuth Access Token。
开放平台中的完整 OAuth 2.0 架构
把前面的角色拼起来,一个成熟开放平台往往会形成:
flowchart TB
DEV[第三方开发者]
APP[第三方应用 / ISV]
USER[用户]
CENTER[Developer Center]
AUTH[Authorization Server]
GW[API Gateway]
ORDER[Order Service]
PRODUCT[Product Service]
USER_SVC[User Service]
DEV -->|注册应用| CENTER
CENTER -->|client_id / client policy| AUTH
CENTER --> GW
USER --> APP
APP -->|Authorization Request| AUTH
AUTH -->|Authorization Code| APP
APP -->|Code Exchange| AUTH
AUTH -->|Access Token| APP
APP -->|Bearer Token| GW
GW -->|校验 Token / Client / Scope| AUTH
GW --> ORDER
GW --> PRODUCT
GW --> USER_SVC
这里可以把开放平台的三个核心能力分开:
1 | |
而内部订单、商品、用户等服务,则是真正的 Protected Resources。微信、支付宝、美团等开放平台虽然接口和参数命名不同,但相关资料中的整体授权流程本质上都可以映射回 Authorization Code 模型。
微服务内部到底应该传 Access Token 还是用户信息
这没有唯一答案。
方案一:原 Token 一路透传
1 | |
优点是:
1 | |
缺点是:
1 | |
方案二:Gateway 解析后直接传用户头
1 | |
实现最简单,但一定要满足一个关键前提:
下游服务只能被可信 Gateway 调用,并且 Gateway 必须删除外部客户端伪造的同名 Header,再重新生成。
否则攻击者可以直接:
1 | |
那就不是 OAuth 了,而是“相信客户端自己填身份证”。
方案三:Gateway 转成内部 JWT
1 | |
这是前面介绍的混合模式。
微服务参考架构中采用的就是类似思路:Gateway 验证 OAuth Access Token 后,把用户和权限信息转换成内部 JWT,再交给 BFF 和领域服务。
旧版 Spring Security OAuth 到底是怎么工作的
很多经典文章里的 Spring OAuth 代码大致长这样:
1 | |
Resource Server 则是:
1 | |
用户安全配置继续继承:
1 | |
Token 相关使用:
1 | |
客户端则可能出现:
1 | |
再配上一组数据库表:
1 | |
这些代码在当年的 Spring Boot 2.x + Spring Cloud OAuth2 环境里非常典型,也很好地演示了:
1 | |
整个 OAuth 体系。
但是这些 API 不能直接当成今天的新项目模板继续复制。
Spring Security OAuth 已经进入历史阶段
原来的 Spring Security OAuth 项目已经在 2022 年结束生命周期。Spring 官方把 OAuth Client 和 Resource Server 能力迁入 Spring Security,并由 Spring Authorization Server 承担 Authorization Server 能力。随后 Spring Authorization Server 又从独立项目进一步整合进入 Spring Security 7;独立的 1.5.x 成为最后一代分支,后续新特性进入 Spring Security。
因此阅读老代码时,应该做一层“概念翻译”。
| 旧实现 | 当前应该理解成 |
|---|---|
spring-cloud-starter-oauth2 |
Spring Security OAuth2 Client / Resource Server / Authorization Server |
@EnableAuthorizationServer |
Authorization Server DSL + SecurityFilterChain |
AuthorizationServerConfigurerAdapter |
Bean + Component-based Configuration |
@EnableResourceServer |
oauth2ResourceServer() |
ResourceServerConfigurerAdapter |
SecurityFilterChain |
WebSecurityConfigurerAdapter |
SecurityFilterChain |
JwtAccessTokenConverter |
JwtEncoder / JwtDecoder / JWK |
TokenEnhancer |
OAuth2TokenCustomizer |
OAuth2RestTemplate |
RestClient OAuth2 Integration |
@EnableOAuth2Sso |
OAuth2 Client + oauth2Login() |
oauth_client_details |
RegisteredClientRepository 等当前模型 |
WebSecurityConfigurerAdapter 从 Spring Security 5.7 开始就已经被弃用,推荐改为基于 SecurityFilterChain 的组件化配置。
理解这个映射以后,老教程依然很有价值:
OAuth 原理没有因为 Spring API 改名而消失,只是框架实现方式变了。
当前 Spring Security 的 Authorization Server
截至当前 Spring Security 官方稳定文档,Authorization Server 已经直接纳入 Spring Security 体系,支持 Authorization Code、Client Credentials、Refresh Token、Device Code、Token Exchange,以及 JWT/Opaque Token、OIDC、Introspection、Revocation、JWK 等能力。
Spring Boot 项目可以直接引入:
1 | |
官方当前入门配置使用 SecurityFilterChain 和 Authorization Server DSL;Authorization Server 当前要求 Java 17 或更高版本。
开发环境可以先注册一个简单 Client:
1 | |
这里的:
1 | |
只适合本地演示。
生产环境的 Client Secret 应该通过可靠的密码编码、Secret Manager、Vault、Kubernetes Secret 等机制管理,绝不能提交到 Git 仓库,更不能打包到浏览器 JavaScript 或移动 App 中。
对于真正的企业系统,Client Registration、Authorization、Consent、JWK 和密钥轮换通常也需要持久化和运维体系,而不是照搬内存 Demo。
当前 Spring Resource Server 的最小配置
资源服务器通常只需要:
1 | |
配置 Authorization Server 的 Issuer:
1 | |
安全配置不再继承 WebSecurityConfigurerAdapter:
1 | |
当前 Resource Server 可以通过 Authorization Server Metadata/JWK 获取验证密钥,对 JWT 签名进行校验,并校验包括 iss、exp、nbf 在内的声明;Scope 默认转换为 SCOPE_ Authority。
这和旧时代:
1 | |
本质上解决的是同一件事,只是今天的 JWK Discovery、Key Rotation 和声明验证已经更加标准化。
TokenEnhancer 在今天怎么写
旧代码可能会这样:
1 | |
当前 Authorization Server 提供 OAuth2TokenCustomizer<JwtEncodingContext>。
例如只放必要的租户标识:
1 | |
当前官方模型允许通过 OAuth2TokenCustomizer 分别定制 JWT 或 Opaque Token Claims。
这里仍然遵守一个原则:
Token 放“授权判断必须的数据”,而不是把用户数据库整行搬进 JWT。
OAuth2RestTemplate 今天怎么替代
旧版微服务客户端常见:
1 | |
它会自动:
1 | |
当前 Spring Security 已经为 RestClient 提供 OAuth2 集成,可以利用 OAuth2ClientHttpRequestInterceptor 与 OAuth2AuthorizedClientManager 自动处理 Authorization Code 或 Client Credentials 等授权客户端流程。
典型配置:
1 | |
服务到服务的 Client Registration 可以配置成:
1 | |
这正是 Client Credentials 在微服务里的典型落地方式。
Spring Security 中如何区分 Authentication 和 Authorization
有了 OAuth Resource Server 以后,一个请求大致会经历:
1 | |
可以把安全检查分成三个层次。
第一层是 Token Authentication:
1 | |
第二层是功能 Authorization:
1 | |
第三层才是业务对象 Authorization:
1 | |
很多系统只做前两层,真正的数据越权就藏在第三层。
一套更合理的企业微服务鉴权边界
综合 OAuth、开放平台和微服务实践,可以形成这样的责任模型:
| 组件 | 主要安全责任 |
|---|---|
| Authorization Server / IDP | 用户认证、Client 认证、授权、Token 生命周期 |
| API Gateway | Token 基础校验、Scope/API 权限、入口安全策略 |
| Resource Server | Token 二次防御、业务权限、对象归属、租户隔离 |
| BFF | 面向前端的聚合授权、上下文传播 |
| Client | 安全保存凭据、PKCE/state、正确处理 Token |
| User Service | 用户状态与安全事件 |
| Developer Center | Client 注册、Secret、Redirect URI、Scope 策略 |
这里要避免两个极端。
一个极端是:
1 | |
那么 Gateway 会慢慢长成一个“全公司业务权限中心”,最后不得不理解订单、支付、库存和财务全部领域知识。
另一个极端是:
1 | |
又会形成大量重复代码和安全策略漂移。
更合理的方式是:
1 | |
client_secret 永远不要放在不能保密的 Client 中
OAuth Client 又可以分成两种重要类型:
1 | |
服务器端 Web 应用通常有能力保护 Secret,可以使用:
1 | |
而:
1 | |
最终代码都运行在用户设备上。
无论怎么混淆、加壳:
1 | |
都不能真正称为秘密。
因此 Public Client 的安全不能建立在:
“攻击者拿不到我的 client_secret。”
而要建立在:
1 | |
这些机制上。
API Gateway 不应该盲目信任上游 Header
假设 Gateway 验证完 Token 后向下游发送:
1 | |
那么一定要保证:
1 | |
而且 Gateway 收到外部请求时,应先删除:
1 | |
再根据受信任 Token 重新写入。
否则攻击者可以自己构造:
1 | |
直接把多租户系统变成“自助切租户”。
内部网络也不能天然等同于可信网络。OAuth 能解决身份和授权的一部分问题,但服务网格、网络策略、mTLS、Gateway ACL 仍然需要根据威胁模型设计。
常见的 OAuth 2.0 安全基线
真正上线一套 OAuth 2.0 系统时,可以至少检查下面这些内容:
- 所有 Authorization、Token、Resource Endpoint 都使用 HTTPS,不允许 Token 通过 URL Query、日志或异常信息泄漏。
- 有最终用户参与的现代应用优先采用 Authorization Code + PKCE,Public Client 强制 PKCE,并使用
S256。 -
redirect_uri使用严格匹配,禁止危险的通配符和 Open Redirector。 - Authorization Code 生命周期足够短、只能使用一次,并正确绑定 Client、Redirect URI 和授权事务。
- 正确实现
state/ PKCE / OIDCnonce,不能使用固定值,更不能只传不校验。 - 新系统不再使用 Resource Owner Password Credentials Grant,也避免 Implicit Grant。
- Access Token 权限遵循最小权限原则,并校验
iss、aud、有效期和必要 Scope。 - Public Client 的 Refresh Token 使用 Rotation 或 Sender Constraint,并具备 Replay Detection。
- JWT Payload 不存放密码、敏感隐私或完整用户对象,理解“签名不等于加密”。
- Client Secret 只存在能够真正保密的服务端环境,不进入 SPA、Native App、Git 仓库或客户端包。
- Gateway 负责通用 Token/Scope 校验,但 Resource Server 仍执行订单归属、租户隔离等对象级授权。
- Gateway 向下游透传身份 Header 时必须覆盖外部同名 Header,并阻止绕过 Gateway 的访问路径。
- 用户冻结、注销、撤销授权等安全事件能够触发 Token/Session 撤销策略。
- 日志、Tracing、APM、监控和审计系统默认对
Authorization、Cookie、Refresh Token 等敏感字段脱敏。 - 如果需求本质是“用户登录和 SSO”,使用 OIDC,不把 OAuth Access Token 当作用户身份协议。
一次 OAuth 请求应该如何排查
OAuth 出问题时,不要一上来就 DEBUG Spring Security 几千行日志。
更有效的方法是按协议阶段定位。
如果问题发生在:
1 | |
重点检查:
1 | |
如果问题发生在:
1 | |
重点检查:
1 | |
如果 Token 已经拿到,但:
1 | |
重点检查:
1 | |
如果返回:
1 | |
则通常已经通过身份校验,更应该看:
1 | |
如果请求成功却拿到了别人的数据,就不要再盯着 OAuth Token 了:
这时最应该排查的是对象级授权和数据归属条件。
怎么看待那些经典 Spring Security OAuth 教程
经典教程并没有“失效到完全不能看”。
恰恰相反,它们非常适合理解 OAuth 的系统结构:
1 | |
它们还很好地说明了:
1 | |
真正需要避免的是:
把 2018~2020 年的 Spring API、Grant 选择和安全假设原样复制进 2026 年的新系统。
框架会变,协议最佳实践也会演进。
但这些核心边界不会轻易变化:
1 | |
只要这几个问题能够回答清楚,换成 Spring Security、Keycloak、Auth0、企业自建 IDP,整体思路都不会乱。
总结
OAuth 2.0 最值得掌握的并不是几个 Endpoint,而是一套关于 委托、边界和凭据生命周期 的思维模型。
Authorization Code 把浏览器 Front Channel 与服务端 Back Channel 分开;PKCE 将授权请求与 Token 兑换进一步绑定;state 和 OIDC nonce 用于保护授权事务;Scope 描述授权范围,而 Resource Server 仍然必须完成真正的数据归属和领域权限判断。
JWT 只是 Token 的一种表达方式。它用本地验签换来了低耦合和高扩展性,同时也带来了撤销延迟、Claims 暴露和权限过期等代价;Opaque Token 则更适合集中控制和即时撤销。大型开放平台和微服务系统还可以通过 Gateway 在两种 Token 或安全上下文之间进行转换。
Spring 生态本身也经历了明显的技术演进:早期 @EnableAuthorizationServer、@EnableResourceServer、OAuth2RestTemplate、WebSecurityConfigurerAdapter 已经属于历史实现;当前应该从 SecurityFilterChain、OAuth2 Client、Resource Server 和 Spring Security Authorization Server 的组件化模型重新理解这套体系。
最终,一套真正可靠的 OAuth 2.0 架构应该让每个组件只回答自己最擅长的问题:
1 | |
把这四个问题分清楚,OAuth 2.0 就不再只是“一套发 Token 的机制”,而会真正成为整个开放平台和微服务安全体系中的授权基础。