OAuth 2.0 授权码模式:从登录到 Token 续期的全链路执行流程

一、问题的起点

当我们采用 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 中存任何东西,不需要在请求头中手动附加 AuthorizationCookie 由浏览器自动管理,自动发送,自动过期。

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 是果

因已消亡,果独立存在。

具体来说:

  1. code 是入口凭证------它唯一的作用是在 token 端点换取 token。换完即废,不存储、不追踪。

  2. refresh_token 是续期凭证------token 续期完全依赖 refresh_token,与 code 无关。

  3. 续期链条是自包含的------每次刷新输入一个 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 存储中,攻击者触不可及。

相关推荐
QiHY4 个月前
通过Spring Authorization Server对vue应用进行授权防护
java·vue.js·spring·oauth
EndingCoder6 个月前
OAuth 2.0与第三方登录
node.js·oauth·第三方登录
w23617346011 年前
OAuth安全架构深度剖析:协议机制与攻防实践
安全·oauth·安全架构
x-cmd1 年前
[250224] Yaak 2.0:Git集成、WebSocket支持、OAuth认证等 | Zstandard v1.5.7 发布
运维·git·websocket·网络协议·安全·oauth·压缩
文浩(楠搏万)1 年前
如何从 Keycloak 的 keycloak-themes.jar 中提取原生主题并自定义设置
java·keycloak·oauth·jar·主题·单点登录·sso
一个人三座城2 年前
【个人博客搭建】(22)申请QQ开发者
oauth·qq
laizhixue2 年前
C# OAuth单点登录的实现
c#·oauth·单点登录
ZHOU西口2 年前
微服务实战系列之Token
微服务·oauth·security·jwt·token·令牌
阿昌喜欢吃黄桃3 年前
Day979.OAuth2.0可能存在的安全问题 -OAuth 2.0
网络·安全·系统安全·oauth·oauth2