📑 本页目录(点开跳转)
08d · 把登录外包出去:授权码流程、PKCE 与 OIDC
⏱ 44 分钟 | ⭐ 第 8 章劝你走的那条路,这一章真的走完
🎯 一句话
第 8 章给了一句很干脆的建议 ——「绝大多数人应该走 OAuth 或托管认证服务」,然后用一个表格单元格把它打发了:「一段回调逻辑 + 账号绑定关系」。这一章就是那一格的展开。 外包掉的是「怎么确认这个人是他自己」;留在你手上的是接住回调、把外面的身份换成你自己的会话。⭐ 至于第 8 步换回来的那张票本身长什么样、怎么被绕过、怎么吊销,是下一章 08e · JWT 的事。
🧩 一、外包之后,你还剩什么活
第 8 章已经说清了为什么别自己存密码(密码校验是最简单的一格,后面还有改密、忘密、验证码、限流、撞库、多设备登出、合规十几格)。这里只回答它没回答的那半句:交出去之后,你手上还剩什么。
| 交出去的 | 留在你这儿的 |
|---|---|
| 登录页、密码存储、改密/忘密、验证码、二次验证 | 一个回调端点(第二节) |
| 可疑登录检测、设备管理 | 账号绑定关系:外部身份 ↔ 你库里的 user 行 |
| 合规和事故责任的一大半 | ⭐ 你自己的会话(第 8 章第三节那一套) |
| —— | ⭐⭐ 全部授权逻辑:谁能看哪份文档、哪个租户 |
⭐ 第 8 章那句「认证只做一次、授权每次都做」在这里有个直接推论:你能外包的只有进门那一次。 门内每一次「他能不能看这个」,外面的服务不知道,也管不了。
⚠️ 最常见的理解偏差:以为拿到第三方的 token 就完事了,直接发给前端当会话凭证用。 不行 —— 它的有效期由别人定、它的受众不是你的接口(⚠️ 这不是理论问题:08e 里有一段能跑的代码,同一家签发方签给别的服务的票,少查一句 aud 就直接进了你的接口)、它一过期你就得让用户重走一遍第三方登录。⭐ 正确做法是拿它换出身份,然后签发你自己的会话。
🎫 二、授权码流程:一步一步走完
四个角色:用户和他的浏览器、你的应用(OAuth 里叫「客户端」)、授权服务器(IdP)、资源服务器。做「用 X 账号登录」时后两个通常是同一家。
| # | 谁在动 | 干什么 |
|---|---|---|
| 1 | 浏览器 | 用户点「用 X 登录」,打到你后端的 /auth/login |
| 2 | 你的后端 | 生成 state 和 PKCE 的 code_verifier,存进服务端会话,302 跳到授权服务器 |
| 3 | 授权服务器 | 显示它自己的登录页 +「某某应用想读取你的基本资料」授权页 |
| 4 | 用户 | 点同意(⭐ 密码只输在这一页,你的代码从头到尾看不到) |
| 5 | 授权服务器 | 302 回你注册的 redirect_uri,URL 上带 code 和 state |
| 6 | 你的后端 | ⭐ 先比对 state,对不上直接拒,不做任何后续动作 |
| 7 | 你的后端 | 拿 code + 客户端凭证 + code_verifier,向 token 端点发一次后端到后端的 POST |
| 8 | 授权服务器 | 校验通过,在响应体里返回 access_token / refresh_token / id_token |
| 9 | 你的后端 | 用 id_token 里的 sub 找到或创建本地账号,⭐ 签发你自己的会话 |
⭐ 第 2 步和第 7 步都是「你的后端」,不是笔误 —— 浏览器只负责跳转两次,敏感动作都在后端做。
🔍 为什么是「先拿 code 再换 token」
这是整个流程最值得理解的设计,而理由本板块已经讲过两遍:第 8 章第三节和第 10 章都写着 ——⚠️ URL 会被服务端访问日志、反向代理日志、浏览器历史、Referer 各抄一份。
第 5 步是一次 302 跳转,参数只能挂在 URL 上,所以它注定会泄露。设计上的应对不是「别泄露」,而是让泄露出去的那个东西不值钱:
code 的四条性质 |
挡住了什么 |
|---|---|
| 一次性 | 已经换过的 code,第二次用直接作废 |
| 短命(🗓️ 各家不同,量级是分钟) | 从日志里翻出来的旧 code 早没用了 |
绑定客户端和 redirect_uri |
换到别人那儿用不了 |
| ⭐ 换 token 还要另一样东西(客户端凭证或 PKCE 的 verifier) | 光有 code 不够 |
而第 8 步的 token 是在 POST 的响应体里回来的 —— 不进 URL、不进历史、不进 Referer。
🗓️ 所以「隐式流程」(授权服务器直接把 access_token 甩在 URL 片段里)现在不推荐再用。 它当年是给「没有后端的纯前端应用」开的捷径,代价正是把 token 塞回了那条注定泄露的路上。今天的答案是下一节的授权码 + PKCE。
🚦 state:只讲它在 OAuth 里的这一个用法
第 2 步生成随机 state 存进服务端会话,第 6 步比对。没有它会怎样:攻击者在自己那边走到第 5 步,把带着他的 code 的回调 URL 骗你点开,你的浏览器带着你的会话去完成第 7~9 步 —— 结果是你的账号被绑定到了攻击者的第三方身份上,他之后随时能用那个身份登进你的账号。
⭐ 判据两条:必须随机(可预测等于没有)、必须存服务端会话里比对(塞在 URL 里自己和自己比毫无意义)。
⚠️ CSRF 是什么、浏览器为什么会替别人的页面发出带凭证的请求 —— 那是 08c · CSRF 与 XSS 的内容。 这里只需要知道:
state是这条防线在 OAuth 流程里的那一个具体落点。
⚠️ 还有 redirect_uri 必须精确匹配,注册成前缀或带通配,等于把 code 交给任何能控制那个前缀下某个路径的人。💀 更隐蔽的一种:redirect_uri 本身没问题,但你自己域名下有一个开放重定向(比如 /go?to=... 那种跳转页),攻击者走你的合法地址再二次跳到自己站上,code 就跟着跳转链出去了。⭐ 所以这条防线是两截的:授权服务器那边精确匹配,你这边不留开放重定向。
🔐 三、PKCE:公开客户端存不住秘密
第 7 步说「拿客户端凭证去换」,但有一类客户端根本没法保管凭证:纯前端应用(代码在浏览器里,打开 DevTools 就能看见)、手机 app(二进制装在用户手机上,反编译就能翻出来)。这和第 13 章那条「⚠️ 密钥绝不放前端,加密也不行,因为解密的代码也在前端」是同一条判据。
这类客户端叫公开客户端。它们的处境是:code 一旦被截获(移动端最典型的路径是恶意 app 抢注了同一个自定义 URL scheme),攻击者就能直接换出 token。PKCE 的做法是让客户端每次登录临时造一个只有自己知道的秘密,把 code 和自己绑死。
# PKCE:verifier 留在本机,出门的只有它的哈希
import base64, hashlib, secrets
def s256(t):
return base64.urlsafe_b64encode(hashlib.sha256(t.encode()).digest()).rstrip(b"=").decode()
verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
challenge = s256(verifier) # ⭐ 只有它跟着跳转 URL 出门
print("code_verifier (只留本机):", verifier)
print("code_challenge(进 URL) :", challenge)
def token_endpoint(saved_challenge, submitted_verifier): # 授权服务器那一侧
return "✅ 换出 token" if secrets.compare_digest(
s256(submitted_verifier), saved_challenge) else "❌ 拒绝:verifier 对不上"
print("真客户端 :", token_endpoint(challenge, verifier))
print("截获 code 的人:", token_endpoint(challenge, "attacker-guess"))
实跑输出(前两行每次都不同,verifier 是随机的):
code_verifier (只留本机): rH1IHAnG2xiCpyNByjksoM0__La9OwFAEgyNWVW0RIM
code_challenge(进 URL) : 8ZxKZpCuKJDd8fI_CBX2x2LlSiwfoBVbLZNCIW_xvJU
真客户端 : ✅ 换出 token
截获 code 的人: ❌ 拒绝:verifier 对不上
⭐ 为什么这样就够:出门的只有 challenge(哈希值),第 7 步要交的是 verifier(原文)——从 SHA-256 的输出反推输入,正是它设计上不成立的方向。于是「拿到 code」不再等于「能换 token」。
⚠️ 两个细节:方法要用 S256 不要用 plain(plain 是把 verifier 原样当 challenge 发出去,等于什么都没做,它只是给算不动哈希的老设备留的兼容口);🗓️ 现在的建议是所有客户端都用 PKCE,包括有客户端凭证的后端客户端 —— 成本只是多算一次哈希,换来的是凭证泄露时还能兜一层。
🪪 四、OIDC 和 OAuth2 到底什么关系
这是被混为一谈最多的一对词,一句话可以说透:
⭐⭐ OAuth2 解决授权 ——「这个应用能不能替用户去拿那个资源」;OIDC 是搭在它上面的一层,解决认证 ——「用户是谁」。
id_token就是这个增量。
OAuth2 规范压根没规定用户身份该怎么表达,所以早年各家自己发明。OIDC 把它标准化了:约定 openid 这个 scope、约定返回一个叫 id_token 的 JWT、约定里面有 sub / iss / aud / exp / nonce 这些字段。
access_token |
id_token |
|
|---|---|---|
| 给谁看 | 资源服务器 | ⭐ 你的应用 |
| 回答什么 | 持票人能做什么 | ⭐ 持票人是谁 |
| 格式 | 🗓️ 规范没规定,很可能是一串不透明字符 | ⭐ 一定是 JWT |
| 你该不该拆开看 | ⚠️ 不该 —— 对你它就是信封上的编号 | 该,且必须验签 + 查 aud/iss/exp |
⚠️ 两个高频错误:① 拿 access_token 当身份证明 —— 它是一张门票,票上没写名字,只证明「持票人被授权访问某资源」。② ⚠️⚠️ 用 email 当本地账号主键 —— email 会变、在某些 IdP 那里可能根本没验证过,而两个不同的 IdP 完全可能给出同一个 email,那是一条账号劫持路径。⭐ 用 (iss, sub) 做联合唯一键,email 只当展示字段。
⭐ 表里那一格「
id_token一定是 JWT」,就是下一章的起点。 登录这条路到第 9 步就走完了,剩下的问题全在那张票身上:它长什么样、怎么被绕过、签出去之后想收回来有多难 —— 08e · JWT。
🔄 换个栈怎么对应
| 概念(不会过期) | Python / FastAPI(本板块主栈) | Node(Express / Hono) | Go |
|---|---|---|---|
| 发起授权、接住回调 | 一个 OAuth/OIDC 客户端库 🗓️ | 同类库 🗓️ | 同类库 🗓️ |
state 和 code_verifier 存哪 ⭐ |
服务端会话(会话表或签名 Cookie) | 同 | 同 |
| 读授权服务器的地址和端点 | 读它的发现文档 + 缓存 🗓️ | 同 | 同 |
| 回调成功后签发你自己的会话 | 第 8 章第三节那一套(Cookie / Bearer) | 同 | 同 |
| 把身份接进每个请求 | Depends(current_principal)(第 8 章第四节) |
中间件写进 req.user / c.set() |
写进 context.Context |
⭐ 这张表要看出的是:三家的库名全会过期,不过期的是那几条判据 ——
state和code_verifier必须存在服务端、redirect_uri必须精确匹配、(iss, sub)才是主键、第 9 步一定要签发你自己的会话。换语言只换了「这个库叫什么名字」。
🔗 这一章连到哪里
| 去哪 | 为什么 |
|---|---|
| 08 · 认证、会话与多租户 | ⭐⭐ 本章是它第二节那张表里「② OAuth / OIDC 第三方登录 | 一段回调逻辑 + 账号绑定关系」这一格的展开;第 9 步「签发你自己的会话」用的就是它第三节那套 Cookie / Bearer |
| ⭐⭐ 08e · JWT | 本章的直接下一步:第 8 步换回来的 id_token 一定是 JWT。那一章讲它的三段结构、被绕过的四种方式(alg:none / 算法混淆 / kid 注入 / 不查 aud),以及吊销为什么难 |
| 08c · CSRF 与 XSS | ⭐ 本章只讲了 state 这一个具体用法;CSRF 的攻击机制、浏览器为什么会替别人的页面带上你的凭证,都在那一章 |
| 13 · 配置与密钥 | ⭐ OAuth 的客户端凭证归它管;⚠️ 它第四节那条「绝不放前端,加密也不行 —— 因为解密的代码也在前端」正是第三节 PKCE 存在的理由 |
| 10 · 把流式接到界面上 | ⚠️ 那一章「别把 token 放 query 参数(会被记进服务器日志、代理日志、浏览器历史)」和本章「为什么先拿 code 再换 token」是同一条理由的两个位置 |
| 16 · 上线前检查单 | ⭐ 那一章给了本章的验收动作:不带 token 打业务接口要得 401、用 A 的 token 请求 B 的资源 id 要得 404/403(只测「A 能拿自己的」测不出漏洞) |
| 智能体工程 08 · MCP 接入外部世界 | ⭐ 那一章「认证要标准化」一条说 OAuth 流程、token 刷新交给平台层,别让每个 Server 自己造 —— 本章讲的正是它让你交出去的那套流程,知道形状才知道平台层替你做了什么 |
✅ 检查点
- 认证外包给第三方之后,你手上还留着哪四类活?哪一类绝对外包不掉?
- 授权码流程九步里,哪两步是在你的后端做的?浏览器在整个流程里只做了什么?
- 为什么要「先拿 code 再换 token」?
code的哪四条性质让泄露出去的 code 不值钱? - 「隐式流程」当年解决的是什么问题?为什么现在不推荐?它的替代品是什么?
state不做会怎样?它有哪两条判据?- 除了
redirect_uri精确匹配,为什么还要管自己域名下的开放重定向? - PKCE 解决谁的问题?为什么「出门的只有 challenge」就够了?
plain为什么等于没做? - 一句话说清 OAuth2 和 OIDC 的关系。
access_token和id_token分别该给谁看,哪一个你不该拆开? - 为什么不能用 email 当本地账号主键,该用什么?
- 为什么不能把第三方发给你的 token 直接当会话凭证发给前端?(三个理由)
👀 答案
- 回调端点、账号绑定关系(外部身份 ↔ 你库里的 user 行)、你自己的会话、全部授权逻辑。⭐ 授权外包不掉 —— 你能外包的只有进门那一次,门内每一次「他能不能看这个」外面的服务不知道也管不了。
- 第 2 步(生成
state和code_verifier存进服务端会话、302 跳走)和第 7 步(拿code+ 客户端凭证 +code_verifier发后端到后端的 POST)。⭐ 浏览器只负责跳转两次,敏感动作都在后端。 - 因为第 5 步是 302 跳转,参数只能挂 URL 上,注定会泄露(⚠️ URL 会被服务端日志、代理日志、浏览器历史、Referer 各抄一份)。所以让泄露出去的东西不值钱:
code一次性、短命(🗓️ 量级是分钟)、绑定客户端和redirect_uri、⭐ 换 token 还要另一样东西(客户端凭证或 PKCE 的 verifier)。真 token 在第 8 步的 POST 响应体里回来。 - 它是给「没有后端的纯前端应用」开的捷径 —— 让授权服务器直接把
access_token甩在 URL 片段里。⚠️ 代价正是把 token 塞回了那条注定泄露的路上(302 的参数只能挂 URL)。🗓️ 替代品是授权码 + PKCE。 - 攻击者可以把带着他自己的 code 的回调 URL 骗你点开,于是你的账号被绑到他的第三方身份上,他之后随时能用那个身份登进你的账号。两条判据:必须随机(可预测等于没有)、必须存服务端会话里比对(塞在 URL 里自己和自己比毫无意义)。
- 因为攻击者可以让回调先跳到你的合法地址,再借那个开放重定向(
/go?to=...那种)二次跳到自己的站,code 跟着跳转链出去。⭐ 所以这条防线是两截的:授权服务器那边精确匹配 + 你这边不留开放重定向。 - 解决公开客户端(纯前端、手机 app)存不住客户端凭证的问题 —— 代码在用户手里,打包进去的秘密不是秘密。出门的只有
challenge = SHA-256(verifier),⭐ 从哈希输出反推输入正是它设计上不成立的方向,所以「拿到 code」不再等于「能换 token」。plain把 verifier 原样当 challenge 发出去,等于什么都没做(它只是给算不动哈希的老设备留的兼容口)。 - ⭐ OAuth2 是授权(这个应用能不能替用户拿那个资源),OIDC 是搭在它上面加认证(用户是谁),
id_token就是这个增量。access_token给资源服务器看,⚠️ 你不该拆开它 —— 对你它就是信封上的编号;id_token给你的应用看,⭐ 而且必须验签 + 查aud/iss/exp。 - email 会变、在某些 IdP 那里可能根本没验证过、两个不同的 IdP 可能给出同一个 email(那是一条账号劫持路径)。⭐ 该用
(iss, sub)联合唯一键,email 只当展示字段。 - ① 有效期由别人定;② ⚠️ 它的受众不是你的接口(08e 里那段能跑的代码就是这个形状:同一家签给别的服务的票,少查一句
aud就进来了);③ 它一过期,你就得让用户重走一遍第三方登录。⭐ 正确做法是拿它换出身份,然后签发你自己的会话。
🛑 可以停在这里
⚡ 走神救援
第 8 章劝你「走 OAuth 或托管服务」却只给了一个表格单元格,这一章是那一格的展开。外包掉登录页、密码存储、改密忘密、验证码;留给你四样:回调端点、账号绑定关系、你自己的会话、⭐⭐ 全部授权逻辑(能外包的只有进门那一次)。授权码九步,⭐ 第 2 步和第 7 步都在你的后端:
state和 PKCE 的code_verifier存进服务端会话 → 302 跳走 → 用户同意(密码只输在那一页)→ 带着code回来 → 先比对state→ 后端到后端 POST 换 token → ⭐⭐ 第 9 步用id_token里的sub签发你自己的会话;⚠️ 最容易漏的就是第 9 步,把第三方 token 直接当会话用(有效期由别人定、受众不是你的接口)。⭐⭐ 为什么先拿 code 再换 token:302 的参数只能挂 URL 上,而 URL 会被服务端日志、代理日志、浏览器历史、Referer 各抄一份,注定泄露 —— 所以让泄露的东西不值钱:code 一次性、短命(🗓️ 分钟量级)、绑定客户端和redirect_uri、换 token 还要另一样东西,真 token 在 POST 响应体里回来。state防「攻击者的 code 被绑到你账号上」:随机 + 存服务端会话比对;⚠️redirect_uri精确匹配,且自己域名下不能留开放重定向 —— 这条防线是两截的。PKCE 给存不住凭证的公开客户端用:verifier不出本机,出门的只有challenge = SHA-256(verifier),⚠️ 用S256不用plain。⭐⭐ OAuth2 是授权、OIDC 是在它上面加认证,id_token就是这个增量;access_token是张没写名字的门票,⚠️ 别用 email 当主键,用(iss, sub)。
下一节 👉 08e-JWT.md