OAuth2 与 OIDC 完全指南:从授权码模式到 PKCE 的安全实战

"用户用 GitHub 登录我的网站,这是怎么工作的?""我能不能自己实现微信扫码登录?" —— 这类需求背后都是 OAuth2 / JWT)表达"谁登录了"。">OIDC。看似简单的"第三方登录",实际涉及一套精密的授权流程和安全设计。这篇文章把 OAuth2 / OIDC 从概念讲到完整时序图,覆盖所有 grant type 和真实场景。

OAuth2 解决什么问题

核心场景:用户在 A 网站,想让 A 访问他在 B 网站的资源,但又不愿意把 B 的密码给 A。具体例子:

  • Notion 想读取你 Google Drive 的文件 —— 你不能把 Google 密码给 Notion。
  • 第三方掘金分析工具想读你 GitHub 的 repo 信息 —— 不能给 GitHub 密码。
  • 小程序想用微信账号登录 —— 不能给小程序你的微信密码。

OAuth2 引入"授权服务器"作为中间人。流程:用户在授权服务器(GitHub / 微信 / Google)登录,授予 A 网站有限权限(读 repo 但不能写,只能读 Drive 不能改),A 网站拿到一个 token 去访问 B 的 API。

四个角色

  • Resource Owner:用户。
  • Client:第三方应用(A 网站)。
  • Authorization Server:发 token 的服务器(GitHub / 微信)。
  • Resource Server:存资源的 API 服务器(GitHub API)。

授权码模式(Authorization Code Flow)

最经典、最安全的流程,用于 Web 应用:

1. 用户在 Client(A)上点"用 GitHub 登录"

2. Client 把用户浏览器重定向到授权服务器(GitHub):
   GET https://github.com/login/oauth/authorize
       ?client_id=YOUR_CLIENT_ID
       &redirect_uri=https://yourapp.com/callback
       &scope=read:user repo
       &state=randomCsrfToken
       &response_type=code

3. GitHub 显示登录页 -> 用户登录 -> GitHub 显示"是否授权 Client 读取你的信息?"

4. 用户同意后,GitHub 把浏览器重定向回 Client:
   https://yourapp.com/callback?code=ABC123&state=randomCsrfToken

5. Client 后端校验 state(防 CSRF),然后用 code 去换 token:
   POST https://github.com/login/oauth/access_token
       client_id=YOUR_CLIENT_ID
       client_secret=YOUR_CLIENT_SECRET    # 后端调,前端不能暴露
       code=ABC123
       redirect_uri=https://yourapp.com/callback

6. GitHub 返回:
   { "access_token": "...", "refresh_token": "...", "expires_in": 3600 }

7. Client 用 access_token 调 GitHub API:
   GET https://api.github.com/user
   Authorization: Bearer ...

8. token 过期前,Client 可以用 refresh_token 续:
   POST https://github.com/login/oauth/access_token
       grant_type=refresh_token
       refresh_token=...

为什么要走 code 这一步

"能不能直接返回 token 给前端?" —— 不行,因为浏览器地址栏里的内容可能被 referrer、浏览器历史泄漏。code 是一次性的、短时效的,泄漏了立刻拿去换 token 才有用 —— 而换 token 必须 client_secret,只有后端有。这就是为什么有 code 中转。

PKCE:公开客户端的安全方案

移动 App / SPA(单页应用)不能安全保存 client_secret(代码在用户设备上,可以反编译)。PKCE(Proof Key for Code Exchange)解决这点:

1. Client 生成随机 code_verifier(43~128 字符)
   code_challenge = base64url(sha256(code_verifier))

2. 授权请求带 code_challenge:
   GET /authorize?code_challenge=XXX&code_challenge_method=S256&...

3. 用 code 换 token 时,提供 code_verifier:
   POST /token?code=YYY&code_verifier=ZZZ

4. 授权服务器验证:sha256(code_verifier) == 存储的 code_challenge
   通过才发 token

