AcoFork 旧站归档 · 第 19 次重构 · 已归档,仅供怀旧展示
← 返回博客

从“回跳已过期”到安全交接:一次 OAuth 账号系统联调复盘

记录一个真实账号系统从密码与通行密钥,到多家 OAuth、跨站回跳、安全审计和生产回归的完整联调过程。

从“回跳已过期”到安全交接:一次 OAuth 账号系统联调复盘

这不是一篇把 OAuth 参数抄一遍的教程,而是一次真实联调的复盘。

一个账号系统从“能登录”走到“能长期安全地登录”,中间会遇到很多看起来互不相关的问题:密码长度到底由谁限制、没有邮箱的第三方账号能不能自动注册、企业内部 OAuth 是否适合公开绑定、浏览器为什么显示“回跳已过期”,以及部署完成后究竟是不是用户拿到了最新代码。

这次工作围绕 AcoFork 的账号页展开。前端是 Astro,认证后端是独立的 Auth Worker;网站页面和认证服务不在同一个 origin,第三方平台又各自有不同的协议和权限模型。真正困难的部分,不是把按钮画出来,而是让每个边界都能被验证。

📝 备注

本文只保留架构、排查过程和可复用经验。应用密钥、Token、Cookie、邮箱、真实用户标识和完整敏感查询参数均已省略。

先把账号规则说清楚

密码只设低水位,不要替用户决定上限

早期的密码表单和后端写入接口都采用了 8–16 位限制。它确实能挡住过短密码,却也带来一个很直观的困惑:用户明明输入了更长、更难猜的密码,为什么系统不允许保存?

这次统一调整为 8–64 位,覆盖四条写入路径:

  • 注册;
  • 忘记密码后的重置;
  • 已登录账号修改密码;
  • 管理员为账号设置密码。

登录接口本身不再增加长度上限。登录时应该验证提交值与已存储哈希是否匹配,而不是因为新策略的上限拒绝历史密码。这样既能兼容存量账号,也不会把“写入策略”和“读取验证”混为一谈。

没有邮箱,就不要假装有邮箱

OAuth 平台返回的字段并不统一。有些平台会返回已经确认的邮箱,有些只返回平台用户 ID、昵称和头像;Steam OpenID 更不会凭空提供站点邮箱。

因此未注册用户的规则必须是:

  1. 第三方返回真实可用邮箱,才自动创建本站账号;
  2. 没有邮箱时,不创建新账号,也不根据平台 ID 拼一个“看起来像邮箱”的地址;
  3. 页面明确引导用户使用能提供邮箱的 OAuth,或先手动注册本站账号,再回到账号页绑定第三方。

这条规则看起来保守,却避免了两个严重问题:账号无法找回,以及把一个无法证明所有权的第三方身份直接变成本站登录凭据。

多个提供商,不等于一套代码换名字

这次联调涉及通行密钥、GitHub、Google、Microsoft、Discord、LinuxDO、Cloudflare、Gitee、阿里云、Telegram、GitLab 和 Steam 等路径。它们需要先按协议分类,再统一到本站的账号绑定模型里。

提供商类型 协议或特点 账号策略
常规 OAuth 2.0 授权码换 Token,再读取用户资料 只接受白名单回调和服务端验证后的身份
OAuth 2.0 + PKCE 浏览器侧需要 verifier,服务端换 code 时核对 challenge verifier 只能放在受保护的短期状态中
Telegram 新版登录 OIDC 授权码,服务端校验 ID Token 和 JWKS 使用稳定 subject;没有邮箱时不能自动建号
GitLab read_user 读取资料,邮箱必须按返回状态判断 真实邮箱可建号,否则走绑定/手动注册路径
Steam OpenID 2.0,不是 OAuth,也不提供邮箱 服务端二次验签,只能作为已有账号绑定或无邮箱路径
企业内部授权 钉钉、飞书等通常面向组织成员 不默认放进公开账号绑定入口

QQ 在审核期间无法作为可靠的公开回归对象;X 的接口和权限状态也不能用“已经拿到一组密钥”来推断可用。提供商的审核、权限点和接口开通状态,是集成的一部分,而不是部署之后才考虑的附加条件。

所有提供商最终都落到同一类回调形状,例如:

text
https://i.2x.nz/api/auth/<provider>/callback

回调地址固定、服务端 allowlist 固定,不能让浏览器提交一个任意 redirect_uri 就改变跳转目的地。

