加载中...

这一讲全是概念,读起来最轻,其实最容易读完就忘。有五处值得停下来:

  • 因为不安全。假如在每一次 API 请求中都带上密码,那么显然密码泄漏的概率会更大” —— 对,但还漏了一条硬得多的理由,是纯性能的。
  • Token 授权和 Session 授权的差别只是应用场景不同” —— 真正的分野不是 Cookie vs Header。
  • 每次访问令牌失效后,通过更新令牌获得新的访问令牌” —— 为什么要设计成两个 token? 原文说了怎么用,没说为什么。
  • 原文给了授权码模式的 A~E 五步,但没有解释这个设计为什么非要有"授权码"这一步?直接发 token 不行吗?
  • 原文一次都没提 scope。 而 scope 才是 OAuth 区别于"一把万能钥匙"的地方。

而这一讲最好的抓手是一句话:

★ 授权的全部麻烦,都来自一条约束:密码不能在网上多跑。
  Session、Token、过期时间、Refresh、OAuth、ID Token —— 全是这条约束的推论。

记住这个零点,43 讲和 44 讲就变成一条直线,而不是一堆名词。

怎么读这份带读

这一部分是 建议
第一部分 · 地基(一) 那条约束,以及从它推出整条链 先读这个,后面全是它的展开
第二部分 · 原文(二~五) 帐号 / 授权 / Session vs Token / OAuth,把省掉的推导补上 主体
第三部分 · 引申(六~八) 认证 ≠ 授权 · PKCE 与 Implicit 的废弃 · scope 标了 ⚠ 非原文

只想搞懂某一件事:
为什么不能每次带密码 → 一 | 帐号该用什么当 ID → 二 | Session vs Token / 双 token → 四
OAuth 在解决什么 → 五 | 为什么要有授权码 → 5.4 | 认证 vs 授权 → 六 | PKCE → 七

这一讲的演示脚本(推荐按这个顺序跑):

# 脚本 演什么 配合
1 代码/为什么不能每次带密码.py ★ 实测密码校验比验签慢 4 万倍 · QPS 上限 · 从一条约束推出整讲 一 · 三
2 代码/双token为什么.py 有状态 vs 无状态 · 改了密码旧 token 还能用 · ★ 双 token 的账 · CSRF
3 代码/授权码模式.py 跑一个最小授权服务器 · ★ 四个攻击各被哪道闸挡住 · PKCE · state 五 · 七

第一部分 · 地基

一 · ★ 零点:密码不能在网上多跑

python3 代码/为什么不能每次带密码.py

1.1 原文的理由只讲了一半

“为什么不用?因为不安全。假如在每一次 API 请求中都带上密码,那么显然密码泄漏的概率会更大。
所以,安全性上的需求会导致我们倾向于尽可能减少密码在网络中传输的次数。”

"泄漏概率更大"具体大在哪?把一次请求的路径摊开:

经过的地方 泄漏方式
① 客户端存储 要每次带,就必须存密码(明文或可解密)。一台设备被拿走 = 密码丢了
② 传输链路 TLS 之外每一跳(网关、LB、反向代理)都可能记日志
③ 服务端日志 一次误打带 header 的 access log,全量密码进日志系统——而日志系统权限最松、保留最久
④ 服务端内存 每个请求都要拿明文密码算哈希,core dump 里到处都是
⑤ 服务端 CPU 这一条不是安全问题,是性能问题(1.2)

★ ① 最致命,而它常被忽略:
"每次请求都带密码"的前提是客户端必须长期保存明文密码
这一步就已经把"密码只在用户脑子里"这个最重要的性质破坏掉了。

★ 而 token 的对应性质是:token 可吊销、有期限、只对一个服务有效。
密码三条都不满足——它不能吊销(只能改)、没有期限、而且用户会在别处复用它。

1.2 ★ 原文没提的那条硬理由:密码校验是被【故意】设计成慢的

关键事实:bcrypt / scrypt / argon2 / PBKDF2 这类密码哈希,唯一的对手是"拿到数据库之后离线爆破",
拖慢每次计算就是在拖慢爆破。OWASP 的建议是把单次校验调到几十到几百毫秒这个量级。

脚本实测(PBKDF2-HMAC-SHA256,60 万次迭代 vs HMAC-SHA256 验签):

校验一次密码(PBKDF2, 600,000 次迭代)     40.994 ms
验一次 token(HMAC-SHA256 签名)          0.00090 ms
★ 相差 45,738 倍

折算成 QPS 上限(10 核,只算授权这一步):

每次请求都验密码              244 QPS
每次请求只验 token     11,157,212 QPS

★ 也就是说:如果 API 每次都带密码,服务端什么业务都还没做,
就已经被授权这一步锁死在两百多 QPS 了。

而这个上限是买不动的——加机器只能线性涨,
而慢哈希的成本是设计目标,你不能把它调快(调快就等于削弱抗爆破能力)。

★ 所以"减少密码在网络中传输的次数"有两个独立的来源:

安全上 密码经过的地方越多,泄漏面越大
性能上 密码校验故意很慢,高频路径上放不起

两条指向同一个设计:
用一次密码,换一个"验起来很便宜"的凭据,之后都用那个凭据。

★ 这就解释了原文那个"必然"用得为什么这么准:
“'用户名 + 密码’这种授权方式,必然会以尽可能少的频率去使用。”
它不是一种偏好,是被两条约束夹出来的。

1.3 ★ 从这一条约束,推出 43 + 44 讲的全部内容

密码只能低频用
    ↓  那高频请求带什么?
带一个【便宜可验】的凭据                    →  Session(Web)/ Token(API)
    ↓  这个凭据从哪来?
