2227 字
约 7 分钟
14
S07-X14 · OAuth2 与 OIDC:授权码 + PKCE 到令牌校验的完整链路

"接一个第三方登录"听起来是加三个回调,实际做起来总在几个地方翻车:redirect_uri 少个斜杠被拒、state 没校验被中间人利用、拿到的 token 不知道该怎么验、refresh 换新 token 后老 token 还在用。

这些不是实现细节问题,是协议里每个参数都有明确的安全职责,跳过它就等于把入口敞开。下面按一次真实授权的顺序走:发起 → 回调 → 换 token → 校验 → 刷新。


1. 四种角色与四种授权类型

角色 职责
Resource Owner 用户本人
Client 你的应用
Authorization Server 发 token 的一方(AS)
Resource Server 认 token 的一方(RS)

授权类型的选择取决于客户端身份,不是取决于方便:

grant type 适用客户端 是否需要 secret 现状
authorization_code 有后端的 Web 应用 是(PKCE 可选) 默认首选
authorization_code + PKCE SPA / 移动端 / 桌面端 否 公共客户端唯一正解
client_credentials 服务间调用 是 无用户参与
refresh_token 所有需要续期的场景 视客户端 换取新 access token
implicit — 否 已废弃,token 会进 URL 片段
password(ROPC) — 是 不推荐,凭据直传 AS

implicit 与 password 在 OAuth 2.1 草案里已被移除。新项目选到这两个,说明参考的是十年前的文章。


2. 授权码 + PKCE:一次完整的授权请求

GET /authorize
  ?response_type=code
  &client_id=web-console
  &redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
  &scope=openid%20profile%20email
  &state=8f3c1a9b7e2d4f60
  &code_challenge=Bkd4X2m9...zQ
  &code_challenge_method=S256
参数 必须 校验方 不校验的后果
state 是 Client CSRF:攻击者用自己的 code 换掉用户的会话
redirect_uri 是 AS 精确匹配 授权码被劫持到攻击者域名
code_challenge 公共客户端必须 AS 授权码被拦截后可被直接兑换
scope 是 AS + RS 申请过宽权限
nonce OIDC 建议 Client(校验 id_token) 重放 id_token

code_challenge 的算法固定:BASE64URL(SHA256(code_verifier)),code_verifier 是 43–128 位的随机串,只存在客户端内存里,换 token 时才带上。

// 生成并暂存 verifier,回调时用它换 token
SecureRandom rnd = new SecureRandom();
byte[] raw = new byte[32];
rnd.nextBytes(raw);
String verifier = Base64.getUrlEncoder().withoutPadding().encodeToString(raw);
String challenge = Base64.getUrlEncoder().withoutPadding()
        .encodeToString(MessageDigest.getInstance("SHA-256")
                .digest(verifier.getBytes(StandardCharsets.US_ASCII)));
session.setAttribute("pkce_verifier", verifier);
session.setAttribute("oauth_state", state);

redirect_uri 的匹配规则值得单独强调:OAuth 2.1 要求精确字符串匹配,不做前缀、不做通配。带 ? 查询串的注册值意味着每次都得完全一致,一个斜杠差异就是 invalid_request。


3. 回调与换 token

POST /token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&client_id=web-console
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

回调端点必须先做两件校验,再换 token:

校验项 比较对象 失败处理
state session 里存的 oauth_state 直接拒绝,不换 token
code 只兑换一次 — 重复兑换必然失败(AS 侧一次性)
@GetMapping("/callback")
public String callback(@RequestParam String code,
                       @RequestParam String state,
                       HttpSession session) {
    String expect = (String) session.getAttribute("oauth_state");
    if (!MessageDigest.isEqual(expect.getBytes(), state.getBytes())) {
        throw new IllegalStateException("state mismatch");
    }
    // 用 code + verifier 换 token,注意 verifier 只在此处取一次
    TokenResponse tr = tokenClient.exchange(code, (String) session.getAttribute("pkce_verifier"));
    session.removeAttribute("pkce_verifier");
    return "redirect:/home";
}

MessageDigest.isEqual 而不是 equals:前者是定时安全比较,避免逐字节比较带来的时序差异。

换回来的响应有三种载荷形态,别搞混:

形态 内容 校验方式
access_token(不透明) 随机串 调 AS 的 introspect 端点
access_token(JWT) 自包含 claims 本地验签 + 校验 claims
id_token(OIDC) JWT,身份信息 必须验签,不能当 access_token 用

4. 令牌校验:JWT 的每一段都要查

// 用 JWKS 验签,不要硬编码公钥
JWKSource<SecurityContext> src = new RemoteJWKSet<>(new URL(asBase + "/.well-known/jwks.json"));
ConfigurableJWTProcessor<SecurityContext> proc = new DefaultJWTProcessor<>();
proc.setJWSKeySelector(new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, src));
proc.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(
        new JWTClaimsSet.Builder().issuer("https://as.example.com").build(),
        Set.of("sub", "exp", "iat", "aud")));
