2740 字
约 9 分钟
15
S27-X12 · OAuth2 授权码流程:四步到底在换什么

接第三方登录、接企业微信、接云盘授权,绕不开 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,纵深防御没有额外成本。


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. 自测

  1. 四个角色分别对应"用 GitHub 登录"里的谁?
  2. 为什么 access_token 不能直接回调给浏览器,而要绕一个 code?
  3. state 与 PKCE 分别防哪种攻击,能否互相替代?
  4. 授权服务器返回的 scope 比请求的少,代码该怎么处理?
  5. invalid_grant 出现在刷新阶段意味着什么,需要设计哪条兜底路径?
  6. 什么情况下 id_token 校验只看签名是不够的?
S27-X12 · OAuth2 授权码流程:四步到底在换什么
https://www.clxhxhhr.top/posts/5790/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。