接第三方登录、接企业微信、接云盘授权,绕不开 OAuth2。难的不是把参数拼对,而是想清楚每一次 HTTP 交互交换了什么。参数记错能查文档,语义理解错了,后面接 OIDC、接刷新、接多租户会一路歪下去。
下面按四步走一遍,每步说清"谁给谁什么、为什么必须这样"。
1. 核心问题与四个角色
OAuth2 要解决的是一个很具体的矛盾:A 应用想读写用户在 B 平台上的资源,但用户不该把密码交给 A。
| 角色 | 英文 | 在"用 GitHub 登录博客后台"里的对应物 |
|---|---|---|
| 资源所有者 | Resource Owner | 你,拥有 GitHub 账号的人 |
| 客户端 | Client | 博客后台,想读你的 GitHub 资料 |
| 授权服务器 | Authorization Server | GitHub 的授权端点与令牌端点 |
| 资源服务器 | Resource Server | GitHub API,存放你的仓库与资料 |
密码只交给授权服务器(在 GitHub 自己的页面上输),客户端全程拿不到——这是 OAuth2 全部设计的目的。它不是认证协议:授权"客户端能做什么",不问"你是谁",第 7 节展开。
2. 四步:每一步换的是什么
| 步 | 谁 → 谁 | 交换物 | 走什么通道 |
|---|---|---|---|
| 1 | 客户端 → 浏览器 | 重定向到授权端点,带 client_id、redirect_uri、scope、state |
前端跳转(地址栏可见) |
| 2 | 授权服务器 → 浏览器 → 客户端 | 用户同意后,把 code 回调到 redirect_uri |
前端跳转(地址栏可见) |
| 3 | 客户端 → 授权服务器 | 用 code + client_secret 换 access_token |
后端直连(浏览器看不到) |
| 4 | 客户端 → 资源服务器 | 带 Authorization: Bearer <token> 取资源 |
后端或前端均可 |
# 第 1 步:把用户送到授权端点
https://auth.example.com/authorize
?response_type=code
&client_id=blog-admin
&redirect_uri=https://blog.example.com/oauth/callback
&scope=read:user repo
&state=7f3a9c1e5b
# 第 3 步:后端用 code 换 token(这一步永远发生在服务端)
curl -s -X POST https://auth.example.com/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d grant_type=authorization_code \
-d code=RETURNED_CODE \
-d redirect_uri=https://blog.example.com/oauth/callback \
-d client_id=blog-admin \
-d client_secret=CLIENT_SECRET
响应里典型字段:
| 字段 | 含义 | 注意 |
|---|---|---|
access_token |
访问令牌 | 只用于调资源服务器,不是身份证明 |
token_type |
通常是 Bearer |
决定请求头格式 |
expires_in |
有效秒数 | 相对时间,别存成绝对时间不换算 |
refresh_token |
续期凭证 | 未必每次都发,取决于 scope 与客户端类型 |
scope |
实际授予的范围 | 可能小于请求值,必须以返回值落库 |
3. 为什么需要"授权码"这个中间物
如果没有第 2 步的 code,让授权服务器直接把 access_token 回调给浏览器,会怎样?
| 风险 | 直接回 token | 走 code |
|---|---|---|
| token 出现在地址栏、浏览器历史 | 会,且可能被 Referer 带出去 | 只有一次性 code 出现 |
| token 落进前端内存/日志 | 前端要处理它 | 前端不接触 token |
| 客户端身份验证 | 无法验证 | 第 3 步用 client_secret 验证(机密客户端) |
| token 被旁观者截获 | 直接可用 | code 一次性、短命,且绑定 redirect_uri |
code 的本质是一张一次性兑换券:它本身不能访问资源,只能在服务端配合客户端凭证换成 token,换完即失效——"长期凭证过不安全通道"由此降级为"一次性票据过不安全通道"。
4. state 与 PKCE 各自防什么
两个参数经常被当成"可选项",但它们防的是不同攻击。
| 参数 | 防什么 | 攻击场景 | 校验方式 |
|---|---|---|---|
state |
CSRF(跨站请求伪造登录) | 攻击者把自己的授权码塞进受害者的回调 | 会话里存随机值,回调时逐字比对 |
code_verifier / code_challenge(PKCE) |
授权码拦截 | 公共客户端(SPA、App)无法保管 client_secret,code 被截获即可兑换 |
第 3 步带上原始 verifier,服务端比对 hash |
// state:发起时生成,放会话,回调时比对
String state = UUID.randomUUID().toString().replace("-", "");
session.setAttribute("oauth_state", state);
String url = authorizeUrl + "&state=" + state;
// 回调里:不一致就直接拒绝,不要"容错"
String expected = (String) session.getAttribute("oauth_state");
if (!MessageDigest.isEqual(expected.getBytes(UTF_8), actualState.getBytes(UTF_8))) {
throw new IllegalStateException("state mismatch");
}
# PKCE:verifier 是 43~128 位随机串,challenge 是其 SHA-256 的 base64url
verifier=$(openssl rand -base64 64 | tr -d '=+/\n' | cut -c1-64)
challenge=$(printf '%s' "$verifier" | openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '=')
echo "code_challenge=$challenge&code_challenge_method=S256"
state 不等于 PKCE:前者防"别人的 code 进到我的会话",后者防"我的 code 被别人拿走"。机密客户端也建议同时用 state 与 PKCE,纵深防御没有额外成本。
5. scope 与 consent
scope 是"客户端请求的权限范围",consent 是"用户看到并同意的那个页面"。三件事必须分清:
| 概念 | 谁决定 | 典型问题 |
|---|---|---|
请求的 scope |
客户端在授权请求里声明 | 一次要太多,用户直接关页面 |
授予的 scope |
用户在 consent 页勾选 | 可能少于请求,代码必须容忍 |
令牌的 scope |
授权服务器写进 token | 资源服务器按它做鉴权 |
两条规矩:请求最小集合(缺了再加),落库存实际授予值。很多"权限莫名不够"的 bug,根源就是把请求的 scope 当授予值存了。
6. token 类型:access 与 refresh 的生命周期差异
| 维度 | access_token | refresh_token |
|---|---|---|
| 生命周期 | 短(分钟到小时级) | 长(天到月级),按当前提供方策略核对 |
| 用途 | 调资源服务器 | 换新的 access_token |
| 是否可撤销 | 可,但依赖实现 | 可,撤销即整条链失效 |
| 是否每次下发 | 必然 | 未必(受 scope、offline_access 等控制) |
| 存放位置 | 服务端内存/缓存,短存 | 服务端持久化,加密存储 |
| 泄露后果 | 短期冒用 | 长期冒用,必须靠轮换兜底 |
# 刷新:grant_type 换成 refresh_token
curl -s -X POST https://auth.example.com/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d grant_type=refresh_token \
-d refresh_token=STORED_REFRESH \
-d client_id=blog-admin -d client_secret=CLIENT_SECRET
刷新失败(invalid_grant)意味着链条断了:refresh 过期、被撤销,或轮换后旧值作废。代码里必须有明确的"刷新失败 → 重新走授权"路径,否则用户会卡在"看着已登录,什么都点不动"。
7. OIDC 在 OAuth2 之上加了什么
OAuth2 只回答"这个令牌能访问什么",不回答"这个人是谁"。OIDC 补的就是后者。
| 项目 | OAuth2 | OIDC |
|---|---|---|
| 目标 | 授权(delegated access) | 认证(用户身份) |
| 关键令牌 | access_token |
额外一个 id_token(JWT) |
| 用户信息 | 没有标准位置 | id_token 的 claims,或 userinfo 端点 |
| 标准 scope | 业务自定义 | openid(必须)+ profile、email |
| 校验要点 | 有效期、scope | 签名、iss、aud、exp、nonce |
// id_token 校验的关键项,缺一项都等于没校验
// 1) 用 JWKS 验签(kid 匹配)
// 2) iss == 预期 issuer
// 3) aud 包含自己的 client_id
// 4) exp / iat 在允许窗口内
// 5) nonce 与发起时存的值一致
最常见的误用是"拿 access_token 调 userinfo 再把返回 id 当登录身份"——纯 OAuth2 提供方上这未必给出稳定身份。要身份就上 OIDC,别用资源接口反推。
8. 客户端类型与流程选择
| 客户端类型 | 能否保管 client_secret |
推荐流程 |
|---|---|---|
| 机密客户端(后端 Web 应用) | 能 | 授权码 + PKCE |
| 公共客户端(SPA、移动 App、桌面) | 不能 | 授权码 + PKCE(必须) |
| 无浏览器的服务间调用 | 能(用服务账号) | 客户端凭证(client credentials) |
| 脚本/CLI 代表用户操作 | 不能 | 设备码(device code)或授权码 + 本地回调 |
隐式流程(response_type=token)已经不属于推荐做法:token 直接出现在 URL 片段里,无法绑定客户端身份。旧系统对接时可能仍会遇到,按当前版本核对提供方是否还支持。
9. 排错:七个高频坑
| 现象 | 原因 | 处理 |
|---|---|---|
回调报 redirect_uri_mismatch |
注册值与请求值不完全一致(尾斜杠、http/https、端口) | 逐字符比对;不要在服务端"自动补斜杠" |
换 token 报 invalid_grant |
code 已用过、过期,或 redirect_uri 与第 1 步不一致 |
code 一次性,重试必须重新走授权 |
| 用户登录后立刻又要求登录 | 把 access_token 当身份,token 过期即"掉登录" | 用 OIDC 的 id_token 或自建会话建立登录态 |
| 刷新偶尔成功偶尔失败 | 多实例并发刷新,refresh 轮换导致互相作废 | 刷新加分布式锁,或集中到单一刷新服务 |
| 拿 token 调接口 401 | scope 不足,或令牌被放在 query 参数里 |
用 Authorization: Bearer,检查授予的 scope |
前端 SPA 里存了 client_secret |
把机密客户端当公共客户端用 | 公共客户端只用 PKCE,secret 不下发 |
10. 边界:什么时候不该用 OAuth2
| 场景 | 更合适 | 理由 |
|---|---|---|
| 只要"用第三方账号登录" | OIDC | 拿身份而不是拿资源权限 |
| 同一个系统内部的单点登录 | 企业 IdP + SAML/OIDC | OAuth2 授权模型对内部 SSO 是绕路 |
| 服务间可信调用 | mTLS / 内部令牌 | 没有"用户授权"这一环 |
| 只需要校验 API Key | 静态密钥 + 轮换 | 引入授权服务器成本过高 |
一句话:OAuth2 是"代客访问"的协议,不是"证明你是谁"的协议。 需要身份时,用它上面的 OIDC。
11. 自测
- 四个角色分别对应"用 GitHub 登录"里的谁?
- 为什么 access_token 不能直接回调给浏览器,而要绕一个
code? state与 PKCE 分别防哪种攻击,能否互相替代?- 授权服务器返回的
scope比请求的少,代码该怎么处理? invalid_grant出现在刷新阶段意味着什么,需要设计哪条兜底路径?- 什么情况下
id_token校验只看签名是不够的?