外观
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 交换。
方案选择上可以按客户端形态判断:服务端渲染应用使用授权码加 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 个高频问题
回调成功但换 Token 报 invalid_grant:常见原因是 redirect_uri 与开放平台登记值不一致,或者代码里把 localhost、http、尾斜杠混用。修正方法是抽成统一配置,授权和换 Token 两个请求使用完全相同的 redirect_uri。
PKCE 偶现 code_verifier invalid:很多人对 challenge 做了十六进制编码,或 Base64 末尾保留 padding。RFC 7636 要求 SHA-256 后使用无填充的 URL Safe Base64。修正方法是使用
base64.RawURLEncoding,并保证 verifier 长度在 43 到 128 字符之间。state 形同虚设导致登录 CSRF:只生成 state 却不校验,或把 state 永久存到服务端,都会留下风险。攻击者可诱导受害者完成攻击者的授权码回调,使业务账号绑定第三方账号。修正方法是 state 高随机、一次性、短 TTL,并同时对比 Cookie 与回调参数。
把 Token 放 localStorage 后被 XSS 拖走:现象通常是异常 IP 调用开放平台接口,用户退出登录后 Token 仍有效。修正方法是 Token 不下发浏览器,改用 HttpOnly Secure Cookie 中的 sid;同时配置 CSP、输出编码和可信来源脚本,降低 XSS 面。
Refresh Token 明文入库或长期不轮换:数据库备份、慢查询日志、运维导出都可能泄露长期凭证。修正方法是使用 KMS 管理数据加密密钥,AES-GCM 加密 Refresh Token,并在每次刷新时轮换;对重复使用旧 Token 的行为触发吊销和告警。
OAuth2 第三方登录的关键不是把接口调通,而是把授权码、客户端、会话和 Token 的信任边界划清。授权码加 PKCE 解决浏览器与客户端不可信的问题,服务端会话与加密存储解决 Token 泄露后的影响半径。按这套方案落地,再配合 HTTPS、日志脱敏、异常刷新检测和最小权限 scope,才能把第三方登录做成真正可上线的安全能力。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。