点一次微信登录,浏览器先跳 2 次 302,后端再发 2 次请求,才换回 1 个 access_token。
整个过程中,access_token 在浏览器地址栏里出现 0 次。
我把它做成一个可点的网页实验室,跑了 10 条断言,又试了 3 种攻击。
少一个 state,账号就能被绑到攻击者的会话里。

一、为什么值得看:登录按钮背后有 4 段请求
微信登录、GitHub 登录、Google 登录,点下去都做同一件事。
浏览器被交给授权服务器,再带着一张一次性票据回来。
这张票据叫授权码。
它只能用一次,有效期通常 10 分钟,换 token 的动作由后端完成。
授权码模式因此有两个硬要求:state 防 CSRF,PKCE 防授权码被截。
少一个,登录流程看起来照常,账号却可能换成别人的。
我把这四段请求逐步点了一遍,又把三个开关逐个关掉,看攻击从哪里进来。
二、为什么我把这件事做成网页
文档里的时序图是静态的。
字段在哪一步出现、在哪一步消失,光看箭头很难记住。
我想同时回答三个问题。
code 在浏览器里停留多久。
token 什么时候进后端。
关掉 state 或 PKCE 后,攻击具体走到哪一步。
网页把这些问题放在同一屏:左边改参数,中间看消息,右边看断言。
码道有三种使用方式:WebUI(浏览器对话)、TUI(终端命令行)和桌面 IDE(IDE 插件)。本文用 WebUI 版。
三、这轮码道实际做了什么
我给码道的需求是一句话:做一个单文件 index.html 的 OAuth 2.0 授权码流程实验室,三泳道、四模式、三种攻击、十条断言。
它交付了一个 954 行、38.7 KB 的 index.html,没有外部 CDN、字体和图片。
左侧是参数配置和攻击入口,中间是浏览器、授权服务器、业务后端三条泳道,右侧是模式对照表和安全断言,底部是时间轴。
首版能跑,但断言是装饰性的。
比如 code 只能用一次这一条,直接写死 pass,没有任何计算。
隐式模式 token 在 fragment 这一条,判断条件是 currentMode !== 'implicit' || true,永远为真。
我把它重写成 10 条带返回值的模拟,页面上显示的是函数真实返回的状态码。
第二个问题是消息卡片重叠。
首版每张卡片按 90px 递进,但 PKCE 模式有 7 个字段,一张卡片就有 150px 高。
改成 170px 递进后,再把长字段单独占一行,字段和方向才不再压在一起。
第三个问题是第三条消息的宽度溢出。
它从浏览器跨到业务后端,宽度算出来比泳道还宽,直接盖住了右侧面板。
我按泳道宽度做了截断,卡片才回到自己的区域。

四、四步流程,每一步传了什么
授权码模式在页面里是 5 段消息。
前 4 段是请求,最后 1 段是 token 响应。
| 步骤 | 方向 | 带什么 | 谁看得见 |
|---|---|---|---|
| 1 | 浏览器 → 授权服务器 | response_type=code、client_id、redirect_uri、scope、state | 浏览器地址栏 |
| 2 | 授权服务器 → 浏览器 | code、state | 浏览器地址栏 |
| 3 | 浏览器 → 业务后端 | code | 后端日志 |
| 4 | 业务后端 → 授权服务器 | grant_type、code、client_id、client_secret | 后端 |
| 5 | 授权服务器 → 业务后端 | access_token、refresh_token、expires_in | 后端 |
严格说,只有前两步是浏览器 302。
后两步是后端请求和响应。
标题里的 4 次跳转,把 4 段请求都算上,最后那段 token 返回没有单独算一次跳转。
这样数更接近开发者嘴里的"走一遍登录流程"。
code 在浏览器里只出现一次。
第 3 步把它交给后端后,浏览器手里就没有 token 了。
页面里的"前端 URL 里的 token"计数器一直是 0。
隐式模式跳过第 3、4 步,token 直接放在地址栏 fragment 里。
同一个计数器会跳到 1。
这就是它被废弃的原因,不是 token 不够随机,是它出现在了不该出现的地方。
五、三种攻击,只有一种能拦住
页面左侧有三个攻击入口。
我把每个开关单独关掉,再点一次攻击,结果如下。
| 攻击 | 关闭的配置 | 攻击结果 | 打开配置后 |
|---|---|---|---|
| CSRF 登录劫持 | state 校验 | 被攻破 | 已拦截 |
| 授权码截获 | PKCE | 被攻破 | 已拦截 |
| redirect_uri 子域拼接 | 精确匹配 | 被攻破 | 已拦截 |
CSRF 的路径最容易被忽略。
攻击者先用自己的账号拿到 code。
再把带 code 的回调链接发给受害者。
后端不校验 state,就会用这个 code 换 token,把受害者的浏览器绑到攻击者账号。
页面把这条链路拆成四步显示,最后一句结论是"受害者浏览器绑定了攻击者的账号"。
修法不复杂:state 随机、一次性、和当前会话绑定,回调时严格比对。
PKCE 解决的是公共客户端没有 client_secret 的问题。
verifier 留在客户端,challenge 先发给授权服务器,换 token 时再交 verifier。
我用 RFC 7636 的示例向量对了一遍。
verifier 是 dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk。
算出的 challenge 是 E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM。
篡改 verifier 最后一位,S256 对不上,返回 400 invalid_grant。
redirect_uri 的通配符更隐蔽。
规则写成 https://myapp.com/* 时,callback.evil.com 和 /x?code=1 都能通过前缀检查。
页面里精确规则放行 0/3 个伪造域名,通配符规则放行 2/3。
剩下那个是 myapp.com.evil.com,前缀检查也拦住了它,但精确匹配同样不会放行。
结论还是那句:redirect_uri 只做精确匹配。

