一、问题的起点
当我们采用 OAuth 2.0 授权码模式(response_type=code)时,前端拿到的只是一个无直接价值的授权码。这引出了一连串工程问题:
- 前端不持有 access_token,怎么访问受保护的 API?
- 前端和自己的后端之间用什么机制做身份校验?
- code 用完就废了,token 过期后靠什么续期?
- 整个生命周期中,各凭证之间的关系是什么?
这些问题的答案构成了一套完整的双层认证代理架构。本文将从第一次点击"登录"按钮开始,到 refresh_token 最终过期用户被迫重新登录为止,完整拆解每一步的执行细节。
二、架构全景
授权码模式在生产环境中落地后,系统中存在四个角色 和两层认证体系:
┌──────────────────────────────────────────────────────────────────┐
│ │
│ 前端(浏览器) │
│ 持有:Session Cookie │
│ 不持有:code、access_token、refresh_token │
│ │
│ │ 第一层认证:Session Cookie │
│ │ 协议:HTTP Cookie(HttpOnly, Secure, SameSite=Lax) │
│ ▼ │
│ │
│ 自己的后端(BFF / Application Server) │
│ 持有:access_token、refresh_token(存储在 session/Redis 中) │
│ 职责:代理所有资源 API 调用、管理 token 生命周期 │
│ │
│ │ 第二层认证:Bearer Token │
│ │ 协议:HTTP Authorization Header │
│ ▼ │
│ │
│ 资源服务器(Resource Server / Web API) │
│ 职责:验证 access_token、返回受保护的资源 │
│ │
│ │
│ 授权服务器(Authorization Server) │
│ 职责:签发 code、签发/刷新 token、验证客户端身份 │
│ │
└──────────────────────────────────────────────────────────────────┘
核心设计原则:前端只和自己的后端通信,后端代理一切敏感操作。 这使得 access_token 永远不暴露在浏览器环境中。
三、阶段一:登录(Code 的诞生与死亡)
3.1 发起授权请求
用户点击"使用 Google 登录"
│
▼
前端生成防护参数:
state = crypto.randomUUID() // 防 CSRF
nonce = crypto.randomUUID() // 防重放(OIDC)
code_verifier = base64url(random(32)) // PKCE
code_challenge = base64url(SHA256(code_verifier))
│
▼
前端将 state、code_verifier 存入自己后端的 session(通过一个预请求)
│
▼
浏览器重定向(前端通道 →):
GET https://accounts.google.com/o/oauth2/v2/auth?
response_type=code
&client_id=shop-app-123
&redirect_uri=https://shop.example.com/callback
&scope=openid email profile
&state=a1b2c3d4-e5f6-...
&nonce=f7g8h9i0-j1k2-...
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
3.2 用户授权
用户在 Google 页面:
① 输入账号密码(如果未登录)
② 看到授权确认页面:"shop.example.com 请求访问您的邮箱和基本信息"
③ 点击"允许"
3.3 授权码回传(前端通道)
← 前端通道:
HTTP/1.1 302 Found
Location: https://shop.example.com/callback?
code=4/0AX4XfWh8kZ3xN2Gv7PQwerty...
&state=a1b2c3d4-e5f6-...
浏览器自动跳转到此地址。
code 出现在 URL 中,面临前端通道的所有风险(浏览器历史、Referer、扩展)。
但 code 是一次性的,且即将在几秒内被消费。
3.4 Code 换 Token(后端通道)
后端收到 /callback 请求,执行以下校验和操作:
① 验证 state 与 session 中存储的值一致 → 防 CSRF
② 验证 redirect_uri 与预注册的一致
③ 向授权服务器发起后端通道请求:
POST https://oauth2.googleapis.com/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=4/0AX4XfWh8kZ3xN2Gv7PQwerty...
&client_id=shop-app-123
&client_secret=GOCSPX-xxxxxxxx ← 只有服务器知道
&redirect_uri=https://shop.example.com/callback
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk ← PKCE
授权服务器验证:
✓ code 有效且未被使用过
✓ code 是签发给 client_id=shop-app-123 的
✓ client_secret 正确
✓ SHA256(code_verifier) == 原始请求中的 code_challenge
✓ redirect_uri 与原始请求一致
验证通过,返回(后端通道 ←):
{
"access_token": "ya29.a0AfH6SM_EqZK...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "1//0gXXXXXXXXXX...",
"id_token": "eyJhbGciOiJSUzI1NiJ9...",
"scope": "openid email profile"
}
④ 授权服务器立即将该 code 标记为已使用 → code 死亡,永久作废
3.5 建立 Session(连接两层认证)
后端完成 token 交换后:
① 验证 id_token 的签名、iss、aud、exp、nonce
② 从 id_token 中提取用户身份(sub、email、name)
③ 在本地数据库中查找或创建用户记录
④ 将 token 存入 session:
session = {
userId: "user_118234567890",
email: "user@gmail.com",
name: "张三",
accessToken: "ya29.a0AfH6SM_EqZK...",
refreshToken: "1//0gXXXXXXXXXX...",
tokenExpiresAt: 1716048000000, // Date.now() + 3600*1000
isAuthenticated: true
}
⑤ 向浏览器设置 Session Cookie:
Set-Cookie: sid=s%3AaGVsbG8gd29ybGQ.签名;
Path=/;
HttpOnly; ← JavaScript 无法读取
Secure; ← 仅 HTTPS 传输
SameSite=Lax; ← 防跨站请求
Max-Age=86400 ← Cookie 有效期(与 session 有效期独立)
⑥ 重定向到应用主页
HTTP/1.1 302 Found
Location: https://shop.example.com/dashboard
至此,登录流程结束。 code 已经死亡,token 安全地存储在服务器端,浏览器只持有一个不含任何敏感信息的 Session Cookie。
四、阶段二:日常使用(双层代理调用)
用户登录后浏览商品、查看订单等日常操作,每次都经过以下链路:
┌───────────┐ ┌───────────────┐ ┌─────────────┐
│ 前端 │ │ 自己的后端 │ │ 资源 API │
│ (浏览器) │ │ (BFF) │ │ │
└─────┬─────┘ └──────┬────────┘ └──────┬──────┘
│ │ │
│ GET /api/orders │ │
│ Cookie: sid=xxx │ │
│────────────────────▶│ │
│ │ │
│ ┌──────┴──────┐ │
│ │ 校验 session │ │
│ │ sid → 查找 │ │
│ │ session 有效? │ │
│ │ 用户已认证? │ │
│ └──────┬──────┘ │
│ │ │
│ ┌──────┴──────┐ │
│ │ 检查 token │ │
│ │ 是否即将过期 │ │
│ │ (提前60秒) │ │
│ └──────┬──────┘ │
│ │ 否,token 仍有效 │
│ │ │
│ │ GET /v1/orders │
│ │ Authorization: │
│ │ Bearer ya29.a0A... │
│ │───────────────────────▶│
│ │ │
│ │ 200 OK │
│ │ [{ orderId, ... }] │
│ │◀───────────────────────│
│ │ │
│ 200 OK │ │
│ [{ orderId, ... }] │ │
│◀────────────────────│ │
关键细节:
- 前端的请求中只有 Cookie,没有任何 token
- Cookie 是 HttpOnly 的,前端 JavaScript 无法读取其内容
- 后端从 session 中取出 access_token,代替前端去调资源 API
- 资源 API 验证 access_token 的签名和有效期,与前端完全无关
- 即使前端存在 XSS 漏洞,攻击者也拿不到 access_token
4.1 前端视角下的权限控制
从前端的角度看,权限模型非常简单------和传统 Session 认证完全一样:
javascript
// 前端代码:完全不知道 OAuth 的存在
async function getOrders() {
const response = await fetch('/api/orders', {
credentials: 'include' // 自动带上 Cookie
});
if (response.status === 401) {
// session 过期,跳转登录
window.location.href = '/login';
return;
}
return response.json();
}
前端不需要管理任何 token,不需要在 localStorage 中存任何东西,不需要在请求头中手动附加 Authorization。Cookie 由浏览器自动管理,自动发送,自动过期。
4.2 后端视角下的权限控制
后端需要同时维护两层认证状态:
请求进入
│
▼
┌─────────────────┐
│ 第一层校验 │
│ Session Cookie │
│ │
│ Cookie 有效? │──── 否 ──→ 401 Unauthorized
│ Session 存在? │ 前端跳转登录页
│ 用户已认证? │
└────────┬────────┘
│ 是
▼
┌─────────────────┐
│ 第二层校验 │
│ Token 状态 │
│ │
│ access_token │
│ 是否即将过期? │──── 是 ──→ 触发 Token 刷新流程
└────────┬────────┘ (见阶段三)
│ 否
▼
┌─────────────────┐
│ 执行业务逻辑 │
│ │
│ 用 access_token │
│ 调用资源 API │
│ │
│ 资源 API 返回 │
│ 401? │──── 是 ──→ 触发 Token 刷新流程
└────────┬────────┘ 刷新后重试请求
│ 正常
▼
返回数据给前端
五、阶段三:Token 刷新(静默续期)
这是整套系统中最精密的环节。当 access_token 过期时,后端需要在前端完全无感知的情况下完成 token 续期。
5.1 触发时机
Token 刷新有两种触发策略:
策略一:主动刷新(推荐)
每次请求前检查 tokenExpiresAt
如果 当前时间 > tokenExpiresAt - 缓冲期(60秒)
则先刷新再调 API
优点:避免一次失败的 API 调用
缺点:依赖客户端时钟准确性
策略二:被动刷新
直接调 API
如果返回 401
则刷新 token 后重试
优点:不依赖时钟
缺点:每次过期都有一次失败请求
生产环境通常两种策略结合使用:主动刷新为主,被动刷新兜底。
5.2 刷新执行流程
自己的后端 授权服务器
│ │
│ 检测到 access_token 即将过期 │
│ │
│ POST /token │
│ Content-Type: application/x-www-form │
│ │
│ grant_type=refresh_token │
│ &refresh_token=1//0gXXXXXX... │
│ &client_id=shop-app-123 │
│ &client_secret=GOCSPX-xxxxxxxx │
│───────────────────────────────────────▶│
│ │
│ 授权服务器验证:│
│ ✓ refresh_token 有效
│ ✓ client 身份正确
│ ✓ 未被撤销
│ │
│ 200 OK │
│ { │
│ "access_token": "ya29.NEW...", │
│ "token_type": "Bearer", │
│ "expires_in": 3600, │
│ "refresh_token": "1//0gNEW..." │← 可能轮换了
│ } │
│◀───────────────────────────────────────│
│ │
│ 更新 session: │
│ accessToken = "ya29.NEW..." │
│ tokenExpiresAt = now + 3600s │
│ refreshToken = "1//0gNEW..." │
│ │
│ 继续执行原始 API 调用(用新 token) │
5.3 Refresh Token 轮换
许多授权服务器实施 Refresh Token Rotation------每次使用 refresh_token 时,旧的立即作废,同时签发一个新的:
初始: refresh_token_1 ──→ 换取 ──→ access_token_2 + refresh_token_2
│ │
立即作废 │
▼
refresh_token_2 ──→ 换取 ──→ access_token_3 + refresh_token_3
│ │
立即作废 │
▼
...
轮换的安全价值: 如果攻击者窃取了 refresh_token_1,并尝试使用它:
场景:refresh_token_1 已经被合法客户端使用过(换成了 refresh_token_2)
攻击者尝试用 refresh_token_1 换取 token
│
▼
授权服务器检测到:refresh_token_1 已被使用过
│
▼
判定为 token 泄露 → 立即撤销整个 refresh_token 家族
(refresh_token_1、refresh_token_2 全部作废)
│
▼
合法用户下次请求时 refresh 失败 → 需要重新登录
(安全优先:宁可让合法用户重新登录,也不让攻击者持有有效 token)
5.4 并发刷新问题
当多个前端请求同时发现 token 过期,会并发触发多次刷新。这必须处理:
请求 A ──→ 检测过期 ──→ 开始刷新 ──→ 获得 token_2 ✓
请求 B ──→ 检测过期 ──→ 开始刷新 ──→ 用已作废的 refresh_token ✗ (如果有轮换)
请求 C ──→ 检测过期 ──→ 开始刷新 ──→ 用已作废的 refresh_token ✗
解决方案------刷新锁:
javascript
let refreshPromise = null; // 模块级变量
async function ensureValidToken(session) {
if (Date.now() < session.tokenExpiresAt - 60000) {
return; // token 未过期,直接返回
}
// 如果已有刷新请求在进行中,等待它完成即可,不重复发起
if (refreshPromise) {
return refreshPromise;
}
// 第一个检测到过期的请求负责刷新
refreshPromise = doRefresh(session)
.finally(() => {
refreshPromise = null; // 刷新完成,清除锁
});
return refreshPromise;
}
有了刷新锁之后:
请求 A ──→ 检测过期 ──→ 发起刷新(设置锁)──→ 获得 token_2 ✓
请求 B ──→ 检测过期 ──→ 发现锁存在 ──→ 等待 ──→ 复用 token_2 ✓
请求 C ──→ 检测过期 ──→ 发现锁存在 ──→ 等待 ──→ 复用 token_2 ✓
六、阶段四:Session 终结
6.1 终结的三种情况
情况一:用户主动登出
前端 ──→ POST /logout ──→ 后端销毁 session
后端可选:调用授权服务器的 revocation endpoint 撤销 token
后端:清除 Cookie
前端:重定向到登录页
情况二:Session Cookie 过期
浏览器自动删除 Cookie
下次请求时后端找不到 session → 401
前端跳转登录页
情况三:Refresh Token 过期
后端尝试刷新 access_token 时收到错误:
{ "error": "invalid_grant" }
后端销毁 session、清除 Cookie
前端收到 401 → 跳转登录页
用户需要重新走完整的授权流程(新的 code → 新的一切)
6.2 Token 撤销(主动登出时的最佳实践)
POST https://oauth2.googleapis.com/revoke
Content-Type: application/x-www-form-urlencoded
token=1//0gXXXXXXXXXX... ← refresh_token
&token_type_hint=refresh_token
# 撤销 refresh_token 时,授权服务器通常会同时撤销关联的 access_token
七、凭证生命周期全景图
时间 ──────────────────────────────────────────────────────────────────→
用户点击登录
│
▼
code 诞生 ─────┐
│ │ 后端换取 token(几秒内完成)
▼ │
code 死亡 ◄────┘
(一次性,永久作废)
│
├──▶ access_token₁ 诞生
│ │
│ │ 有效期:1 小时
│ │ 用途:每次 API 调用都使用它
│ │ 使用次数:不限
│ │
│ ▼
│ access_token₁ 过期 ──→ 死亡
│
│ │ 触发刷新
│ ▼
│ access_token₂ 诞生(由 refresh_token 换取)
│ │
│ │ 又有 1 小时
│ ▼
│ access_token₂ 过期 ──→ 死亡
│
│ ... 如此循环 ...
│
└──▶ refresh_token₁ 诞生
│
│ 有效期:30 天(示例)
│ 用途:仅用于换取新 access_token
│ 可能被轮换为 refresh_token₂、₃、₄...
│
▼
refresh_token 最终过期 ──→ 整条链断裂
│
▼
用户重新登录
新的 code → 新的一切
Session Cookie 生命周期(独立于 OAuth token):
├──────────────────── 24小时或浏览器关闭 ────────────────────┤
session 中持有 access_token 和 refresh_token 的引用
Cookie 过期 = 前端丧失和后端的认证关系 = 需要重新登录
八、为什么 code 和 token 不需要"对应关系维护"
这是一个常见疑惑,但本质上这是一个不存在的问题。
code 与 token 的关系不是"对应",而是"因果":
code 是因 ──→ token 是果
因已消亡,果独立存在。
具体来说:
-
code 是入口凭证------它唯一的作用是在 token 端点换取 token。换完即废,不存储、不追踪。
-
refresh_token 是续期凭证------token 续期完全依赖 refresh_token,与 code 无关。
-
续期链条是自包含的------每次刷新输入一个 refresh_token,输出一个新的 access_token(可能还有新的 refresh_token)。这个链条可以无限延续,直到 refresh_token 过期或被撤销。
错误的心智模型:
code ←──对应──→ access_token(需要维护映射?)正确的心智模型:
code ──→ [一次性转化] ──→ access_token₁ + refresh_token₁
│
refresh_token₁ ──→ access_token₂ + refresh_token₂
│
refresh_token₂ ──→ access_token₃ + ...code 在第一步之后就不存在了,后续的链条与它毫无关系。
九、总结
OAuth 2.0 授权码模式在工程实践中形成的是一套双层代理架构:
| 层 | 通信双方 | 凭证 | 安全机制 |
|---|---|---|---|
| 第一层 | 前端 ↔ 自己的后端 | Session Cookie | HttpOnly, Secure, SameSite |
| 第二层 | 自己的后端 ↔ 资源 API | access_token | Bearer Token, TLS |
前端的世界很简单------它只知道 Cookie,不知道 OAuth 的存在。
后端承担了全部复杂性------管理 token 存储、检测过期、执行刷新、处理并发、代理 API 调用、处理登出和 token 撤销。
code 是引信,不是燃料------它点燃整个 token 链条后就消失了。燃料是 refresh_token,它驱动着 access_token 的持续更新,直到自己也燃尽,用户被引导重新登录,一切重新开始。
这套机制的优雅之处在于:每一层都只暴露最低限度的信息给最不安全的环境。浏览器只拿到不可读的 Cookie,后端拿到有时效的 access_token,只有 refresh_token 具有长期价值------而它被锁在服务器的 session 存储中,攻击者触不可及。