OAuth 2.0 授权码模式:四个角色与一次安全握手

授权码模式,是 OAuth 2.0 里最经典、也是最安全的一种流程。我做了这么多年安全架构,但凡涉及到第三方应用需要代表用户访问资源,首选就是它。说白了,它就像一把「临时钥匙」,你交给别人用,但随时可以收回。

这一节,我们把这套机制拆开揉碎。先搞清楚谁是谁,再看它们怎么配合。

四个角色,一个都不能少

OAuth 2.0 定义了四个核心角色。我见过不少新手搞混「授权服务器」和「资源服务器」,其实它们分工很明确。

角色 英文 一句话解释 现实类比
资源所有者 Resource Owner 数据的真正主人,通常是用户 酒店房客
客户端 Client 想要访问资源的第三方应用 客房清洁员
授权服务器 Authorization Server 负责认证用户、颁发令牌 前台接待处
资源服务器 Resource Server 存储和保护实际数据 客房保险柜

关键区别:授权服务器只管「发证」,资源服务器只管「验货」。很多系统里它们可以是同一台机器,但逻辑上必须分开。我在项目中就见过把令牌校验逻辑写在业务代码里的,后来出了问题,排查起来非常痛苦。

授权码流程:七步走完一次安全授权

这个流程看起来步骤多,但每一步都有它的道理。你想想看,如果少了任何一步,要么不安全,要么用户体验极差。

  1. 客户端发起请求 — 用户点击「使用登录」,客户端把用户引导到授权服务器的登录页面。
  2. 用户认证并授权 — 用户在授权服务器上输入账号密码,并同意客户端访问某些数据(比如头像、昵称)。
  3. 授权服务器返回授权码 — 授权服务器通过浏览器重定向,把授权码(Authorization Code)传给客户端。注意,这一步用的是前端通信,所以授权码是明文的。
  4. 客户端用授权码换令牌 — 客户端拿着授权码,在后台直接请求授权服务器,换取访问令牌(Access Token)。这一步是后端到后端,安全多了。
  5. 授权服务器返回访问令牌 — 授权服务器验证授权码有效后,返回访问令牌,有时还会附带刷新令牌(Refresh Token)。
  6. 客户端携带令牌访问资源 — 客户端在请求资源服务器的 API 时,把访问令牌放在 HTTP 头里。
  7. 资源服务器返回资源 — 资源服务器验证令牌有效,返回用户数据。

我的习惯:授权码一定要设置有效期,通常 5-10 分钟就够了。时间太长,被截获的风险就越大。我曾经在一个项目里看到授权码有效期设了 24 小时,当时就惊了——这等于把大门钥匙放在门口垫子下面,还放了一整天。

为什么需要授权码?直接给令牌不行吗?

这是个好问题。早期的 OAuth 2.0 隐式模式(Implicit Flow)就是直接返回令牌的。但问题在于,令牌会经过浏览器,容易被恶意脚本或中间人攻击截获。

授权码模式多了一层「授权码换令牌」的后台交互。即使授权码在传输过程中被偷了,攻击者也拿不到令牌,因为他没有客户端的密钥(Client Secret)。

避坑指南:我曾经接手过一个项目,他们把 Client Secret 硬编码在移动端 App 里。这等于把保险柜密码写在保险柜门上。正确的做法是:移动端使用 PKCE(Proof Key for Code Exchange)扩展,用动态密钥代替静态 Secret。

核心流程可视化

下面这张图,把刚才的七步流程串起来了。我建议你多看几遍,直到能在脑子里把每一步的通信方向、数据内容都过一遍。

OAuth 2.0 授权码模式核心流程 资源所有者 客户端 授权服务器 资源服务器 ① 请求授权 ② 认证并授权 ③ 返回授权码 ④ 授权码换令牌 ⑤ 返回访问令牌 ⑥ 携带令牌请求资源 ⑦ 返回资源 注意:步骤 ③ 经过浏览器(前端通信),步骤 ④⑤ 是服务器到服务器(后端通信)

令牌与授权码的区别

很多人分不清授权码和访问令牌。我打个比方:授权码是「取票凭证」,你拿着它去柜台换演出门票;访问令牌就是那张「门票」,入场时给检票员看就行。

属性 授权码 (Authorization Code) 访问令牌 (Access Token)
用途 换取访问令牌 直接访问资源
有效期 极短(通常几分钟) 较短(通常几小时)
传输方式 前端重定向(URL 参数) 后端请求(HTTP Header)
是否需要 Client Secret 不需要(换令牌时需要) 不需要
泄露风险 中等(有效期短,风险可控) 高(直接可用)

一个简单的代码示例

下面是一个简化版的授权码流程实现。注意,这只是演示逻辑,生产环境请使用成熟的 OAuth 库。

// 1. 客户端引导用户到授权服务器
// 用户点击后,浏览器跳转到:
// https://auth.example.com/authorize?
//   response_type=code&
//   client_id=YOUR_CLIENT_ID&
//   redirect_uri=https://client.example.com/callback&
//   scope=profile&
//   state=random_state_string

// 2. 用户认证并授权后,授权服务器重定向回客户端
// GET https://client.example.com/callback?code=AUTH_CODE&state=random_state_string

// 3. 客户端在后台用授权码换令牌
POST https://auth.example.com/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&
code=AUTH_CODE&
redirect_uri=https://client.example.com/callback&
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET

// 4. 授权服务器返回访问令牌
{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "def50200df...",
  "scope": "profile"
}

// 5. 客户端使用访问令牌请求资源
GET https://api.example.com/user/profile
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...

我的建议:一定要校验 state 参数。它可以防止跨站请求伪造(CSRF)攻击。我见过一个案例,就是因为没校验 state,攻击者伪造了回调请求,拿到了用户的授权码。虽然还有 Client Secret 保护,但多一层防护总是好的。

常见误区与最佳实践

  • 误区一:把授权码直接当令牌用。授权码只能换一次令牌,换完就失效。
  • 误区二:在客户端存储 Client Secret。公开客户端(如浏览器、移动 App)无法安全存储密钥,必须使用 PKCE。
  • 误区三:忽略令牌过期时间。访问令牌过期后,应该用刷新令牌获取新的,而不是让用户重新登录。
  • 最佳实践:始终使用 HTTPS。所有 OAuth 通信都应该加密,否则令牌和授权码都可能被中间人截获。

我曾经踩过的坑:有一次,我在一个微服务架构里,把授权服务器和资源服务器完全分开部署,但忘了同步时钟。结果资源服务器校验令牌时,因为时间偏差,所有令牌都显示「已过期」。从那以后,我养成了一个习惯:所有涉及令牌校验的服务,都用 NTP 同步时间,并且留出 5 分钟的时钟偏差容忍窗口。

授权码模式,说白了就是「让专业的人做专业的事」。授权服务器负责认证和发证,资源服务器负责保护数据,客户端只负责展示。各司其职,才能构建出既安全又灵活的系统。