OAuth 2.0 授权码流程实测:4 次跳转,少个 state 就被劫持

点一次微信登录,浏览器先跳 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...

登录后就能在对话窗口输入需求,不需要装软件。

本文的页面就是用这条入口生成的。

相关推荐
deli0072 小时前
手机20%突然关机?电量百分比是估的,SOC曲线4组实测
前端
深蓝AI13 小时前
Meta 开源用了八年的设计系统 astryx:连坐两天 GitHub 总榜第一,组件库开始写给 agent 的说明书
前端·人工智能
IT_陈寒14 小时前
Vue的watch把我坑惨了,原来要这样用才靠谱
前端·人工智能·后端
1-1=014 小时前
Vue 3 模板语
前端·javascript·vue.js
用户76103044737114 小时前
【转战智能眼镜开发】ROKID AIUI 小游戏 亚马逊标枪项目实战讲解
前端
风卿14 小时前
看着AI写代码太孤独了,来欣赏星云吧:一个DSH 装饰插件
前端·javascript
牧艺14 小时前
弹层不用再手算坐标了:CSS Anchor Positioning 从原理到可上线
前端·javascript·css
用户老李14 小时前
YOLO 目标检测自学教程 07:YOLOv3——多尺度预测
前端
moshuying15 小时前
ESP32 墨水屏金价牌(上):从点亮到把图片推过去
前端