一套可审计的 OAuth 主流程

账号页不直接把第三方 Token 交给前端。登录或绑定从服务端开始,callback 只生成一次性的 bridge,前端再用 bridge 换取本站会话。

flowchart LR A[账号页] -->|POST start + 安全请求头| B[Auth Worker] B -->|签名 state + 加密 bootstrap ticket| A A -->|授权码流程| C[第三方平台] C -->|callback code + state| B B -->|一次性 OAuth bridge| A A -->|POST /api/auth/bridge| B B -->|本站会话 Cookie / 会话 Token| D[账号中心]

这里有几个不能省略的检查点:

  • state 必须由服务端签名,并同时受到 HttpOnly Cookie 的 double check 保护;
  • state 里要有 provider、mode、redirect 和一次性 jti,不能只放一个用户可读的随机字符串;
  • OAuth 2.0 provider 使用 PKCE 时,code verifier 必须和 state 一起受到保护;
  • 回调只能跳转到生产 allowlist 中的站点;
  • bridge 必须一次性、短时有效、加密保存,不把会话 Token 放进 URL;
  • 绑定操作必须再次确认当前登录用户,不能只相信前端传回来的 user ID;
  • 所有写操作都带时间戳和 Nonce,缺少安全请求头时直接拒绝。

真正的 bug:回跳还没有到第三方就失效了

最初看到用户反馈时,页面只显示了一句“登录回跳已过期”。直觉上很容易先怀疑:用户是不是打开了旧页面?前端是不是没有发布?第三方是不是回调太慢?

HAR 给出了更具体的答案。失败序列大致是:

  1. /api/auth/github/start 返回 200,并且明确处于 link 模式;
  2. 前端请求 /api/auth/github/bootstrap
  3. bootstrap 在跳往 GitHub 之前就返回 invalid_state
  4. 后续没有 GitHub 授权页,也没有 GitHub callback;
  5. 账号状态接口仍然可以返回 200。

这说明 state 不是自然等待五分钟后过期,而是在“浏览器页面 → Auth Worker”的交接阶段没有通过校验。更重要的是,HAR 中已经出现了包含 bootstrap 逻辑的生产前端资源,所以这不是单纯的用户缓存没有更新。

为什么只在一部分浏览器里出现

link-mode 原来的 bootstrap 请求依赖当前登录态重新认证。可是前端的本站 Bearer Token 保存在浏览器端,顶层导航到 Auth Worker 时不会自动复制原页面的 Authorization 请求头。后端只能看到一个可能存在、也可能因为跨站 Cookie 策略而不可用的 __Host-session

于是出现了一个很隐蔽的组合:

text
前端:我有一个有效的 Bearer 登录态
顶层导航:我只会带浏览器决定发送的 Cookie
后端:这个请求里没有能证明绑定用户的登录态
页面:把 invalid_state 翻译成“回跳已过期”

这不是降低 state 校验就能解决的问题。直接相信 ticket 里的 user ID,会把“修复回跳”变成账号越权风险。

修复方式:同源 popup handoff

修复后的绑定流程在用户点击按钮的同步阶段先打开一个 popup,避免异步安全确认完成后再开窗而被浏览器拦截。Auth Worker 返回一个不可直接信任为登录凭据的短时 bootstrap ticket,popup 回到第一方 handoff 页面后:

  1. handoff 只向精确匹配的 opener origin 发送 ready 消息;
  2. 主页面只在精确匹配 popup 且来源正确时发送内存中的 Bearer;
  3. popup 通过同源 POST 把 Bearer、ticket、时间戳和 Nonce 交给 Auth Worker;
  4. 服务端重新验证 ticket、签名 state、provider、授权地址、绑定模式和用户一致性;
  5. 通过后才设置 state/PKCE Cookie,并把第三方授权地址返回给主页面;
  6. Token 不进入 URL,popup 完成后关闭,主页面继续走正常 provider 授权。

跨站 OAuth 安全交接的示意图

这个 handoff 解决的是“认证材料如何跨浏览器上下文抵达服务端”,不是“绕过认证”。真正的用户绑定仍然由服务端决定,登录模式也继续保留原有的 Cookie 和 state 校验。

安全审计中最值得留下的几条规则

不要因为错误文案简单,就让校验变简单

“回跳已过期”是面向用户的模糊文案,日志和诊断则要保留有限的内部分类:是 state 缺失、Cookie 不匹配、ticket 解密失败、provider 不匹配,还是第三方资料接口失败。外部不应泄露 state 生命周期、限流阈值或令牌细节,内部也不应把完整 Token 和邮箱写进 HAR 或日志。