用密码换来的                              →  ★ 登录(login)就是那个入口
    ↓  换来的凭据会不会也泄漏?
会,所以它必须【有期限】                    →  过期时间
    ↓  过期了要用户重新输密码?那不又高频了
不用,用另一个凭据去换新的                  →  ★ Refresh Token / Session 顺延
    ↓  如果要给【第三方 App】用呢?
不能给密码,也不能给我的完整凭据             →  ★ OAuth 2.0
    ↓  第三方拿到的凭据里没有"我是谁"
再加一种带身份的凭据                        →  ★ ID Token / OpenID Connect(44 讲)

★ 整个 43 讲加 44 讲,就是这一条链。
原文是按"概念清单"的顺序讲的(帐号 → 授权 → OAuth → 模式),
所以读起来像一堆并列的名词。按这条链读,它们是一个接一个被逼出来的。


第二部分 · 原文这一讲

二 · 帐号(Account)

2.1 原文的清单

“帐号,简单说就是某种表征用户身份的实体,它代表了一个’用户’。”

表征方式 典型场景
电子邮件 · 手机号 · 用户自定义 ID 通用
自动分配的唯一 ID(UUID) 银行——“你的银行帐号从来都不是你自己定义的”
身份证号(冷门) 政府公共服务类

原文还有一句很实在的观察:

“虽然一个物理的自然人用户可能会在同一个网站开多个帐号,但从业务角度,我们往往把这些帐号看作不同的用户。”

2.2 ★ 那个清单里混了三种东西(原文没分开)

原文把邮箱、手机号、UUID 并列成"帐号的表征方式"。但它们不是同一类东西:

是什么 能不能变 例子
标识符 identifier 系统内部指代这个用户的东西 绝对不能变 自动分配的 UUID / 自增 id
凭据 credential 用来证明"我是那个人"的东西 必须能换 邮箱、手机号、密码、第三方 OpenID
属性 attribute 关于这个人的信息 随便变 昵称、头像、身份证号

把凭据当标识符用(邮箱/手机号当主键),会撞上这些:

· 用户换手机号            → 要改主键 → 所有外键、所有日志、所有已发出去的链接
· 旧手机号被运营商回收     → ★ 新持有者拿到了老用户的帐号(真实发生过的事故)
· 用户想改邮箱            → 但邮箱是主键 → 只能"注销重注册"
· 同一个人既想用手机登录又想用微信登录 → 两个凭据,一个人 → 主键该是哪个?

★ 所以正经做法是三层分开:

内部主键     uid = 自动分配的不可变 ID          ← 全系统只认这个
               ↑ 一对多
登录凭据     邮箱 / 手机号 / 微信 openid / 密码  ← 可增、可删、可换
属性         昵称、头像                        ← 随便

★ 这正是原文那个"银行"例子的深层原因:银行卡号是预先分配的,
而不是让你用身份证号或手机号——因为标识符必须不可变,而人的一切属性都会变。

★ 而 42 讲那个 uid UserID = uint64,选的正好就是这一层。
43 讲要做的不是"换掉 uid",是给 uid 前面接上一套"怎么证明我是这个 uid"的机制。

三 · 授权(Authorization)

3.1 原文的定义与那条"一对多"

授权是帐号对服务的访问方式。
从这句话字面去理解,授权和帐号相关。有帐号,就会有授权。
但是帐号和授权并不是对应的关系同一个帐号,可能会有多种授权。

"一对多"具体是哪些?

一个帐号                       它活着的授权
  uid=7        ─┬─  浏览器上的 web session(3 天)
                ├─  手机 App 的 access + refresh token
                ├─  给印象笔记的第三方授权(scope: drawings:read)
                ├─  CI 用的 machine token / AK-SK
                └─  一个几个月前忘了的第三方 App 授权     ← ★ 最危险的那个

★ 为什么"一对多"这么重要?因为它决定了"改密码"和"退出登录"到底该做什么。

动作 应该作废哪些授权?
退出登录 只作废这一个 session
改密码 所有 session(因为改密码通常意味着"我怀疑被盗了")
改密码 那第三方授权呢?API key 呢?——这是个产品决策,没有唯一答案

GitHub 的选择是:改密码作废所有 web session,但不作废 PAT(个人访问令牌)
理由是 PAT 是用户显式创建、显式管理的东西,用户有独立的界面能看到和删除它们。

★ 于是推出一条判断:一个系统如果没法列出"我的帐号现在有哪些活着的授权",
它的安全模型是不完整的
——因为用户没有办法回答"我到底把什么权限给了谁"。
授权必须是可枚举、可单独吊销的对象,不能只是一个飘在外面的字符串。

3.2 "用户名 + 密码"为什么被原文刻意略过,又必须补回来

“但实际上还有一种最常见的授权机制没有被提到,那就是:用户名 + 密码
这里的’用户名’其实就是指’帐号’。
……我们在业界基本上看不到用’用户名 + 密码’来作为网络 API 的授权机制。”

它只在两个地方出现(原文的两条):

入口 之后变成什么
登录(login) 一个 Session 用途的 Cookie,“往往有几天的有效期”
Token 授权的入口 Access Token + Refresh Token

原文顺手澄清了一个常见混淆,值得记:
“这里说的 Session 授权,和浏览器引入的 Session 不是一回事
Session 授权发生在登录之后,一般并不会随浏览器窗口的关闭而消失。”

浏览器的 sessionStorage / 会话 Cookie   → 关窗口就没了
Session【授权】                        → 服务端记着,几天有效期,关窗口还在

四 · Session vs Token:真正的分野不是 Cookie 和 Header

python3 代码/双token为什么.py

4.1 原文的对照表

