--- 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、
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,
不经过浏览器 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 -->|有效期短
5-30 分钟| E[调用 API] D -->|有效期长
几天到几个月| 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"` 是最差的做法。