PKCE 让"code 拦截"攻击失效 —— 即使别人拿到 code,没有 code_verifier 也换不到 token。2025 年起所有 OAuth2 客户端(包括 Web 后端)都应该用 PKCE,这是 OAuth 2.1 草案的核心变化。

客户端凭证模式(Client Credentials)

没有用户,机器对机器(M2M)调用:

POST /token
grant_type=client_credentials
client_id=...
client_secret=...
scope=api:read

# 直接返回 access_token,没有 refresh_token
# 适合后端服务调内部 API、API 网关到下游

设备码模式(Device Code Flow)

智能电视、CLI 工具登录(没有方便输入密码的环境):

1. 设备申请 device_code 和 user_code:
   POST /device/code
   -> { device_code: "abc...", user_code: "WDJB-MJHT", verification_uri: "..." }

2. 设备显示:"请在浏览器打开 https://x.com/device,输入 WDJB-MJHT"
   设备同时开始轮询 token 端点

3. 用户在另一台设备(手机)打开 verification_uri,输入 user_code,登录授权

4. 设备的轮询拿到 token,登录完成

# GitHub CLI 登录、Twitch 在电视上登录都用这个

OIDC:基于 OAuth2 的身份认证

OAuth2 是"授权" —— 给 Client 权限访问资源。OIDC(OpenID Connect)在 OAuth2 之上加了"身份认证"层:让 Client 知道"用户是谁"。

OIDC 在 token 响应里多了一个 id_token(JWT 格式):

{
  "access_token": "...",       # 调 API 用
  "id_token": "eyJhbGc...",    # 身份信息,JWT
  "refresh_token": "...",
  "expires_in": 3600,
  "token_type": "Bearer"
}

# 解码 id_token(JWT)
{
  "iss": "https://accounts.google.com",   # 谁颁发的
  "sub": "12345",                          # 用户 ID
  "aud": "your_client_id",                 # 给谁用的
  "exp": 1234567890,                       # 过期时间
  "email": "user@example.com",
  "name": "Mores",
  "picture": "https://...",
  ...
}

# Client 用 JWKS(JSON Web Key Set)验证 id_token 签名,确认是 Google 真发的

有了 OIDC,登录变得标准化 —— "用 Google / Microsoft / Apple 登录"按钮背后都是 OIDC。企业内的 SSO(单点登录)、Auth0 / Okta / Keycloak 等身份中间件,核心都是 OIDC。

Scope:权限的边界

scope=read:user            # 读基本信息
scope=read:user repo       # 读基本信息 + repo
scope=write:repo           # 写 repo

# 关键:用户授权时能看到 scope,知道自己授予了什么权限
# Client 不能要求过度的 scope —— 用户会拒绝,且容易被认为不安全

scope 设计是 OAuth 应用安全的重要部分。最小权限原则:Client 只申请确实需要的 scope,定期审查。

实现 OAuth2 Server 的工程组件

不要自己从零写 OAuth Server —— 它的安全性要求极高,bug 容易酿成事故。用成熟方案:

  • Keycloak(Red Hat):开源 IAM,完整 OAuth2 + OIDC + SAML 支持。最常用的自部署方案。
  • Auth0:SaaS,易上手,免费版够小项目。
  • Okta:企业级 IAM。
  • Spring Authorization Server:Java 项目自部署的好选择。
  • Hydra(Ory):云原生 OAuth Server,Go 实现。

常见坑

坑 1:state 参数没用上。 state 是防 CSRF 的关键。客户端发起授权时生成随机 state,callback 校验。漏了 state 等于敞开 CSRF。

坑 2:redirect_uri 验证不严格。 授权服务器必须严格匹配预注册的 redirect_uri(精确字符串,不是 prefix)。否则攻击者构造 redirect_uri=evil.com,把用户授权后的 code 引到自己服务器,拿去换 token。

坑 3:把 access_token 放 URL。 URL 会进浏览器历史、referer、日志 —— token 泄漏。永远用 Authorization header 传 token。