“Session 授权会有过期时间,Token 授权也会有过期时间。Session 授权有自动顺延,Token 授权有 Refresh。
Session 授权的典型入口是登录(login),Token 授权也一样有’用户名 + 密码’授权这个入口。
……Token 授权基于 HTTP 的 Authorization 头,而 Session 授权则基于 Cookie。

结论"差别只是应用场景不同"是对的。但原理不在这张表里。

4.2 ★ 分野在"服务端存不存东西"

有状态(传统 Session) 无状态(JWT 式 Token)
凭据里有什么 一个随机 id(不含信息 内容本身(uid、过期、权限)
服务端存什么 id → 用户 的一张表 什么都不存,只有一个密钥
校验怎么做 查一次库 重算一遍签名(微秒级)
立即吊销 删一行,下次请求就失效 ★ 做不到
水平扩容 要共享存储(Redis)或粘性会话 随便加机器
大小 几十字节 几百字节~1KB,每个请求都带
内容改了怎么办 改库就行,凭据不用换 必须重发(旧的还在流通)

★ Cookie vs Authorization 头只是【运输方式】,两种运输方式都能装两种凭据:

Cookie 里放 JWT              →  无状态 + Cookie     (很常见)
Authorization 里放随机 id     →  有状态 + Header     (API key 就是这样)

所以原文那张对照表描述的是【惯例】,不是原理。原理在"服务端存不存东西"。

4.3 ★ 无状态的代价:改了密码,旧 token 还能用

脚本实测(用户发现被盗、赶紧改密码):

── 用户改密码 ──
   有状态:删掉这个用户的所有 session 行
   无状态:★ 什么也做不了 —— token 在用户手机里,服务端根本不知道有它

改密码之后再用一次 Session(有状态)  → 查不到(已吊销)    ✓ 已失效
改密码之后再用一次 Token(无状态)    → 通过               ✗ 【仍然有效】

★ 这就是无状态要付的账,而且它精确地说明了这笔账的性质:
它的全部好处(不查库)来自"服务端不知道有这个 token 存在",
而它的全部坏处(不能吊销)来自同一件事。

常见的"补救"是维护一张黑名单(被吊销的 token 记下来,每次校验查一下)。
★ 但那就等于又变成有状态了——你花力气换来的"不查库"又还回去了。
所以黑名单只对少量事件划算:改密码、登出、检测到盗号——而不是常态。

4.4 ★ 于是必须有【两个】token——这是原文没解释的那件事

既然不能吊销,那"被偷的 token 能用多久"只有一个旋钮:有效期。

有效期 被偷之后的危险窗口 用户体验
30 天 30 天 一次登录用一个月,很爽
1 小时 1 小时 每小时重新登录——不能忍
5 分钟 5 分钟 完全不可用

★ 这张表里没有一行是可接受的。
"安全"要求短,"体验"要求长,而它们在一个 token 上不可能同时满足。
——这就是为什么必须有两个。

Access Token Refresh Token
用在哪 每一个业务请求 只在换新 access token 时
频率 极高 极低(比如 15 分钟一次)
有效期 (5~60 分钟) (几天~几个月)
有状态吗 无状态,验签就行 有状态,服务端记着它
能吊销吗 不能(靠短命自然消亡) 能,删一行就行
校验成本 微秒级,不查库 查一次库——但频率极低,所以没事

算一笔账(1000 万次业务请求,access 15 分钟、平均每用户 200 个请求):

方案甲:一个长效 token,无状态       查库 0 次           ★ 但被偷能用 30 天,改密码也拦不住
方案乙:一个长效 token,有状态       查库 10,000,000 次   ★ 能立刻吊销,但授权成了数据库的负担
方案丙:双 token(OAuth 的做法)     查库    50,000 次   ★ 降到 1/200,而吊销能力一点没丢
                                                        (删掉 refresh,最迟 15 分钟那人就出不去了)

★ 一句话:把"高频 + 无状态 + 短命"和"低频 + 有状态 + 长命"分成两个东西。
高频那个不能查库(会成为瓶颈),低频那个可以查库(于是能吊销)。

★ 这个手法在这门课里反复出现,是同一个思路:

按什么拆
37 讲 读写分离 按"读 / 写"拆,各自优化
39 讲 缓存 按"热 / 冷"拆,热的走内存
双 token 按"频率高 / 低"拆,高频那个不许查库
┌────────────────────────────────────────────────────────────────┐
│  ★ 通用形式:当一个东西要同时满足两个互相矛盾的要求时,           │
│    先看能不能按【频率】把它拆成两个,让高频那个只承担便宜的要求。  │
└────────────────────────────────────────────────────────────────┘

★ 而原文那句"Session 授权有自动顺延,Token 授权有 Refresh",现在能看出它们不完全对等:

是什么
Session 顺延 同一个(有状态的)凭据延长寿命
Refresh 用一个凭据换另一个凭据——因为那两个凭据性质不同

Session 不需要拆成两个,因为它本来就是有状态的、本来就能吊销。
Refresh Token 是【为无状态付的税】。

原文提到了运输方式的差别,但没说这个差别最重要的后果:

Cookie          是【浏览器自动携带】的 —— 只要域名对得上,谁触发的请求都带
Authorization   是【代码主动加】的 —— 别人的页面写不出你的头
<!-- 恶意网站 evil.com 上放这么一段 -->
<form action="https://qpaint.com/api/drawings/507f" method="POST">
<script>document.forms[0].submit()</script>
授权方式 结果
Cookie 浏览器自动带上 qpaint.com 的 cookie → ★ 请求真的执行了
Authorization 头 evil.com 的脚本拿不到你的 token → 请求是匿名的 → 401

★ 这就是 CSRF(跨站请求伪造)——它是 Cookie"自动携带"这个便利的直接代价。

方案 必须额外做什么 它自己的坑
Cookie CSRF token / SameSite=Lax|Strict / 检查 Origin CSRF
Authorization 头 自己管 token 放哪 XSS(存 localStorage 脚本就能读到;HttpOnly Cookie 读不到)

★ 判断:CSRF 和 XSS 这两个风险,两个方案是【互补】的,不是"一个更安全"。
今天的主流答案是把 token 放进 HttpOnly + SameSite 的 Cookie 里(同时躲开两个),
代价是要处理 CSRF 的边界情况和跨域。


五 · OAuth 2.0

python3 代码/授权码模式.py

5.1 ★ 先退回零点:它到底在解决什么问题

原文直接从"三个角色"讲起。先把问题说出来,三个角色才有意义:

┌────────────────────────────────────────────────────────────┐
│  我想让第三方 App 访问我在某个网站的数据,                   │
│  但我【不想把那个网站的密码给它】。                          │
└────────────────────────────────────────────────────────────┘

在 OAuth 之前的做法就是"把密码给它"——这个做法有个正式的名字,叫 password anti-pattern。真实存在过:

· 早年社交网站的"导入通讯录":让你填【邮箱账号 + 邮箱密码】
· 早年的记账 App:让你填【网银登录密码】
· 各种"一键同步"工具:让你填另一个平台的账号密码

把密码给第三方,等于给了它这五件事的权限(而你只想给第一件):

① 读你想让它读的那部分数据            ← 你想给的
② 读你【所有】数据
③ 【改】和【删】你的数据
④ ★ 改掉你的密码,把你自己锁在外面
⑤ ★ 永久有效 —— 你没法"只收回它的权限",只能改密码(连自己一起断)

★ 于是 OAuth 的设计目标就清楚了:造出一种凭据,同时满足四条
—— 范围可限(只给①)· 可吊销(不影响自己)· 有期限 · 不暴露密码

★ 剩下所有的复杂度(三个角色、六个步骤、六种模式)都是为了同时满足这四条。
读原文时如果觉得"为什么要绕这么多弯",就回来对一遍这四条。

5.2 三个角色,换成一个具体的故事

原文的名字 故事里是谁 它想干什么
资源拥有方 Resource Owner 我的图,我做主
客户端 Client 印象笔记 我想把你的 QPaint 图插进笔记
授权服务器 Authorization Server QPaint 的登录处 验明身份、征求同意、发令牌
资源服务器 Resource Server QPaint 的 API 收到令牌就给数据

原文有一句提醒比它看起来重要:

“在 OAuth 的视角中,官方应用和第三方应用并无大的区别,以相同的机制在工作。
从这一点来说,称之为客户端会更加合理。”

★ 它的含义是:你自己的 App 也走同一套流程,于是【你不需要给自己开一个后门】。
而后门是安全事故的第一来源——因为后门那条路上的代码没人审、没人测、也没人记得它存在。

(42 讲那个 QPaintStub <uid> 就是一个典型的后门。44 讲带读会看到它最后怎么被"关"起来的。)

5.3 两种使用场景,以及第二种的门票

场景 目的
提供开放接口(OAuth 的核心场景) 授权第三方 App 调用我的服务
作为 OpenID 提供方 第三方接入我,只是为了复用我的用户

原文对第二种很清醒:
“这当然不是谁都能够做得到的,还是要有足够大的用户基数,并且有一定的入口价值才有可能被接受。”

国内的例子:微信 / QQ、支付宝、新浪微博。
为了支持这个场景,OpenID Foundation 专门引入了 OpenID Connect——44 讲的主角。

5.4 ★ 为什么非要有"授权码"这一步(原文完全没解释)

原文给了 A~E 五步,但没说这个设计的要点。先看清一件事:

┌──────────────────────────────────────────────────────────────────┐
│  授权码走【浏览器重定向】                                          │
│      → 它会出现在 URL 栏、浏览历史、Referer 头、代理日志、崩溃报告里 │
│  真正的 token 走【服务器到服务器】(原文的 D 步)                   │
│      → 它【不经过浏览器】                                          │
└──────────────────────────────────────────────────────────────────┘

原文其实写到了这一点,但只用了半句话,很容易划过去:

“(D)该网站收到授权码,附上早先的’重定向 URI’,向认证服务器申请令牌。
这一步是在网站的后端服务器上完成的,对终端用户不可见。

★ 一句话概括整个设计:
把"要经过不可信信道的东西"和"值钱的东西"分开。
授权码经过浏览器但不值钱(一次性、短命、绑定 client);
token 值钱但不经过浏览器。

脚本把四个攻击各跑了一遍,看它们分别被哪道闸挡住:

① 攻击者从浏览器历史里捡到一张新鲜的授权码,想去换 token
    → ★ client_secret 不对(拒绝)                    ✓ 被挡住
② 攻击者注册了自己的 client,用捡到的码去换
    → ★ 授权码不是发给这个 client 的(拒绝)            ✓ 被挡住
③ 授权码被中途窃听,攻击者和真客户端【都】拿它去换
    → ★ 授权码已被使用过(一次性,拒绝)                ✓ 被挡住
④ 攻击者构造链接,把 redirect_uri 改成自己的地址
    → ★ redirect_uri 与注册时不一致(拒绝)             ✓ 被挡住
规则 它挡住的是
1 换 token 要带 client_secret 捡到码的人换不到(secret 在服务器上)
2 绑定发放时的 client 攻击者用自己的 client 换不到
3 码是一次性 重放,以及"双方都拿到码"的竞争
4 redirect_uri 必须预注册 码根本不会被送到攻击者手上

★ 第 4 道最关键,也最容易在实现里出错:redirect_uri 必须【精确匹配】预先登记的地址。
历史上大量 OAuth 漏洞就出在这里放松了——允许前缀匹配、允许通配、
允许目标站点上有 open redirect,于是授权码被转送到攻击者的域名下。

★ 这条判断可以推广:一个协议里凡是"经过第三方信道"的东西,
都应该被设计成【一次性 + 短命 + 绑定用途】,而不能是长效凭据。

短信验证码、邮件里的重置链接、支付回调的 token——全是这一条。

5.5 六种模式:原文的清单,与它们的真实关系

原文列的六条:授权码、简化(Implicit)、用户名+密码、客户端模式、访问令牌、更新令牌。

这六条不是并列的,原文自己也说了:

“其中,基于**访问令牌(Access Token)**的授权模式是最核心的一种,请求频率最大。
**更新令牌(Refresh Token)**则次之。
其他所有的授权方式,是在不同场景下的授权入口。

画清楚就是两层:

【入口】(拿到令牌的方式,每种场景一个)
   授权码模式        有用户在场 + 客户端有后端
   简化模式          有用户在场 + 客户端没后端        ← ⚠ 已废弃,见第七节
   用户名+密码模式    自家 App,用户信任               ← ⚠ 已不推荐
   客户端模式        ★ 没有用户,是程序自己的身份(定时任务、服务间调用)

【日常】(拿到之后怎么用)
   访问令牌          每个请求都用
   更新令牌          换新的访问令牌

★ 而"入口"那四种,按两个维度一分就清楚了(这张表比清单好用):

有用户在场 无用户
客户端能保住秘密(有后端) 授权码模式 客户端模式
客户端保不住秘密(SPA / App) 授权码 + PKCE(原 Implicit 的位置) ——
特例:自家 App、用户信任 用户名+密码模式(⚠ 已不推荐)

★ 两个维度就是两个真实的约束:「有没有人可以点同意」和「能不能藏住一个 secret」。
记住这两个问题,你就不需要背那六条。


第三部分 · 引申

⚠ 这一部分不是原文内容。 第六节补一个原文全篇混着讲的区分(它是理解 44 讲的钥匙);
第七节是 2019 年之后的重要变化;第八节补原文一次都没提的 scope。

六 · ⚠ 认证(Authentication)≠ 授权(Authorization)

原文全篇说"授权",但混着讲了两件事。必须分开,因为 44 讲的全部内容建立在这个区分上:

回答什么问题 典型动作
认证 Authentication 你是谁 登录、验密码、验短信码、验 ID Token
授权 Authorization 你能干什么 检查 scope、检查归属(42 讲那个 uid)、检查角色

★ 而 OAuth 2.0 名字里有 Auth,它其实是一个【授权】框架(delegated authorization),
不是认证框架。

这正是 44 讲那句话的根源:
“因为 OAuth 2.0 本身只关心授权,所以它会返回访问令牌和更新令牌。
但无论是访问令牌还是更新令牌,都并没有包含身份(Identity)信息
没有身份信息,就没法作为 OpenID Provider。”

★ 关键:这不是 OAuth 的缺陷,是它的【设计范围】。
一个 access token 说的是"持有者被允许做 X",它从来没打算说"持有者是谁"。

6.1 ★ 把 access token 当身份凭据用 = 一个真实的漏洞类型

这是 ID Token 存在的真正理由,44 讲只说了"补上身份信息",没说不补会怎样:

场景:网站 A 允许"用微信登录"。它的做法是:
      客户端把从微信拿到的 access token 发给 A 的后端,
      A 拿这个 token 去问微信"这是谁",微信答"是用户 7",A 就让他以用户 7 登录。

攻击:我做一个自己的小应用 B,也接入微信登录。
      你在 B 上授权后,B 拿到了【你的】微信 access token。
      B 把这个 token 拿去发给 A —— A 一问微信,微信说"是你",
      ★ 于是 B 的运营者用你的微信 access token 登进了你在 A 的帐号。

★ 问题的根源:access token 上没写"我是发给谁用的"。
它是一张不记名的通行证,A 无法分辨这张票是用户在 A 上授权得来的,还是从 B 那儿转手过来的。
这类问题的通称是混淆代理(confused deputy)。

★ ID Token 的解法:它是一个 JWT,里面有一个 aud(audience,受众)字段,
写明"这个 token 是发给 client_id = A 用的"。
A 验签时检查 aud == 自己
从 B 转手来的 token 的 aud 是 B,当场拒掉。

★ 所以 44 讲那句"ID Token 补上了身份信息"要补一句:
它补上的不只是"是谁",更关键的是【这条身份声明是说给谁听的】。

一个不说明受众的身份声明,是可以被转手的。

七 · ⚠ 2019 之后的两个重要变化

7.1 Implicit 模式已经废弃

原文清单里的"简化模式(Implicit)",今天已经不该用了(OAuth 2.1 草案直接移除,各家授权服务器也纷纷下线)。

为什么?因为它做的正是 5.4 说的那件不能做的事:

Implicit:授权服务器【直接把 access token 放在重定向 URL 的 fragment 里】
          → ★ token 经过了浏览器 → 进 URL 栏、进历史、可能进 Referer
          → 而且它没有 refresh token(fragment 里放长效凭据太危险)

当初为什么要有它? 因为 SPA(纯前端应用)没有后端,
保不住 client_secret,于是走不了授权码模式的第 D 步。

7.2 PKCE:给"保不住 secret 的客户端"一个动态的 secret

① 客户端【每次】随机生成一个 verifier
② 申请授权码时,只发过去 challenge = BASE64URL(SHA256(verifier))
③ 换 token 时出示 verifier,服务器自己算一遍 SHA256 对比

★ 相当于把"长期的、写死在代码里的 client_secret"换成了"一次性的、每次都不同的动态 secret"。
——于是不需要保存任何秘密的客户端也能安全走授权码模式。

脚本实测(手机 App 的自定义 URL scheme 可以被别的 App 抢注,所以授权码真的会被截获):

攻击者(没有 verifier)→ ★ PKCE:缺 code_verifier(拒绝)              ✓ 被挡住
攻击者(猜 verifier)  → ★ PKCE:code_verifier 对不上 challenge(拒绝)  ✓ 被挡住
真正的客户端           → 换到令牌                                      ✓ 成功

★ 今天(2026)的标准答案:

所有客户端 —— 有后端的、SPA 的、手机 App 的 —— 都走【授权码模式 + PKCE】。
Implicit 不要用,密码模式也不要用。

所以读原文那份"六种模式"清单时要打个折:它是 2019 年的完整清单,
今天实际只剩两种半(授权码+PKCE、客户端模式、以及给电视/机顶盒用的设备码模式)。

7.3 state:原文的流程图里没有它,但少了它有漏洞

攻击场景(叫 OAuth 登录 CSRF / 会话固定):

① 攻击者自己走一遍授权流程,拿到【他自己帐号】的授权码,但【不用】
② 他把 https://yinxiang.com/cb?code=<攻击者的码> 这个链接发给你
③ 你点了。印象笔记以为是你在完成授权,于是把【攻击者的 QPaint 帐号】
   绑到了【你的印象笔记帐号】上
④ ★ 之后你在印象笔记里存的图,都进了攻击者的 QPaint 帐号

state 挡住它:客户端在 A 步生成一个随机 state,存在自己的会话里,
C 步回来时比一比。攻击者构造的链接里那个 state 不在你的会话里 → 拒绝。

★ 也就是说 state 把"这次回调"和"我这个浏览器发起的那次请求"绑在了一起——
它和 CSRF token 是同一个东西、同一个原理
(4.5 节那个)。

⚠ 顺带区分一个容易混的:OpenID Connect 还有一个 nonce

绑的是
state 授权请求 ↔ 回调
nonce 授权请求 ↔ ID Token(防 token 重放)

八 · ⚠ scope:原文一次都没提,但它是 OAuth 的核心

回头看 5.1 那四条设计目标,第一条是"范围可限"——而实现它的东西就是 scope。

授权请求:     ?scope=drawings:read
令牌里带:      scope: "drawings:read"
资源服务器:    收到删除请求 → 看 scope 里没有 drawings:write → 403

★ scope 是【最小权限原则】在协议层的表达。
没有它,OAuth 就只是"一把不用给密码的万能钥匙"——安全性只比 password anti-pattern 好一档
(好在可吊销、有期限,但"能干什么"还是全部)。

而 scope 真正的难点不在技术,在产品:

粒度 后果
太粗 “允许该应用访问你的全部数据” → 用户不敢点同意,或者点了也没得选
太细 列出 40 个复选框 → 用户看不懂,随手全选,等于没有
好的设计 条数少(3~7 条)· 每条能用一句人话说清 · 和用户的心智模型对齐("读你的图"而不是 drawings.list.v2

★ 这条判断可以推广到所有权限系统——包括你给 AI Agent 发的工具权限:
权限的粒度不是技术问题,是"人能不能在三秒内做出正确决定"的问题。

九 · 收口:这一讲带走什么

① 一个零点             密码不能在网上多跑(安全 + 性能,两条独立理由)
② 一条推导链           Session/Token → 过期 → Refresh → OAuth → ID Token
③ 三个概念的分家       标识符 / 凭据 / 属性;认证 / 授权
④ 一条通用手法         按【频率】把矛盾的需求拆成两个(双 token)
⑤ 一条协议设计准则     经过第三方信道的东西必须一次性 + 短命 + 绑定用途

★ 这一讲在四讲里的位置:42 讲把授权的【机制】做通了(uid 怎么流进业务层),
但那个 uid 是浏览器自己声称的——它没有【可信性】。

43 讲整讲回答的就是"可信性从哪来",而答案是:
可信性来自"密码只在一个地方、一次性地被验证,之后所有环节都只认那次验证的产物"。

44 讲是把这个答案落地:不自己造,用 dex。


验收

地基(一)

# 问题 答案在
1 “每次带密码"除了泄漏面大,还有一条硬理由是什么?为什么这个上限"买不动”?
2 从"密码不能多跑"这一条约束,能推出 43+44 讲的哪几个机制? 1.3

原文(二~五)

# 问题 答案在
3 原文那份"帐号的表征方式"清单里混了哪三种东西?为什么银行用卡号? 2.2
4 "同一帐号可有多种授权"为什么重要?它推出了什么判断? 3.1
5 Session 和 Token 真正的分野是什么?(不是 Cookie vs Header) 4.2
6 无状态 token 为什么不能吊销?"黑名单"为什么不是个好补救? 4.3
7 为什么必须有两个 token? 它省下了多少查库?这个手法的通用形式是什么? 4.4
8 Cookie 方案和 Header 方案各自天生躲开了哪个风险、又撞上哪个? 4.5
9 OAuth 之前人们怎么做?把密码给第三方等于给了哪五件事? 5.1
10 为什么非要有"授权码"这一步? 四道闸各挡住什么?哪道最容易实现错? 5.4
11 六种模式怎么用两个维度一分了事?那两个维度是什么? 5.5

引申(六~九)

# 问题 答案在
12 认证和授权差在哪?为什么说 OAuth "不含身份信息"不是缺陷?
13 把 access token 当身份凭据用会出什么事?ID Token 靠哪个字段解决? 6.1
14 Implicit 为什么被废弃?PKCE 用一句话说是什么?
15 state 防的是什么攻击?它和 nonce 各绑什么? 7.3
16 scope 是什么原则在协议层的表达?它的真正难点在哪?

答案

1. 硬理由是:密码校验是被故意设计成慢的(bcrypt/argon2/PBKDF2,OWASP 建议单次几十到几百毫秒),因为它唯一的对手是离线爆破。脚本实测 PBKDF2 一次 41 ms,HMAC 验签一次 0.0009 ms,差 4 万多倍;折算 QPS 上限是 244 vs 1100 万
"买不动"是因为慢是设计目标——你不能把它调快(调快等于削弱抗爆破),加机器也只能线性涨。
所以"少传密码"有两个独立来源:安全上泄漏面大,性能上高频路径放不起。 原文那个"必然"用得很准:它是被两条约束夹出来的,不是偏好。

2. 密码只能低频用 → 高频请求带便宜可验的凭据(Session/Token)→ 凭据从密码换来(登录是那个入口)→ 凭据也会泄漏所以有期限 → 过期了不能让用户再输密码,所以Refresh → 要给第三方用而不能给密码 → OAuth 2.0 → 第三方拿到的凭据里没有"我是谁" → ID Token / OpenID Connect
整个 43+44 讲就是这一条链。

3. 混了标识符(不可变的内部主键,如自动分配的 UUID)、凭据(可换的登录方式:邮箱、手机号、密码、第三方 openid)、属性(昵称、身份证号)。
把凭据当标识符用会撞上:换手机号要改主键、旧号被运营商回收导致新持有者拿到老帐号、改邮箱只能注销重注册、一个人多种登录方式时主键该是谁。
银行用预分配的卡号,正是因为标识符必须不可变,而人的一切属性都会变。42 讲那个 uid uint64 选的就是这一层。

4. 因为它决定了"改密码"和"退出登录"到底该作废什么:退出登录只作废这一个 session,改密码通常意味着"我怀疑被盗",应该作废所有 session;而第三方授权和 API key 要不要一起作废是产品决策(GitHub 的选择是作废 session 但保留 PAT)。
推出的判断:一个系统如果没法列出"我的帐号现在有哪些活着的授权",它的安全模型是不完整的——授权必须是可枚举、可单独吊销的对象。

5. 分野是服务端存不存东西(有状态 vs 无状态)。
有状态:凭据是个不含信息的随机 id,服务端存一张表,校验要查库,能立刻吊销,扩容要共享存储。
无状态:凭据里装着内容本身,服务端只有一个密钥,校验是重算签名(微秒级),不能吊销,随便扩容,但每个请求都要带几百字节。
Cookie vs Header 只是运输方式——Cookie 里能放 JWT,Authorization 里也能放随机 id(API key 就是)。原文那张表描述的是惯例,不是原理。

6. 因为服务端根本不知道有这个 token 存在——token 在用户手机里,服务端手上只有一个密钥。脚本实测:改密码之后,session 立刻失效,而 token 仍然通过
它的全部好处(不查库)和全部坏处(不能吊销)来自同一件事
黑名单不是好补救,因为它等于又变成有状态了——你花力气换来的"不查库"又还回去。所以黑名单只对少量事件划算(改密码、登出、检测到盗号),不能当常态。

7. 因为不能吊销之后,"被偷能用多久"只有有效期一个旋钮,而 30 天 / 1 小时 / 5 分钟没有一档同时满足安全和体验——它们在一个 token 上不可能同时满足。
双 token 把矛盾拆开:Access(高频 + 无状态 + 短命,不查库)Refresh(低频 + 有状态 + 长命,能吊销)
账:1000 万请求、平均每用户 200 个,查库从 1000 万降到 5 万(1/200),而吊销能力一点没丢(删掉 refresh,最迟 15 分钟那人就出不去)。
通用形式:当一个东西要同时满足两个矛盾的要求时,先看能不能按【频率】拆成两个,让高频那个只承担便宜的要求。(37 讲读写分离、39 讲缓存都是这条。)
补一句:Session 顺延和 Refresh 不对等——Session 是延长同一个凭据的寿命,Refresh 是换另一个性质不同的凭据。Refresh Token 是为无状态付的税。

8. Cookie 是浏览器自动携带的 → 天生撞上 CSRFevil.com 上一个自动提交的表单就能带着你的 cookie 真的执行请求),必须额外做 CSRF token / SameSite / 查 Origin。
Authorization 头是代码主动加的 → 天生免疫 CSRF,但要自己管 token 放哪,存 localStorage 就换来 XSS 风险(脚本能读到;HttpOnly Cookie 读不到)。
两个风险是互补的,不是"一个更安全"。 今天主流答案是 HttpOnly + SameSite 的 Cookie 里放 token(同时躲开两个),代价是处理 CSRF 边界情况和跨域。

9. 之前的做法就是把密码给它,叫 password anti-pattern(早年的"导入通讯录"要你填邮箱密码、记账 App 要你填网银密码)。
给了密码等于给了五件事:① 读你想给的那部分 ② 读所有数据 ③ 改和删改掉你的密码把你锁在外面永久有效——你没法只收回它的权限,只能改密码(连自己一起断)。
于是 OAuth 的四条设计目标:范围可限、可吊销、有期限、不暴露密码。剩下所有复杂度都是为了同时满足这四条。

10. 因为授权码走浏览器重定向(会进 URL 栏、历史、Referer、代理日志),而真正的 token 走服务器到服务器、不经过浏览器(原文的 D 步那半句"这一步是在网站的后端服务器上完成的")。
一句话:把"要经过不可信信道的东西"和"值钱的东西"分开——授权码经过浏览器但不值钱,token 值钱但不经过浏览器。
四道闸:① 换 token 要带 client_secret(捡到码的人换不到)② 码绑定发放时的 client ③ 码是一次性的 ④ redirect_uri 必须预注册
第 4 道最容易实现错——必须精确匹配,历史上大量漏洞出在允许前缀/通配匹配或目标站上有 open redirect,于是码被送到攻击者域名下。
推广:协议里凡是"经过第三方信道"的东西,都应该一次性 + 短命 + 绑定用途(短信验证码、邮件重置链接、支付回调 token 全是这条)。

11. 两个维度:有没有用户在场(能不能点"同意")× 客户端能不能保住秘密(有没有后端)。
有用户 + 能保密 → 授权码模式;有用户 + 保不住密 → 授权码 + PKCE(原 Implicit 的位置);无用户 + 能保密 → 客户端模式(定时任务、服务间调用)。
而"访问令牌 / 更新令牌"根本不是入口,是拿到之后怎么用——原文自己说了"其他所有的授权方式,是在不同场景下的授权入口"。记住那两个问题就不用背六条。

12. 认证 = 你是谁(登录、验密码、验 ID Token);授权 = 你能干什么(scope、归属、角色)。
OAuth 2.0 名字里有 Auth,但它是一个授权框架(delegated authorization),不是认证框架。
所以"不含身份信息"不是缺陷,是设计范围:一个 access token 说的是"持有者被允许做 X",它从来没打算说"持有者是谁"。这正是需要 OpenID Connect 的根源。

13. 会出混淆代理(confused deputy):我做一个自己的应用 B 也接入微信登录,你在 B 上授权后 B 拿到你的 access token,B 把它拿去发给网站 A,A 一问微信"这是谁",微信说"是你",于是 B 的运营者用你的 token 登进了你在 A 的帐号。
根源是access token 上没写"我是发给谁用的"——它是一张不记名通行证。
ID Token 靠 **aud(audience,受众)**字段解决:写明"这个 token 是发给 client_id = A 用的",从 B 转手来的 token 的 aud 是 B,当场拒掉。
★ 所以 ID Token 补上的不只是"是谁",更关键的是"这条身份声明是说给谁听的"。

14. Implicit 被废弃,因为它直接把 access token 放在重定向 URL 的 fragment 里——干的正是"让值钱的东西经过浏览器"这件不能做的事,而且没有 refresh token。(OAuth 2.1 直接移除。)
它当初存在是因为 SPA 没有后端、保不住 client_secret
PKCE 一句话:把"长期写死在代码里的 client_secret"换成"一次性的、每次都不同的动态 secret"(发 SHA256(verifier) 去要码,换 token 时出示 verifier)——于是不需要保存任何秘密的客户端也能走授权码模式。
今天的标准答案:所有客户端都走授权码 + PKCE。

15.OAuth 登录 CSRF / 会话固定:攻击者先拿到他自己帐号的授权码不用,把回调链接发给你,你一点,第三方就把攻击者的帐号绑到了你的帐号上,之后你存的东西都进了攻击者的帐号。
state 让客户端在 A 步生成随机值存进自己的会话,C 步回来比一比——它把"这次回调"和"我这个浏览器发起的那次请求"绑在了一起,和 CSRF token 同一个原理。
区分:state 绑 授权请求 ↔ 回调;nonce 绑 授权请求 ↔ ID Token(防 token 重放)。

16. scope 是最小权限原则在协议层的表达。没有它,OAuth 只是"一把不用给密码的万能钥匙"——可吊销、有期限,但"能干什么"还是全部。
真正的难点在产品:太粗(“访问你的全部数据”)用户不敢同意或没得选;太细(40 个复选框)用户看不懂随手全选等于没有。好的设计是 3~7 条、每条一句人话、和用户心智模型对齐("读你的图"而不是 drawings.list.v2)。
推广:权限的粒度不是技术问题,是"人能不能在三秒内做出正确决定"的问题——给 AI Agent 发工具权限也是同一条。


下一步

带读 18 · 44 讲:dex、ID Token 与"不造轮子"

43 讲:把"可信性从哪来"讲清了,全是概念
44 讲:落地。而这一讲有两个很大的意外,是我 diff 过代码才发现的:
        ★ 意外一:原文让你去看的 compare/v42...v44 —— 是【空的】。
                  真正提交了代码的是另一个分支 v44-bear(自建帐号那条路)。
        ★ 意外二:那个分支里,paintdom(业务服务)【一个字节都没改】。
                  帐号授权的全部代码都落在网关上,而 42 讲那个 mock 的
                  `QPaintStub <uid>`,格式一个字没改就变成了【内网协议】。
        另外要补的:JWT 不是加密的(最常见的误解)· dex 那个形状你已经见过很多次

回看:
带读 16 · 42 讲mock 授权那个接缝 · 归属校验为什么写进查询)
笔记 · 40 讲(Token 与 AK/SK 两种授权方式的出处)
带读 13 · 39 讲幂等 · 无状态与一致性的那笔账)

公告栏
这是我的个人知识库。
记录技术,也记录生活 —— 读过的、试过的、想明白的,都堆在这儿。
最新文章
网站资讯
文章数目 :
5
已运行时间 :
本站总字数 :
15.7k
本站访客数 :
本站总访问量 :
最后更新时间 :
全局知识图谱
当前页面 已访问 文章 标签
ESC 关闭 · 滚轮缩放 · 拖拽移动 · Ctrl+G 开关