账号绑定必须 fail closed

当第三方身份已经绑定到另一个账号、邮箱出现重复、用户身份无法确认、数据库查询暂时失败,服务端不能“先绑定再说”,也不能随机挑一个账号继续。绑定是不可逆影响很大的动作,无法证明所有权时必须停下来,让用户走明确的恢复或手动绑定流程。

安全证明要短时且可消费

密码登录只是第一因素。修改密码、解绑 OAuth、删除凭据等敏感操作要经过邮箱码、TOTP、恢复码或通行密钥等 step-up,并使用短时 action token。原始密码不应该被当作所有敏感操作的通用通行证,step-up token 也不应长期放进 localStorage。

生产验证不能拿 D1 当测试夹具

真实账号回归和生产数据库测试是两回事。生产 OAuth 测试只执行用户明确授权的实际绑定/解绑,并确保恢复原状态;测试脚本、重置 routine 和迁移演练必须使用 remote: false 的独立本地数据库。任何生产数据库变更之前,都要先做可恢复备份,记录范围和回滚路径。

发布之后,如何证明修复真的到了用户那里

这次前后端采用了不同发布路径:

  • Auth Worker 由 Wrangler 部署到 i.2x.nz,部署后核对版本 ID 和自定义域名触发器;
  • Astro 前端只推送到 main,由 CI/CD 自动构建和发布;
  • Cloudflare 查询使用实际的 Worker 名称 astro-aio-2x,不能凭印象写成旧项目名称;
  • 前端不能只看入口 HTML,必须进一步确认新的页面组件 bundle 已经被生产引用。

静态检查通过只是起点。完整回归至少要包含:一条登录流程、一条已登录绑定流程、一次错误回跳检查,以及一次 provider 资料字段检查。GitLab 的实际回归采用了“解绑 → 到达真实授权页 → 确认授权 → 回到账号页”的完整链路,最后连接状态恢复为已绑定,没有出现 invalid_state 或“回跳已过期”。

最后一个容易被忽略的变量:浏览器不是无状态测试机

排查完服务端之后,还有一个容易被忽略的事实:浏览器本身会保留很多状态。

前端 logout 可以清除 React 内存态和本站 Token,但跨站的 HttpOnly Cookie 可能由于 Cookie 策略无法在普通跨站请求中被清掉。下一次顶层加载时,Auth Worker 仍可能凭 Cookie 恢复会话。这个现象会让测试者误以为“匿名登录成功了”,实际上只是旧会话被恢复。

因此干净测试需要显式准备环境:

  1. 在浏览器站点数据设置中分别删除 2x.nzacofork.com 的条目;
  2. 关闭仍指向这两个注册域名的旧标签页;
  3. 新开标签页进入登录页,确认页面确实处于未登录状态;
  4. 保留第三方平台的登录态,这样可以测试授权流程,而不必把所有外部账号一起登出;
  5. 分开测试“未登录 OAuth 登录”和“已登录 OAuth 绑定”,不要用一个流程的残留状态证明另一个流程;
  6. 记录最终页面状态和错误分类,不记录 URL 中的 code、ticket 或其他敏感值。

如果测试工具无法操作浏览器的站点数据管理界面,就不要把“退出按钮点过了”当成完整清理。应该明确标注测试边界,要求人工清理或换用隔离的浏览器配置后再复测。

结语:OAuth 的难点是边界,不是按钮

这次联调最后留下的不是一组 provider 按钮,而是一套边界意识:

  • 密码长度给用户足够空间,防爆破交给限流和安全策略;
  • 没有邮箱就不要自动创建无法恢复的账号;
  • 第三方审核状态不等于接口可用;
  • HTTP 200 不等于流程成功,必须继续看响应和最终状态;
  • 跨 origin 交接要传递证明,不要传递裸 Token,更不能把 Token 放进 URL;
  • 生产部署要核对实际项目名、版本和资源,而不是相信某个旧页面;
  • 测试环境要把浏览器 Cookie 和本地数据当作真实依赖管理。

当这些规则都能被代码、日志和测试步骤分别证明时,“回跳已过期”就不再是一句让人束手无策的提示,而会变成一条可以定位、修复和回归的工程链路。

Community notes

评论

评论来自 GitHub Discussions,登录 GitHub 后即可参与。