六、验证与踩坑:10 条断言和三次修正
10 条断言打开就自动跑。
授权码模式 10/10。
授权码加 PKCE 同样 10/10。
几条关键断言的返回值如下。
| 断言 | 实测返回值 |
|---|---|
| code 只能使用一次 | 首次 200,二次 400 invalid_grant |
| code 有效期 600 秒 | 599s 有效,601s 返回 400 |
| state 不匹配必须拒绝 | 匹配 200,篡改 400 state_mismatch |
| PKCE verifier 不匹配 | S256 匹配 200,篡改末位 400 invalid_grant |
| access_token 不写进历史 | 授权码模式 0 条,隐式模式 1 条 |
| redirect_uri 精确匹配 | 精确放行 0/3,通配符放行 2/3 |
隐式模式只有 9/10。
红的是一条"access_token 不写进浏览器历史"。
这不是页面算错,是隐式模式本身的问题。
客户端凭证模式也是 10/10,但它没有用户参与,不能拿来当登录方案。
三次修正里,有一次是断言自己写错。
第一版第 8 条把精确规则和通配符规则的放行数写反了,页面显示 9/10。
改成同时计算两套规则后,才回到 10/10。
还有一次是隐式模式的 refresh_token 断言。
隐式模式根本不发 refresh_token,判 FAIL 不符合事实。
改成"本模式不发放"后,失败项只剩 token 进 URL 那一条。
失败路径也留了入口。
把 redirect_uri 改成 http://myapp.com/callback,页面弹出中文提示。
提示里写了两条原因:不能用 HTTP,以及未在登记白名单里。
关掉弹窗后,输入框回到上一次有效值。


边界也要说清楚。
这是一个前端模拟器,不是真实的身份提供方。
token 字符串是假的,时间轴是动画,不是真实网络延迟。
S256 用了 WebCrypto 真算,但 token endpoint 没有真的发请求。
它能解释流程和攻击路径,不能用来给一个线上系统做安全验收。
七、本地复现
整页只有一个 index.html。
在文件所在目录起一个静态服务:
yaml
python -m http.server 8080
浏览器打开 http://localhost:8080/index.html 就能跑。
五张截图都是 1920×1080,分别是开始界面、授权码流程、PKCE、隐式模式和攻击失败路径。
案例和源码放在公开仓库:gitcode.com/OPCTeam/dem...
打开后直接点"开始授权流程",不需要装依赖。
八、使用码道体会
这次最省事的是界面骨架。
一句话需求,码道把三泳道、模式表、时间轴和攻击卡片都搭齐了。
需要我补的是协议细节和判定逻辑。
第一版断言写死 PASS,就是典型:界面像样,证据不成立。
我的用法是:让码道做界面和计算骨架,自己盯住字段、断言和失败路径。
下一步想加的是本地 token endpoint mock,把过期、刷新和撤销也跑通。
那一步更接近真实授权服务器。
九、准备环境:进入码道 Web
浏览器打开码道 Web 版:devcloud.cn-north-4.huaweicloud.com/chat?source...
登录后就能在对话窗口输入需求,不需要装软件。
本文的页面就是用这条入口生成的。