10 KiB
tags, create time
| tags | create time | |||
|---|---|---|---|---|
|
2026-07-13 10:03 |
OAuth 2.0 授权码模式
概述
假设你在开发一个「周报助手」应用,需要读取用户的 Google 日历来自动安排会议。你不可能让用户把 Google 密码告诉你——这既不安全,也不合理。你需要的只是有限度地访问用户的日历数据。
OAuth 2.0(RFC 6749) 就是解决这个问题的框架:让用户授权第三方应用访问自己的部分资源,而不需要交出密码。
[!info] 认证(Authentication)vs 授权(Authorization) 这是两个经常混淆的概念:
- 认证:证明「你是谁」→ 比如出示身份证,回答「我是张三」
- 授权:决定「你能干什么」→ 比如张三可以查看日历,但不能删除日历
OAuth 2.0 只解决授权。它能拿到你的日历数据,但不知道(也不关心)登录的用户是张三还是李四。后来 OIDC 在 OAuth 2.0 之上补了认证,才实现了完整的 SSO。
核心角色
OAuth 2.0 定义了四个角色(比 SSO 主文档多了一个 Resource Server):
| 角色 | 说明 | 生活类比 |
|---|---|---|
| Resource Owner | 资源的拥有者,就是用户本人 | 房子的主人 |
| Client | 请求访问资源的第三方应用 | 要借住的朋友 |
| Authorization Server | 授权服务器,负责认证用户、颁发 Token | 物业管理处 |
| Resource Server | 存放资源的 API 服务 | 你的房子 |
[!tip] 为什么 Authorization Server 和 Resource Server 要分开? 在小型系统中它们通常是同一个服务。但在大型架构中,认证是一个独立的基础设施(如 Keycloak),业务 API 可能有几十个。分离后,所有 API 共享同一个认证中心。
授权码模式完整流程
这是 OAuth 2.0 中最安全、最常用的模式。我们用「周报助手访问 Google 日历」这个场景来走一遍:
sequenceDiagram
participant U as 用户 (浏览器)
participant App as 周报助手 (Client)
participant Google as Google 授权服务器
participant Cal as Google 日历 API
U->>App: 1. 点击「关联 Google 日历」
App->>U: 2. 浏览器跳转到 Google 登录页
Note right of App: URL 携带 client_id、scope、<br/>redirect_uri、state 参数
U->>Google: 3. 登录 Google 账号
Google->>U: 4. 展示授权页面:「周报助手想要读取你的日历」
U->>Google: 5. 点击「允许」
Google->>U: 6. 浏览器跳回周报助手的回调地址
Note left of Google: URL 携带一次性授权码 code 和 state
U->>App: 7. 周报助手收到 code
App->>Google: 8. 后端用 code + client_secret 换 Token
Note right of App: 这一步是后端直连 Google,<br/>不经过浏览器
Google-->>App: 9. 返回 access_token(+ 可选的 refresh_token)
App->>Cal: 10. 用 access_token 请求日历数据
Cal-->>App: 11. 返回日历事件
为什么这么绕?关键设计:
- 步骤 2-7 走浏览器跳转(用户能看到)—— 让用户亲自在 Google 页面登录和授权,密码不会经过第三方应用
- 步骤 8-9 走后端直连(用户看不到)——
code换token的过程在后端完成,token不会暴露在浏览器地址栏中 code只能用一次,有效期通常 < 10 分钟,即使被截获也无法使用
关键参数说明
步骤 2 的跳转 URL 大致长这样:
https://accounts.google.com/o/oauth2/v2/auth
?client_id=abc123 ← 你在 Google 注册的应用 ID
&redirect_uri=https://app.example.com/callback ← 回调地址(注册时填好的)
&scope=https://www.googleapis.com/auth/calendar.readonly ← 请求的权限范围
&state=xyz789 ← 防 CSRF 的随机字符串
&response_type=code ← 告诉 Google 我要授权码模式
| 参数 | 说明 |
|---|---|
client_id |
应用在授权服务器注册时获得的 ID,类似「应用的身份证号」 |
client_secret |
应用密钥,只有后端知道,绝不能暴露到前端 |
redirect_uri |
授权完成后浏览器跳回的地址,必须和注册时完全一致 |
scope |
请求的权限范围,如「只读日历」「读写日历」。范围越小,用户越信任 |
state |
随机字符串,用于防 CSRF 攻击。回调时 Google 会原样返回,你比对一下就知道是不是你发起的请求 |
code |
一次性授权码,用来换 Token,用完即废 |
Access Token vs Refresh Token
授权服务器通常返回两个 Token:
graph LR
A[用户授权] --> B[授权服务器]
B -->|颁发| C[Access Token]
B -->|颁发| D[Refresh Token]
C -->|有效期短<br/>5-30 分钟| E[调用 API]
D -->|有效期长<br/>几天到几个月| F[换新的 Access Token]
F -.->|每次刷新,旧的 RT 作废| D
| Token | 用途 | 有效期 | 存储建议 |
|---|---|---|---|
| Access Token | 调用 API 时放在 Header 里 | 短(5-30 分钟) | 内存中,或 HttpOnly Cookie |
| Refresh Token | Access Token 过期后用来续期 | 长(天 ~ 月) | 后端存储,或 HttpOnly Secure Cookie |
[!tip] Refresh Token Rotation(轮换机制) 每次用 Refresh Token 换新 Token 时,授权服务器会同时颁发一个新的 Refresh Token,并使旧的失效。这样即使某个 Refresh Token 泄露,攻击者也只能用一次,第二次就会被发现。
四种授权模式对比
除了授权码模式,OAuth 2.0 还定义了其他几种模式。了解它们的适用场景:
| 模式 | 一句话解释 | 适用场景 | 安全性 |
|---|---|---|---|
| 授权码模式 | 用户授权 → 拿到一次性 code → 后端换 Token | Web 后端应用、SSO | 最高 |
| 授权码 + PKCE | 在授权码基础上加一个动态验证码,防止 code 被截获 | SPA(前端单页应用)、移动端 | 高 |
| 客户端凭证 | 没有用户参与,应用直接用自己的身份换 Token | 服务间调用(M2M),如定时任务调 API | 中 |
| Token 直接返回到浏览器地址栏 | 已弃用,仅遗留系统 | 低 |
[!warning] 什么是 PKCE? PKCE(Proof Key for Code Exchange,发音 "pixy")解决的是 SPA 和移动端无法安全存储
client_secret的问题。原理:客户端先生成一个随机的
code_verifier,对其做哈希得到code_challenge。授权请求时带上code_challenge,换 Token 时带上原始的code_verifier。授权服务器验证哈希是否匹配。即使攻击者截获了code,没有code_verifier也换不到 Token。新项目做 SPA/移动端,一律用 PKCE,不要用隐式模式。
Go 实现示例
使用标准库 golang.org/x/oauth2 实现完整的授权码流程:
import (
"context"
"crypto/rand"
"encoding/hex"
"golang.org/x/oauth2"
)
// 1. 配置 OAuth2 客户端(通常从环境变量或配置文件读取)
conf := &oauth2.Config{
ClientID: "your-client-id",
ClientSecret: "your-client-secret",
// scope:请求哪些权限。这里请求读取用户基本信息
Scopes: []string{"profile", "email"},
Endpoint: oauth2.Endpoint{
AuthURL: "https://idp.example.com/oauth/authorize",
TokenURL: "https://idp.example.com/oauth/token",
},
// 授权完成后,IdP 会把浏览器重定向到这个地址
RedirectURL: "https://app.example.com/callback",
}
// 2. 处理「登录」按钮点击 — 生成授权 URL 并跳转
func handleLogin(w http.ResponseWriter, r *http.Request) {
// 生成随机 state 防 CSRF,存入 session 以便回调时比对
state := generateRandomState()
session, _ := store.Get(r, "session")
session.Values["oauth_state"] = state
session.Save(r, w)
// AccessTypeOffline 表示我们需要 Refresh Token(默认只给 Access Token)
authURL := conf.AuthCodeURL(state, oauth2.AccessTypeOffline)
http.Redirect(w, r, authURL, http.StatusFound)
}
// 3. 处理回调 — 用 code 换 Token
func handleCallback(w http.ResponseWriter, r *http.Request) {
// 校验 state,防止 CSRF 攻击
session, _ := store.Get(r, "session")
expectedState := session.Values["oauth_state"].(string)
if r.URL.Query().Get("state") != expectedState {
http.Error(w, "state mismatch: possible CSRF attack", http.StatusBadRequest)
return
}
// 用一次性授权码交换 Token(后端直连 IdP,不经过浏览器)
code := r.URL.Query().Get("code")
token, err := conf.Exchange(context.Background(), code)
if err != nil {
http.Error(w, "token exchange failed: "+err.Error(), http.StatusInternalServerError)
return
}
// token.AccessToken — 用来调 API 的短期令牌
// token.RefreshToken — 用来续期的长期令牌(如果请求了 AccessTypeOffline)
// token.Expiry — Access Token 的过期时间
// 用 Token 请求用户信息
client := conf.Client(context.Background(), token)
resp, _ := client.Get("https://idp.example.com/userinfo")
defer resp.Body.Close()
// ... 解析用户信息 ...
}
// 辅助函数:生成随机 state
func generateRandomState() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}
常见陷阱与最佳实践
CSRF 攻击:必须使用 state 参数
攻击场景:攻击者构造一个恶意链接 https://app.example.com/callback?code=ATTACKER_CODE,诱导用户点击。如果没有 state 校验,你的应用会拿攻击者的 code 去换 Token,攻击者就能冒充用户。
防御:生成随机 state 存入 session,回调时比对一致性。不一致就拒绝。
Token 存储安全
| 位置 | 安全性 | 建议 |
|---|---|---|
localStorage |
❌ 任何 JS 代码(含 XSS)都能读取 | 不要用 |
sessionStorage |
❌ 同上 | 不要用 |
| HttpOnly Cookie | ✅ JS 无法读取 | 推荐(后端设置) |
| 内存变量 | ✅ 页面刷新就丢失 | 适合短期使用 |
redirect_uri 必须严格匹配
授权服务器对 redirect_uri 的校验是精确匹配——不支持通配符,不支持路径前缀。这是防止 Token 被发送到攻击者控制的地址。
scope 最小化原则
只请求你真正需要的权限。用户在授权页面看到的权限列表越少,越愿意点击「允许」。scope: "all" 是最差的做法。