Files
Qiniu/technical/sso/sso-oauth2.md
T

230 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [sso, oauth2, auth]
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 日历」这个场景来走一遍:
```mermaid
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. 返回日历事件
```
**为什么这么绕?关键设计:**
1. **步骤 2-7 走浏览器跳转**(用户能看到)—— 让用户亲自在 Google 页面登录和授权,密码不会经过第三方应用
2. **步骤 8-9 走后端直连**(用户看不到)—— `code` 换 `token` 的过程在后端完成,`token` 不会暴露在浏览器地址栏中
3. **`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:
```mermaid
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` 实现完整的授权码流程:
```go
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"` 是最差的做法。