跳到主内容

OAuth2 授权码实战:从泄露拦截到 PKCE 与 Token 安全存储 ​

为什么第三方登录不能再用隐式授权 ​

很多团队第一次接入微信、GitHub、Google 等第三方登录时,会被 OAuth2 的多个授权模式绕晕。历史上前端单页应用常使用隐式授权,由开放平台直接把 Access Token 拼在重定向地址中返回。这个方案看似简单,却把 Token 暴露给浏览器历史、Referer、前端脚本和日志系统,一旦存在 XSS,攻击者就能直接冒用用户身份。

更稳妥的做法是使用 OAuth2 授权码模式:浏览器只拿到一次性、短生命周期的 authorization code,真正的 Access Token 由后端通过 Client Secret 向授权服务器交换。对于没有机密存储能力的移动端、SPA、桌面端,还必须叠加 PKCE,即 Proof Key for Code Exchange,防止授权码被截获后重新使用。

本文用 Go 实现一个可运行的第三方登录后端,重点覆盖:state 防 CSRF、PKCE 防授权码注入、Token 后端交换、Refresh Token 安全存储,以及登录态 Cookie 的正确设置。

OAuth2 授权码与 PKCE 流程对比 ​

标准授权码模式依赖 Client Secret,因此适合传统 Web 应用。PKCE 则在客户端启动登录前生成高随机 code_verifier,并把它的 SHA-256 摘要 code_challenge 发给授权服务器;回调时再提交原始 code_verifier。即使攻击者拿到授权码,由于不知道 verifier,也无法完成 Token 交换。

用户浏览器发起登录业务后端生成 state/verifier授权服务器校验 challenge回调业务后端校验 state 并换 Token返回 Access TokenRefresh Token 加密存储

方案选择上可以按客户端形态判断:服务端渲染应用使用授权码加 Client Secret;移动 App、桌面端和 SPA 使用授权码加 PKCE;微服务之间的机器调用应使用 client credentials,而不是让用户完成浏览器授权;任何场景都不应继续选择 implicit 模式。

核心配置与可运行 Go 示例 ​

下面示例使用 Go 1.22 标准库实现授权码登录。生产环境建议替换内存存储为 Redis,并给 Refresh Token 增加用户绑定、轮换、吊销和审计能力。

go
package main

import (
    "context"
    "crypto/aes"
    "crypto/cipher"
    "crypto/rand"
    "crypto/sha256"
    "encoding/base64"
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "net/url"
    "os"
    "sync"
    "time"
)

// OAuthConfig 保存第三方开放平台配置。
// RedirectURI 必须与平台后台登记值完全一致。
type OAuthConfig struct {
    AuthURL     string
    TokenURL    string
    ClientID    string
    ClientSecret string
    RedirectURI string
    Scopes      string
}

var cfg = OAuthConfig{
    AuthURL:      "https://example-oauth-server.com/authorize",
    TokenURL:     "https://example-oauth-server.com/oauth/token",
    ClientID:     os.Getenv("OAUTH_CLIENT_ID"),
    ClientSecret: os.Getenv("OAUTH_CLIENT_SECRET"),
    RedirectURI:  "https://api.your-domain.com/auth/callback",
    Scopes:       "openid profile email",
}

// LoginState 只在跳转期间短暂保存,设置 5 分钟过期。
type LoginState struct {
    CodeVerifier string
    Next         string
    ExpireAt     time.Time
}

var (
    stateStore = map[string]LoginState{}
    stateMu    sync.Mutex
)

func randomString(n int) string {
    b := make([]byte, n)
    if _, err := rand.Read(b); err != nil {
        panic(err)
    }
    return base64.RawURLEncoding.EncodeToString(b)
}

// S256Challenge 按 RFC 7636 生成 code_challenge。
func S256Challenge(verifier string) string {
    sum := sha256.Sum256([]byte(verifier))
    return base64.RawURLEncoding.EncodeToString(sum[:])
}

