"接一个第三方登录"听起来是加三个回调,实际做起来总在几个地方翻车: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. 自测
- 公共客户端为什么必须用 PKCE?
code_verifier存在哪里? state防的是什么攻击?和 PKCE 防的是同一个吗?redirect_uri的匹配规则是精确匹配还是前缀匹配?- 只验签不验
aud会导致什么问题? - refresh token rotation 下并发刷新为什么会把用户登出?
- 为什么
id_token不能拿去访问资源服务器?