OAuth 2.0 是一个授权框架,允许第三方应用获得对某 web 服务上用户资源的有限访问,而无需知道用户的登录凭证。在 OAuth 之前,给第三方应用访问你数据的唯一方式是交出你的用户名和密码——一个严重的安全风险。OAuth 通过引入一个围绕带 scope、会过期的令牌构建的委托访问模型解决这个。它 2012 年作为 RFC 6749 发布,此后成为整个 web 事实上的授权标准,支撑"用 Google 登录"、API 授权、微服务间认证,以及电视和 IoT 设备的设备授权流程。理解完整协议——四种授权类型、PKCE、令牌生命周期、scope,以及与 OpenID Connect 的边界——对任何构建或保护现代 API 的工程师都至关重要。
- OAuth 2.0 是授权,不是认证——它说你能做什么,而非你是谁。在其上加 OpenID Connect(OIDC)做身份(ID token)。
- 授权码 + PKCE 是所有面向用户流程(web 和移动)的安全默认;授权码在服务端交换,把令牌挡在浏览器之外。
- 客户端凭证(Client Credentials)用于机器对机器——无用户参与;服务用 client_id + client_secret 以自身身份认证。
- 隐式授权已弃用——它在 URL 片段返回令牌,把它们暴露给浏览器历史和 JavaScript。改用授权码 + PKCE。
- 访问令牌应短命(几分钟到一小时);刷新令牌必须服务端存储并在每次使用时轮换。
- 授权码流程里务必校验 state 参数以防 CSRF 攻击。
OAuth 2.0 是授权(你能做什么),不是认证(你是谁)。它引入一个授权层:不共享凭证,而由资源所有者授予客户端一个由授权服务器签发的、带 scope、会过期的访问令牌。授权码流程是服务端应用的安全默认;客户端凭证用于机器对机器。
关键角色
| 角色 | 职责 |
|---|---|
| 资源所有者(Resource Owner) | 拥有受保护资源的终端用户,授予客户端代表自己访问它的权限。 |
| 客户端(Client) | 请求访问的应用(web 应用、移动应用、CLI 工具、后端服务)。可以是"机密的"(能保守 secret)或"公开的"(不能,如 SPA/移动)。 |
| 授权服务器(Authorization Server) | 认证资源所有者、获取同意、签发访问令牌和刷新令牌。常与资源服务器分离(如 Google 的认证服务器 vs Calendar API)。 |
| 资源服务器(Resource Server) | 承载受保护资源(如一个 REST API)。在服务数据前对每个请求校验访问令牌——通过内省它们或验证其 JWT 签名。 |
| 访问令牌(Access Token) | 代表所授权限的短命凭证。含 scope 和过期。可以是不透明字符串(需内省)或自包含 JWT(本地验证)。 |
| 刷新令牌(Refresh Token) | 用于无需用户重新认证就获取新访问令牌的长命凭证。必须服务端安全存储并在每次使用时轮换。 |
OAuth 2.0 vs. 认证——关键区别
一个常见的面试错误:把 OAuth 与认证混淆。OAuth 2.0 纯粹关于授权——授予客户端代表用户执行动作的权限。它不告诉你用户是谁。一个访问令牌说的是"这个客户端被允许读你的日历",而非"这是 Alice"。OpenID Connect(OIDC)是建在 OAuth 2.0 之上的认证层——它加一个 ID token(一个 JWT),携带 sub(主体标识符)、name、email、email_verified 等身份声明。当你点"用 Google 登录"时,你在用 OIDC(身份)叠在 OAuth(授权)之上。Google API 服务器既是 OAuth 授权服务器,也是 OIDC 身份提供者。
| 问题 | OAuth 2.0 | OpenID Connect |
|---|---|---|
| 解决什么问题? | 授权——这个应用能访问你的数据吗? | 认证——这个用户是谁? |
| 签发什么? | 访问令牌(+ 可选刷新令牌) | ID token(带身份声明的 JWT)+ 访问令牌 |
| 用什么 scope? | 资源级 scope(read:calendar、write:orders) | 需要 openid scope;profile、email 可选 |
| 令牌验证什么? | 客户端被允许做什么 | 用户是谁(sub、name、email 等) |
| 规范 | RFC 6749 | OIDC Core 1.0(建于 OAuth 2.0) |
授权类型——四种深入
"授权(grant)"是客户端获取授权的方法。OAuth 2.0 为不同客户端类型和用例定义了四种 grant。选错的不只是设计气味,而是安全漏洞。
1. 授权码(Authorization Code)——面向用户流程的安全默认
对任何涉及人类用户的客户端最安全的流程。关键洞见:一个短命、一次性的授权码(code)经浏览器重定向返回,然后立即在服务器对服务器之间换取真正的令牌。令牌从不碰浏览器地址栏、浏览器历史或 JavaScript 环境。客户端后端用它的 secret 做码交换——码本身对截获重定向的攻击者无用。
- 用户点"用 X 登录"——客户端把用户重定向到授权服务器,带
response_type=code、scope、redirect_uri和一个state参数(CSRF 防护——一个随机 nonce)。 - 用户在授权服务器的 UI 上认证并同意——用户从不在客户端站点输入凭证。
- 授权服务器带一个短命
code和原始state值重定向回客户端的redirect_uri。 - 客户端校验
state与它发出的匹配(CSRF 检查),然后客户端后端通过 POST 到 token 端点用client_secret把码换成令牌。 - 授权服务器返回
access_token、过期时间,以及可选的refresh_token。
# 步骤 1 — 把用户重定向到授权服务器
GET https://auth.example.com/authorize
?response_type=code
&client_id=my-app
&redirect_uri=https://myapp.com/callback
&scope=read:profile email
&state=xK9mN2pQ # 随机 nonce,存进 session
# 步骤 3 — 授权服务器带 code 重定向回来
GET https://myapp.com/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=xK9mN2pQ
# 步骤 4 — 服务器用 code 换令牌(从不在浏览器)
POST https://auth.example.com/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https://myapp.com/callback
&client_id=my-app
&client_secret=my-secret
# 响应
{
"access_token": "eyJhbGci...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "def50200...",
"scope": "read:profile email"
}
2. 客户端凭证(Client Credentials)——机器对机器授权
当无人类用户参与时使用——一个后端服务直接以自身身份认证以调用另一个服务的 API。客户端把它的 client_id 和 client_secret 直接发到 token 端点并收到一个访问令牌。无重定向、无用户同意屏、无授权码。常见于:微服务间调用、cron 作业、访问 API 的 CI/CD 流水线、调用车队管理 API 的 IoT 设备后端。
关键安全考量:client_secret 必须存在密钥管理器(AWS Secrets Manager、HashiCorp Vault、GCP Secret Manager)——绝不在源码或纳入版本控制的环境变量里。定期轮换它。机器对机器流程里泄露的客户端 secret 给攻击者无限制的服务级访问,而没有用户会注意到异常行为。
# 客户端凭证——无需用户重定向
POST https://auth.example.com/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64(client_id:client_secret)
grant_type=client_credentials
&scope=internal:orders.read internal:inventory.write
# 响应:服务级访问令牌
{
"access_token": "eyJhbGci...",
"token_type": "Bearer",
"expires_in": 3600
# 无 refresh_token——过期就重新请求
}
3. 设备授权(Device Authorization Grant)——给输入受限设备
为输入 URL 不实际的设备设计——智能电视、游戏主机、CLI 工具、IoT 设备。设备显示一个短码和一个 URL。用户在另一台设备(手机或笔记本)上访问该 URL、输入码并授权。设备轮询 token 端点直到授权完成。定义在 RFC 8628。
# 步骤 1 — 设备请求一个 device code
POST https://auth.example.com/device/code
grant_type=urn:ietf:params:oauth:grant-type:device_code
&client_id=my-tv-app
&scope=read:profile
# 响应——设备在屏幕上显示 user_code
{
"device_code": "GmRhm...",
"user_code": "WDJB-MJHT", # 显示在电视屏幕上
"verification_uri": "https://example.com/activate",
"expires_in": 1800,
"interval": 5 # 每 5 秒轮询
}
# 步骤 2 — 设备轮询直到用户在手机上批准
POST https://auth.example.com/token
grant_type=urn:ietf:params:oauth:grant-type:device_code
&device_code=GmRhm...
&client_id=my-tv-app
# → 用户批准前返回 "authorization_pending"
# → 批准后返回令牌
4. 隐式(已弃用)与资源所有者密码(避免)
隐式授权(Implicit grant)是在 PKCE 存在前为单页应用设计的:令牌直接在 URL 片段(#access_token=...)返回,跳过码交换步骤。这把令牌暴露给浏览器历史、referrer 头,以及页面上运行的任何 JavaScript——包括来自分析或广告的第三方脚本。OAuth 2.1 草案正式移除它。新应用不要用。用授权码 + PKCE 替代。
资源所有者密码凭证授权(ROPC)让客户端直接收集用户的用户名和密码并转发给授权服务器。这彻底违背了 OAuth 的初衷——用户必须把原始凭证托付给客户端。它仅适合在脱离直接凭证处理的遗留迁移期间用于高度可信的第一方客户端。OAuth 2.1 草案也移除了这个 grant。
刷新令牌——长命凭证
访问令牌刻意短命(通常 15 分钟到 1 小时)以限制令牌被盗的爆炸半径。刷新令牌是一个长命凭证(数天、数周,或直到被吊销),客户端可用它获取新访问令牌而无需再次提示用户。这实现无缝用户会话,同时让任何单个访问令牌的攻击窗口保持狭窄。
生产中重要的刷新令牌安全实践:
- 刷新令牌轮换——每次使用时,授权服务器签发一个新刷新令牌并使旧的失效。若被盗的刷新令牌被使用,合法客户端的下次刷新尝试会失败,触发重新认证流程。这是 RFC 6819 和 OAuth 2.1 草案推荐的模式。
- 存储——服务端 web 应用:存在 HttpOnly、Secure、SameSite=Strict cookie 或服务端会话里(绝不 localStorage)。SPA:只用 HttpOnly cookie——JavaScript 读不到它们。移动应用:用平台安全存储(iOS Keychain、Android Keystore)。
- 吊销——实现一个令牌吊销端点(RFC 7009),让用户能登出且令牌即便未过期也立即失效。
- 绝对过期——刷新令牌应有独立于活动的最大寿命(如 90 天),之后用户必须重新认证。这限制了存在旧备份里的刷新令牌的风险。
PKCE——Proof Key for Code Exchange
PKCE(RFC 7636,读作 "pixie")是授权码流程的扩展,为公开客户端设计——无法安全存储客户端 secret 的移动应用和 SPA。没有 PKCE,若攻击者截获重定向里的授权码(通过注册同一 URI scheme 的恶意应用,或泄露的 referrer 头),他们能用自己的客户端凭证换取令牌。PKCE 用密码学手段堵上这个缺口。
机制:授权请求前,客户端生成一个密码学随机的 32–96 字节 code_verifier,然后计算 code_challenge = BASE64URL(SHA256(code_verifier))。challenge 包含在授权请求里。当客户端稍后用码换令牌时,它发送原始 code_verifier。授权服务器重算哈希并验证它匹配——证明换码的人就是发起请求的同一方,而无需静态客户端 secret。
// SPA 里的 PKCE 实现(Web Crypto API)
async function generatePKCE() {
const verifier = generateRandomString(64); // 64 随机字节,base64url 编码
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
const challenge = base64urlEncode(digest);
return { verifier, challenge };
}
// 步骤 1 — 在授权请求里包含 code_challenge
const { verifier, challenge } = await generatePKCE();
sessionStorage.setItem('pkce_verifier', verifier); // 存起来备用
const authUrl = `https://auth.example.com/authorize
?response_type=code
&client_id=my-spa
&code_challenge=${challenge}
&code_challenge_method=S256
&redirect_uri=https://myapp.com/callback
&scope=read:profile
&state=xK9mN2pQ`;
// 步骤 2 — 换码,发送 verifier(不是 secret)
const tokenResponse = await fetch('https://auth.example.com/token', {
method: 'POST',
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authCode,
code_verifier: sessionStorage.getItem('pkce_verifier'), // verifier,不是 challenge
redirect_uri: 'https://myapp.com/callback',
client_id: 'my-spa'
// 无 client_secret——公开客户端
})
});
RFC 7636 最初瞄准公开客户端,但 OAuth 2.1 草案对所有授权码流程强制 PKCE——包括服务端机密客户端。原因:即便有客户端 secret,PKCE 也防御授权码注入攻击。给机密客户端加 PKCE 无任何坏处,且免费堵上一类攻击。
JWT 访问令牌——结构与校验
访问令牌有两种口味:不透明字符串(资源服务器必须通过内省在授权服务器查找的随机标识符)和 JWT(资源服务器能本地校验的自包含令牌)。JWT 在现代系统里常见得多,因为它们消除内省往返——每次 API 调用省一跳网络。
JWT 有三部分,用点分隔:header.payload.signature,每部分 base64url 编码。header 标识算法;payload 携带声明;signature 是完整性的密码学证明。
// JWT header——标识签名算法
{
"alg": "RS256", // RSA-SHA256 非对称——授权服务器签名,
"typ": "JWT", // 资源服务器用公钥验证(无需共享 secret)
"kid": "key-v2" // key ID——支持无停机的密钥轮换
}
// JWT payload——注册声明 + 自定义
{
"iss": "https://auth.example.com", // 签发者——必须匹配预期值
"sub": "user-42", // 主体——用户或服务身份
"aud": "https://api.example.com", // 受众——预期接收者
"exp": 1716553600, // 过期——UNIX 时间戳,过期则拒绝
"iat": 1716550000, // 签发时间
"jti": "a1b2c3d4", // JWT ID——用于吊销和重放检测
"scope": "read:orders write:cart", // 所授 scope
"roles": ["customer"] // 自定义声明——应用特定授权
}
资源服务器在服务数据前必须全部通过的校验步骤:
- 用授权服务器的公钥(从
/.well-known/jwks.json的 JWKS 端点取)验证签名。 - 检查
exp在未来(带小的时钟偏移容差,如 30 秒)。 - 检查
iss匹配预期的授权服务器 URL。 - 检查
aud含此资源服务器的标识符——防止令牌跨服务复用。 - 检查
scope含被调用的特定端点所需的权限。 - 对敏感操作,可选地对照一个短命吊销列表检查
jti。
绝不把 JWT header 里的 alg 字段当权威——在服务端用白名单校验它。经典攻击:攻击者把 alg 从 RS256 改成 HS256,然后用授权服务器的公钥作为 HMAC secret(它常可获得)签名令牌。一个信任 header 里 alg 的天真库会验证成功。务必在服务端钉死预期算法。
Scope——细粒度授权
Scope 是定义客户端请求权限的空格分隔字符串。授权服务器把它们放进令牌;资源服务器强制它们。好的 scope 设计遵循最小权限原则——客户端应只请求当前操作所需的,而非一个笼统的"admin" scope。
# 请求特定 scope——遵循 resource:action 命名约定
scope=orders:read orders:write profile:read
# 资源服务器中间件:服务前检查 scope
# (伪代码——在 Express、FastAPI、Spring Security 等里都适用)
function requireScope(scope) {
return (req, res, next) => {
const tokenScopes = req.auth.scope.split(' ');
if (!tokenScopes.includes(scope)) {
return res.status(403).json({ error: 'insufficient_scope' });
}
next();
};
}
# 按路由应用
app.get('/orders', requireScope('orders:read'), ordersController);
app.post('/orders', requireScope('orders:write'), ordersController);
state 参数与 CSRF 防护
state 参数不是可选的——它是授权码流程里的主要 CSRF 防御。没有它,攻击者能构造一个授权 URL、自己完成认证流程,然后骗受害者用攻击者的授权码访问回调端点。受害者的会话随后与攻击者的账号关联(登录 CSRF 攻击)。修法:生成一个密码学随机 nonce,存在服务端会话或签名 cookie 里,在授权请求里作为 state 包含它,并在回调时严格校验它匹配再继续。
常见安全坑与令牌防盗
redirect_uri 里的开放重定向
若授权服务器不严格对照预注册白名单校验 redirect_uri,攻击者能构造一个带 redirect_uri=https://attacker.com/steal 的 URL。授权码被重定向到攻击者的服务器。缓解:注册精确的 redirect URI(无通配符)并在授权服务器上把它们作为精确字符串匹配校验——而非前缀匹配。
SPA 里的访问令牌存储
把访问令牌存在 localStorage 或 sessionStorage 里会把它们暴露给 XSS 攻击——任何注入脚本都能读取并外泄它们。推荐模式:令牌只放内存(一个 JavaScript 变量或 React state),刷新令牌存在 HttpOnly、Secure、SameSite=Strict cookie 里。HttpOnly cookie 无法被 JavaScript 读取,所以 XSS 偷不到它。令牌在同源请求上自动发送。配合一个只能从同源调用的令牌刷新端点。
通过 Referrer 头的令牌泄露
若访问令牌出现在 URL 里(已弃用的隐式流程就这样,或有人不小心把它嵌进 query string),浏览器的 Referrer 头会把它暴露给页面加载的任何外部资源——分析脚本、CDN 资源、嵌入内容。绝不把令牌放进 URL。只在 HTTP 头里携带它们:Authorization: Bearer <token>。
刷新令牌被盗与检测
若刷新令牌被盗,攻击者能无限期静默维持访问。刷新令牌轮换提供检测:当攻击者使用被盗刷新令牌时,授权服务器签发一个新的并使旧的失效。当合法客户端下次试图用现已失效的令牌刷新时,服务器检测到这次复用——一个表明被盗的异常——并能使整个刷新令牌族失效,强制重新认证。这是 Auth0、Okta 和大多数现代 IdP 实现的"刷新令牌族"检测模式。
| 攻击向量 | 缓解 |
|---|---|
| 回调端点上的 CSRF | 每次回调校验 state 参数(密码学 nonce) |
| 授权码截获 | PKCE(所有流程,按 OAuth 2.1 连机密客户端也用) |
| 开放重定向 | 在授权服务器对照预注册白名单做精确 URI 匹配 |
| XSS 令牌窃取(SPA) | 访问令牌只放内存;刷新令牌放 HttpOnly cookie |
| 刷新令牌被盗 | 轮换 + 复用检测时令牌族失效 |
| 重放攻击 | 短访问令牌 TTL;敏感操作用 jti 声明 + 吊销列表 |
| 算法混淆 | 服务端算法白名单;绝不信任 JWT header 的 alg |
| 受众混淆 | 每个资源服务器校验 aud 声明;一个服务的令牌不得在另一个上工作 |
实践用例
- 第三方应用集成——"连接你的 Google 日历"——应用获得对日历数据的读访问而从不看到你的 Google 密码。用户可随时在 Google 账号设置里吊销访问而无需改密码。
- 单点登录(SSO)——用一个身份提供者(Okta、Auth0、Google Workspace)认证一次并访问多个内部应用。IdP 签发短命访问令牌和 ID token;每个应用本地校验它们而无需每个请求都回调 IdP。
- 给第三方开发者的 API 授权——给 API 消费者签发带 scope 的令牌。一个只读集成伙伴得到
scope=read:products;一个物流伙伴得到scope=read:orders write:shipments。泄露的伙伴令牌只危及他们的 scope,而非整个 API。 - 微服务对微服务——订单服务用客户端凭证获取一个限于
internal:inventory.read的令牌,再调用库存服务。每个服务校验令牌的aud和scope声明,所以被攻陷的订单服务无法调用支付服务。 - CLI 工具与开发者工作流——设备授权 grant 让 CLI 工具(
kubectl、gh、Terraform Cloud)让用户通过浏览器授权,同时 CLI 轮询令牌。终端里从不输入密码。
所有面向用户的流程用授权码 + PKCE——按 OAuth 2.1,web 和移动皆然。服务对服务用客户端凭证。输入受限客户端用设备授权。新系统绝不用隐式或密码 grant。让访问令牌短命且放内存,刷新令牌存 HttpOnly cookie 或平台安全存储,每次使用轮换,并校验 state 参数防 CSRF。记住:OAuth 2.0 是授权——在其上叠 OpenID Connect 做身份。每个资源服务器请求都校验 JWT 的签名、过期、签发者和受众。
OAuth vs OpenID Connect——区别是什么?OAuth 2.0 是授权(这个应用能访问你的日历吗?)。OIDC 在其上加认证——它签发一个带 name、email 等身份声明的 ID token(JWT)。"用 Google 登录"用 OIDC 叠 OAuth。ID token 回答"这是谁?";访问令牌回答"这个客户端能做什么?"
隐式授权为什么被弃用?令牌在 URL 片段返回,暴露给浏览器历史、referrer 头,以及页面上任何 JavaScript 包括第三方脚本。授权码 + PKCE 为 SPA 实现同样目标而无此暴露——PKCE 替代公开客户端对 secret 的需要。
PKCE 防御什么?授权码截获攻击。code_challenge 证明令牌交换来自发起授权请求的同一方,即便没有客户端 secret。OAuth 2.1 对所有授权码流程强制它。
SPA 应在哪存令牌?访问令牌只放内存(不放 localStorage——XSS 能读它)。刷新令牌放 HttpOnly、Secure、SameSite=Strict cookie(JavaScript 读不到,所以 XSS 偷不到)。刷新端点只从同源服务。
怎么校验一个 JWT 访问令牌?用 JWKS 端点取来的授权服务器公钥验证 RS256 签名,然后检查 exp(未过期)、iss(预期签发者)、aud(此服务标识符)、scope(此端点所需权限)。绝不信任 header 的 alg 字段——在服务端钉死它。