func writeSecureCookie(w http.ResponseWriter, name, value string, maxAge int) {
    http.SetCookie(w, &http.Cookie{
        Name:     name,
        Value:    value,
        Path:     "/",
        MaxAge:   maxAge,
        HttpOnly: true,                  // 禁止 JavaScript 读取
        Secure:   true,                  // 仅 HTTPS 传输
        SameSite: http.SameSiteLaxMode,  // 兼顾外部跳转回调
    })
}

// GET /auth/login:生成 state、verifier,并重定向到授权页。
func loginHandler(w http.ResponseWriter, r *http.Request) {
    state := randomString(32)
    verifier := randomString(32)
    challenge := S256Challenge(verifier)

    stateMu.Lock()
    stateStore[state] = LoginState{
        CodeVerifier: verifier,
        Next:         r.URL.Query().Get("next"),
        ExpireAt:     time.Now().Add(5 * time.Minute),
    }
    stateMu.Unlock()

    writeSecureCookie(w, "oauth_state", state, 300)

    u, _ := url.Parse(cfg.AuthURL)
    q := u.Query()
    q.Set("response_type", "code")
    q.Set("client_id", cfg.ClientID)
    q.Set("redirect_uri", cfg.RedirectURI)
    q.Set("scope", cfg.Scopes)
    q.Set("state", state)
    q.Set("code_challenge", challenge)
    q.Set("code_challenge_method", "S256")
    u.RawQuery = q.Encode()

    http.Redirect(w, r, u.String(), http.StatusFound)
}

type tokenResponse struct {
    AccessToken  string `json:"access_token"`
    RefreshToken string `json:"refresh_token"`
    TokenType    string `json:"token_type"`
    ExpiresIn    int    `json:"expires_in"`
    IDToken      string `json:"id_token"`
}

// GET /auth/callback:校验 state,使用 code 与 verifier 交换 Token。
func callbackHandler(client *http.Client) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        code := r.URL.Query().Get("code")
        state := r.URL.Query().Get("state")
        errMsg := r.URL.Query().Get("error")
        if code == "" || state == "" || errMsg != "" {
            http.Error(w, "授权请求被拒绝", http.StatusBadRequest)
            return
        }

        cookie, err := r.Cookie("oauth_state")
        if err != nil || cookie.Value != state {
            http.Error(w, "state 校验失败", http.StatusForbidden)
            return
        }

        stateMu.Lock()
        saved, ok := stateStore[state]
        delete(stateStore, state)
        stateMu.Unlock()

        if !ok || time.Now().After(saved.ExpireAt) {
            http.Error(w, "登录流程已过期", http.StatusUnauthorized)
            return
        }

        form := url.Values{}
        form.Set("grant_type", "authorization_code")
        form.Set("code", code)
        form.Set("redirect_uri", cfg.RedirectURI)
        form.Set("client_id", cfg.ClientID)
        form.Set("client_secret", cfg.ClientSecret)
        form.Set("code_verifier", saved.CodeVerifier)

        resp, err := client.PostForm(cfg.TokenURL, form)
        if err != nil {
            http.Error(w, "交换 Token 失败", http.StatusBadGateway)
            return
        }
        defer resp.Body.Close()

        if resp.StatusCode != http.StatusOK {
            http.Error(w, "授权服务器拒绝换 Token", http.StatusBadGateway)
            return
        }

        var token tokenResponse
        if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
            http.Error(w, "Token 响应格式错误", http.StatusBadGateway)
            return
        }

        // 生产环境:先校验 ID Token 的 issuer、audience、nonce、签名和过期时间,
        // 再将 Refresh Token 加密落库,Access Token 只保留在服务端缓存中。
        sessionID := randomString(32)
        encryptedRefresh, err := encryptAESGCM([]byte(token.RefreshToken), []byte(os.Getenv("TOKEN_ENC_KEY")))
        if err != nil {
            http.Error(w, "Token 加密失败", http.StatusInternalServerError)
            return
        }
        log.Printf"保存会话 %s,加密 Refresh Token:%s", sessionID, encryptedRefresh)

        writeSecureCookie(w, "sid", sessionID, 7*24*3600)
        http.Redirect(w, r, "/dashboard", http.StatusFound)
    }
}