坑 4:把 client_secret 写进前端代码。 客户端 / 移动 App 不能有 secret,用 PKCE 替代。

坑 5:token 不验证 audience。 Resource Server 拿到 access_token 必须验 aud 字段是不是自己,否则别的服务的 token 能用来访问自己。

OAuth 2.1:汇总最佳实践

OAuth 2.1 把过去几年的安全实践标准化:

  • 强制 PKCE(所有客户端,不只是公开客户端)。
  • 禁用 implicit flow(token 直接返回浏览器)。
  • 禁用 password grant(用户名密码直接交给 Client)。
  • refresh_token 必须 rotation(用一次换新的)。
  • 严格的 redirect_uri 匹配。

新项目直接按 OAuth 2.1 设计,旧项目逐步迁移。

SSO 的工程实现

SSO(Single Sign-On)让用户在一个地方登录,自动访问多个内部应用 —— 不必每个应用重新输密码。OIDC 是实现 SSO 的现代标准:

1. 用户访问应用 A,未登录 -> A 重定向到 IdP(身份提供商)
2. 用户在 IdP 登录(可能已经有 session)
3. IdP 给 A 发 id_token + access_token
4. 用户访问应用 B,未登录 -> B 重定向到 IdP
5. IdP 看到 session 还在 -> 直接给 B 发 token,无需再次登录

企业内常见 IdP:Keycloak、Auth0、Okta、Azure AD、Google Workspace。一旦搭起来,新增内部应用接入只需几个配置,大幅减少账号管理负担。

Token 存储的安全考量

前端拿到 access_token 后存哪里?三种选择各有取舍:

1. localStorage:
   + 简单,JS 直接读
   - XSS 攻击能偷 token

2. HttpOnly Cookie:
   + JS 读不到,XSS 偷不走
   - 受 CSRF 影响(需要 SameSite + CSRF token)

3. 内存(JS 变量):
   + XSS 难偷(每次刷新就丢)
   - 刷新页面要重新登录

业界共识:
   - access_token 放内存(短期)
   - refresh_token 放 HttpOnly Cookie(长期)
   - 关键操作要二次确认

授权码 + PKCE 完整代码

// 前端发起授权
const codeVerifier = generateRandomString(64);
const codeChallenge = base64url(sha256(codeVerifier));
sessionStorage.setItem('code_verifier', codeVerifier);

const state = generateRandomString(32);
sessionStorage.setItem('state', state);

window.location.href = `https://auth.example.com/authorize?` + new URLSearchParams({
    response_type: 'code',
    client_id: CLIENT_ID,
    redirect_uri: window.location.origin + '/callback',
    scope: 'openid profile email',
    state: state,
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
});

// 回调处理
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const returnedState = params.get('state');
const savedState = sessionStorage.getItem('state');
if (returnedState !== savedState) throw new Error('CSRF detected');

const codeVerifier = sessionStorage.getItem('code_verifier');
const tokens = await fetch('https://auth.example.com/token', {
    method: 'POST',
    body: new URLSearchParams({
        grant_type: 'authorization_code',
        code, code_verifier: codeVerifier,
        client_id: CLIENT_ID,
        redirect_uri: window.location.origin + '/callback',
    }),
}).then(r => r.json());

写在最后

OAuth2 看似只是"用第三方登录",实际是一套精密的安全协议。它的每一个设计(code 中转、state、PKCE、scope、audience)都对应一种现实威胁。理解这些威胁,你才能正确实现和评审 OAuth 集成 —— 而不是抄抄 SDK 就完事。所有涉及登录的 bug 中,OAuth 相关的危害最大,因为它直接关联用户身份。把这一篇内容理解透,你看任何 OAuth 集成的代码都能立刻识别"哪里漏了"。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Elasticsearch 完全指南:从倒排索引到集群部署的实战

2026-5-15 17:25:45

技术教程

JWT 完全指南:从结构到 RS256 与 Refresh Token 的生产实战

2026-5-15 17:25:46

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索