Files

10 KiB
Raw Permalink Blame History

tags, create time
tags create time
sso
oauth2
auth
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. 返回日历事件

为什么这么绕?关键设计:

  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:

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" 是最差的做法。