// AES-GCM 用于加密数据库中的 Refresh Token,密钥由 KMS 管理。
func encryptAESGCM(plaintext, key []byte) (string, error) {
    block, err := aes.NewCipher(key)
    if err != nil {
        return "", err
    }
    gcm, err := cipher.NewGCM(block)
    if err != nil {
        return "", err
    }
    nonce := make([]byte, gcm.NonceSize())
    if _, err := rand.Read(nonce); err != nil {
        return "", err
    }
    sealed := gcm.Seal(nonce, nonce, plaintext, nil)
    return base64.RawURLEncoding.EncodeToString(sealed), nil
}

func main() {
    client := &http.Client{Timeout: 5 * time.Second}
    mux := http.NewServeMux()
    mux.HandleFunc("/auth/login", loginHandler)
    mux.HandleFunc("/auth/callback", callbackHandler(client))
    log.Fatal(http.ListenAndServeTLS(":8443", "cert.pem", "key.pem", mux))
}

代码中有两个安全边界必须坚持:第一,浏览器侧只保存会话 Cookie,不把第三方 Access Token、Refresh Token 下发给前端;第二,后端调用第三方接口时通过服务端会话找到短期 Access Token,过期后再用加密存储的 Refresh Token 轮换,不把长期凭证放进 JWT、localStorage 或 URL。

Token 应该放在哪里:服务端会话优于前端存储 ​

常见做法可以分成三类。localStorage 读写方便,但任何同源脚本都能访问,XSS 后 Token 会被直接偷走;普通内存变量刷新页面后丢失,也难以跨标签页共享;服务端会话配合 HttpOnly、Secure、SameSite Cookie,把凭证留在后端,前端只持有随请求自动携带的 sid,安全边界更清晰。

如果系统采用 JWT,也不建议把 Refresh Token 编进 JWT。Access Token 可短期缓存于 Redis,key 与 sessionID 关联并设置 TTL;Refresh Token 应加密存储,记录用户 ID、客户端 ID、设备信息、使用次数和吊销状态。刷新时执行轮换:旧 Refresh Token 立即失效,新 Refresh Token 重新加密入库。发现旧 Token 被重复使用时,可判定为泄露,吊销该用户或设备的整条授权链。

避坑总结:至少检查这 5 个高频问题 ​

  1. 回调成功但换 Token 报 invalid_grant:常见原因是 redirect_uri 与开放平台登记值不一致,或者代码里把 localhost、http、尾斜杠混用。修正方法是抽成统一配置,授权和换 Token 两个请求使用完全相同的 redirect_uri。

  2. PKCE 偶现 code_verifier invalid:很多人对 challenge 做了十六进制编码,或 Base64 末尾保留 padding。RFC 7636 要求 SHA-256 后使用无填充的 URL Safe Base64。修正方法是使用 base64.RawURLEncoding,并保证 verifier 长度在 43 到 128 字符之间。

  3. state 形同虚设导致登录 CSRF:只生成 state 却不校验,或把 state 永久存到服务端,都会留下风险。攻击者可诱导受害者完成攻击者的授权码回调,使业务账号绑定第三方账号。修正方法是 state 高随机、一次性、短 TTL,并同时对比 Cookie 与回调参数。

  4. 把 Token 放 localStorage 后被 XSS 拖走:现象通常是异常 IP 调用开放平台接口,用户退出登录后 Token 仍有效。修正方法是 Token 不下发浏览器,改用 HttpOnly Secure Cookie 中的 sid;同时配置 CSP、输出编码和可信来源脚本,降低 XSS 面。

  5. Refresh Token 明文入库或长期不轮换:数据库备份、慢查询日志、运维导出都可能泄露长期凭证。修正方法是使用 KMS 管理数据加密密钥,AES-GCM 加密 Refresh Token,并在每次刷新时轮换;对重复使用旧 Token 的行为触发吊销和告警。

OAuth2 第三方登录的关键不是把接口调通,而是把授权码、客户端、会话和 Token 的信任边界划清。授权码加 PKCE 解决浏览器与客户端不可信的问题,服务端会话与加密存储解决 Token 泄露后的影响半径。按这套方案落地,再配合 HTTPS、日志脱敏、异常刷新检测和最小权限 scope,才能把第三方登录做成真正可上线的安全能力。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。