2026-07-13 10:08:46 +08:00
|
|
|
|
---
|
|
|
|
|
|
tags: [sso, oauth2, auth]
|
|
|
|
|
|
create time: 2026-07-13 10:03
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
# OAuth 2.0 授权码模式
|
|
|
|
|
|
|
|
|
|
|
|
## 概述
|
|
|
|
|
|
|
2026-07-13 11:11:37 +08:00
|
|
|
|
假设你在开发一个「周报助手」应用,需要读取用户的 Google 日历来自动安排会议。你不可能让用户把 Google 密码告诉你——这既不安全,也不合理。你需要的只是**有限度地访问用户的日历数据**。
|
2026-07-13 10:08:46 +08:00
|
|
|
|
|
2026-07-13 11:11:37 +08:00
|
|
|
|
**OAuth 2.0(RFC 6749)** 就是解决这个问题的框架:让用户**授权**第三方应用访问自己的部分资源,而不需要交出密码。
|
2026-07-13 10:08:46 +08:00
|
|
|
|
|
2026-07-13 11:11:37 +08:00
|
|
|
|
> [!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 日历」这个场景来走一遍:
|
2026-07-13 10:08:46 +08:00
|
|
|
|
|
|
|
|
|
|
```mermaid
|
|
|
|
|
|
sequenceDiagram
|
|
|
|
|
|
participant U as 用户 (浏览器)
|
2026-07-13 11:11:37 +08:00
|
|
|
|
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. 返回日历事件
|
2026-07-13 10:08:46 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-07-13 11:11:37 +08:00
|
|
|
|
**为什么这么绕?关键设计:**
|
2026-07-13 10:08:46 +08:00
|
|
|
|
|
2026-07-13 11:11:37 +08:00
|
|
|
|
1. **步骤 2-7 走浏览器跳转**(用户能看到)—— 让用户亲自在 Google 页面登录和授权,密码不会经过第三方应用
|
|
|
|
|
|
2. **步骤 8-9 走后端直连**(用户看不到)—— `code` 换 `token` 的过程在后端完成,`token` 不会暴露在浏览器地址栏中
|
|
|
|
|
|
3. **`code` 只能用一次**,有效期通常 < 10 分钟,即使被截获也无法使用
|
2026-07-13 10:08:46 +08:00
|
|
|
|
|
2026-07-13 11:11:37 +08:00
|
|
|
|
### 关键参数说明
|
2026-07-13 10:08:46 +08:00
|
|
|
|
|
2026-07-13 11:11:37 +08:00
|
|
|
|
步骤 2 的跳转 URL 大致长这样:
|
2026-07-13 10:08:46 +08:00
|
|
|
|
|
2026-07-13 11:11:37 +08:00
|
|
|
|
```
|
|
|
|
|
|
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 我要授权码模式
|
|
|
|
|
|
```
|
2026-07-13 10:08:46 +08:00
|
|
|
|
|
|
|
|
|
|
| 参数 | 说明 |
|
|
|
|
|
|
|------|------|
|
2026-07-13 11:11:37 +08:00
|
|
|
|
| `client_id` | 应用在授权服务器注册时获得的 ID,类似「应用的身份证号」 |
|
|
|
|
|
|
| `client_secret` | 应用密钥,只有后端知道,绝不能暴露到前端 |
|
|
|
|
|
|
| `redirect_uri` | 授权完成后浏览器跳回的地址,必须和注册时完全一致 |
|
|
|
|
|
|
| `scope` | 请求的权限范围,如「只读日历」「读写日历」。范围越小,用户越信任 |
|
|
|
|
|
|
| `state` | 随机字符串,用于防 CSRF 攻击。回调时 Google 会原样返回,你比对一下就知道是不是你发起的请求 |
|
|
|
|
|
|
| `code` | 一次性授权码,用来换 Token,用完即废 |
|
|
|
|
|
|
|
|
|
|
|
|
## Access Token vs Refresh Token
|
2026-07-13 10:08:46 +08:00
|
|
|
|
|
2026-07-13 11:11:37 +08:00
|
|
|
|
授权服务器通常返回两个 Token:
|
2026-07-13 10:08:46 +08:00
|
|
|
|
|
|
|
|
|
|
```mermaid
|
|
|
|
|
|
graph LR
|
2026-07-13 11:11:37 +08:00
|
|
|
|
A[用户授权] --> B[授权服务器]
|
|
|
|
|
|
B -->|颁发| C[Access Token]
|
|
|
|
|
|
B -->|颁发| D[Refresh Token]
|
|
|
|
|
|
C -->|有效期短<br/>5-30 分钟| E[调用 API]
|
|
|
|
|
|
D -->|有效期长<br/>几天到几个月| F[换新的 Access Token]
|
|
|
|
|
|
F -.->|每次刷新,旧的 RT 作废| D
|
2026-07-13 10:08:46 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-07-13 11:11:37 +08:00
|
|
|
|
| Token | 用途 | 有效期 | 存储建议 |
|
|
|
|
|
|
|-------|------|--------|----------|
|
|
|
|
|
|
| **Access Token** | 调用 API 时放在 Header 里 | 短(5-30 分钟) | 内存中,或 HttpOnly Cookie |
|
|
|
|
|
|
| **Refresh Token** | Access Token 过期后用来续期 | 长(天 ~ 月) | 后端存储,或 HttpOnly Secure Cookie |
|
2026-07-13 10:08:46 +08:00
|
|
|
|
|
2026-07-13 11:11:37 +08:00
|
|
|
|
> [!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,不要用隐式模式。**
|
2026-07-13 10:08:46 +08:00
|
|
|
|
|
|
|
|
|
|
## Go 实现示例
|
|
|
|
|
|
|
2026-07-13 11:11:37 +08:00
|
|
|
|
使用标准库 `golang.org/x/oauth2` 实现完整的授权码流程:
|
2026-07-13 10:08:46 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
2026-07-13 11:11:37 +08:00
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"crypto/rand"
|
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
|
"golang.org/x/oauth2"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// 1. 配置 OAuth2 客户端(通常从环境变量或配置文件读取)
|
2026-07-13 10:08:46 +08:00
|
|
|
|
conf := &oauth2.Config{
|
|
|
|
|
|
ClientID: "your-client-id",
|
|
|
|
|
|
ClientSecret: "your-client-secret",
|
2026-07-13 11:11:37 +08:00
|
|
|
|
// scope:请求哪些权限。这里请求读取用户基本信息
|
|
|
|
|
|
Scopes: []string{"profile", "email"},
|
2026-07-13 10:08:46 +08:00
|
|
|
|
Endpoint: oauth2.Endpoint{
|
|
|
|
|
|
AuthURL: "https://idp.example.com/oauth/authorize",
|
|
|
|
|
|
TokenURL: "https://idp.example.com/oauth/token",
|
|
|
|
|
|
},
|
2026-07-13 11:11:37 +08:00
|
|
|
|
// 授权完成后,IdP 会把浏览器重定向到这个地址
|
2026-07-13 10:08:46 +08:00
|
|
|
|
RedirectURL: "https://app.example.com/callback",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 11:11:37 +08:00
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
2026-07-13 10:08:46 +08:00
|
|
|
|
|
2026-07-13 11:11:37 +08:00
|
|
|
|
// 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()
|
|
|
|
|
|
// ... 解析用户信息 ...
|
2026-07-13 10:08:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 11:11:37 +08:00
|
|
|
|
// 辅助函数:生成随机 state
|
|
|
|
|
|
func generateRandomState() string {
|
|
|
|
|
|
b := make([]byte, 16)
|
|
|
|
|
|
rand.Read(b)
|
|
|
|
|
|
return hex.EncodeToString(b)
|
|
|
|
|
|
}
|
2026-07-13 10:08:46 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## 常见陷阱与最佳实践
|
|
|
|
|
|
|
2026-07-13 11:11:37 +08:00
|
|
|
|
### 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 必须严格匹配
|
2026-07-13 10:08:46 +08:00
|
|
|
|
|
2026-07-13 11:11:37 +08:00
|
|
|
|
授权服务器对 `redirect_uri` 的校验是**精确匹配**——不支持通配符,不支持路径前缀。这是防止 Token 被发送到攻击者控制的地址。
|
2026-07-13 10:08:46 +08:00
|
|
|
|
|
2026-07-13 11:11:37 +08:00
|
|
|
|
### scope 最小化原则
|
2026-07-13 10:08:46 +08:00
|
|
|
|
|
2026-07-13 11:11:37 +08:00
|
|
|
|
只请求你真正需要的权限。用户在授权页面看到的权限列表越少,越愿意点击「允许」。`scope: "all"` 是最差的做法。
|