JWTClaimsSet claims = proc.process(token, null);
claim 校验什么 漏掉的攻击面
iss 必须等于预期 AS 其它 AS 签的 token 被接受
aud 必须包含本服务标识 给 A 服务的 token 拿去调 B
exp / nbf 时间窗 过期 token 永久有效
sub 用户唯一标识 身份混淆
alg 必须白名单,禁用 none alg 混淆攻击
nonce(id_token) 与请求时一致 重放

前三条是最常被跳过的。只验签不验 claims 等于只确认了"这张票是真的",没确认"这张票是给谁的、还有效吗"。

密钥轮换是另一个坑:JWKS 里的 kid 会变,RemoteJWKSet 会缓存并自动刷新,但硬编码公钥的实现会在轮换后全线 401。缓存 max-age 通常按 AS 响应的 Cache-Control 走。


5. Refresh 与 token 轮换

策略 行为 安全性
长期 refresh token 一直用到过期 泄漏即长期可用
每次刷新都换新(rotation) 旧的立即失效 推荐
rotation + 重用检测 旧 token 被用即判定泄漏,撤销整个家族 最强
POST /token
grant_type=refresh_token
&refresh_token=vF9d2...
&client_id=web-console

rotation 实现的注意点:并发刷新会撞车。两个请求同时拿旧 refresh token 去换,先到的成功、后到的触发"重用检测"导致整条链被撤销——用户被莫名登出。常见处理是单个会话串行化刷新(分布式锁或用同一实例内的信号量),并在客户端做"刷新中"的合并等待。

存储位置 XSS 风险 CSRF 风险 适用
HttpOnly cookie + SameSite=Lax 低 需 CSRF token 有后端的 Web
内存变量(SPA) 中(页面脚本可读) 无 SPA + PKCE
localStorage 高 无 不推荐
后端 session 低 需 CSRF token BFF 模式

6. OIDC 在 OAuth2 上加了什么

OAuth2 只解决"授权",没规定"你是谁"。OIDC 补了三样东西:

新增 作用
scope=openid 声明这是 OIDC 请求
id_token 身份 JWT,含 sub/iss/aud/nonce
/.well-known/openid-configuration 发现文档,给出各端点与 JWKS 地址
# 发现文档是所有集成的起点,比自己拼端点可靠
curl -s https://as.example.com/.well-known/openid-configuration | jq '{
  issuer, authorization_endpoint, token_endpoint, jwks_uri,
  code_challenge_methods_supported, id_token_signing_alg_values_supported
}'

id_token 不能当 access_token 用:它的 aud 是你的 client_id,RS 校验 aud 时必然失败。反过来,access_token 也不能当身份用——它可能是不透明串,且内容由 AS 决定,随时可能改。


7. 排错:七种典型失败

错误 真实原因 处理
invalid_request: redirect_uri mismatch 注册值与请求值不精确相等(斜杠/端口/协议) 逐字符比对,注意末尾 /
invalid_grant code 已用过、过期,或 code_verifier 不匹配 确认只兑换一次、verifier 未被重算
invalid_client 公共客户端却发了 secret,或 secret 过期 公共客户端不要发 client_secret
401 且 WWW-Authenticate 指明 invalid_token aud 不含本服务、或 exp 已过 打印 claims 逐条核对,别只看签名
密钥轮换后全线 401 硬编码公钥,未走 JWKS 改 RemoteJWKSet 并允许缓存刷新
用户被莫名登出 并发刷新触发 rotation 重用检测 串行化刷新
state mismatch 偶发 多标签页共享同一 session 键 用每请求独立的 state 存储

8. 边界:什么时候不该用这套流程

场景 更合适 理由
纯内部服务间调用 client_credentials 或 mTLS 没有用户,不需要授权码
单页应用 + 有 BFF BFF 模式(后端持 token) 浏览器完全不接触 token
需要设备码输入 Device Authorization Grant 电视/CLI 场景无浏览器
已有企业 IdP SAML 或 OIDC 联邦 别自建账号体系
只需要"登录"不要"授权" 直接用 OIDC,别只做 OAuth2 少了身份层

一句话收束:OAuth2 是委托授权协议,不是认证协议;身份必须由 OIDC 的 id_token 提供,且每个 claim 都要验。


9. 自测

  1. 公共客户端为什么必须用 PKCE?code_verifier 存在哪里?
  2. state 防的是什么攻击?和 PKCE 防的是同一个吗?
  3. redirect_uri 的匹配规则是精确匹配还是前缀匹配?
  4. 只验签不验 aud 会导致什么问题?
  5. refresh token rotation 下并发刷新为什么会把用户登出?
  6. 为什么 id_token 不能拿去访问资源服务器?
S07-X14 · OAuth2 与 OIDC:授权码 + PKCE 到令牌校验的完整链路
https://www.clxhxhhr.top/posts/6027/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。