"用户用 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