从“回跳已过期”到安全交接:一次 OAuth 账号系统联调复盘
记录一个真实账号系统从密码与通行密钥,到多家 OAuth、跨站回跳、安全审计和生产回归的完整联调过程。
这不是一篇把 OAuth 参数抄一遍的教程,而是一次真实联调的复盘。
一个账号系统从“能登录”走到“能长期安全地登录”,中间会遇到很多看起来互不相关的问题:密码长度到底由谁限制、没有邮箱的第三方账号能不能自动注册、企业内部 OAuth 是否适合公开绑定、浏览器为什么显示“回跳已过期”,以及部署完成后究竟是不是用户拿到了最新代码。
这次工作围绕 AcoFork 的账号页展开。前端是 Astro,认证后端是独立的 Auth Worker;网站页面和认证服务不在同一个 origin,第三方平台又各自有不同的协议和权限模型。真正困难的部分,不是把按钮画出来,而是让每个边界都能被验证。
📝 备注
本文只保留架构、排查过程和可复用经验。应用密钥、Token、Cookie、邮箱、真实用户标识和完整敏感查询参数均已省略。
先把账号规则说清楚
密码只设低水位,不要替用户决定上限
早期的密码表单和后端写入接口都采用了 8–16 位限制。它确实能挡住过短密码,却也带来一个很直观的困惑:用户明明输入了更长、更难猜的密码,为什么系统不允许保存?
这次统一调整为 8–64 位,覆盖四条写入路径:
- 注册;
- 忘记密码后的重置;
- 已登录账号修改密码;
- 管理员为账号设置密码。
登录接口本身不再增加长度上限。登录时应该验证提交值与已存储哈希是否匹配,而不是因为新策略的上限拒绝历史密码。这样既能兼容存量账号,也不会把“写入策略”和“读取验证”混为一谈。
没有邮箱,就不要假装有邮箱
OAuth 平台返回的字段并不统一。有些平台会返回已经确认的邮箱,有些只返回平台用户 ID、昵称和头像;Steam OpenID 更不会凭空提供站点邮箱。
因此未注册用户的规则必须是:
- 第三方返回真实可用邮箱,才自动创建本站账号;
- 没有邮箱时,不创建新账号,也不根据平台 ID 拼一个“看起来像邮箱”的地址;
- 页面明确引导用户使用能提供邮箱的 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 的接口和权限状态也不能用“已经拿到一组密钥”来推断可用。提供商的审核、权限点和接口开通状态,是集成的一部分,而不是部署之后才考虑的附加条件。
所有提供商最终都落到同一类回调形状,例如:
https://i.2x.nz/api/auth/<provider>/callback回调地址固定、服务端 allowlist 固定,不能让浏览器提交一个任意 redirect_uri 就改变跳转目的地。
一套可审计的 OAuth 主流程
账号页不直接把第三方 Token 交给前端。登录或绑定从服务端开始,callback 只生成一次性的 bridge,前端再用 bridge 换取本站会话。
这里有几个不能省略的检查点:
- 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 给出了更具体的答案。失败序列大致是:
/api/auth/github/start返回 200,并且明确处于 link 模式;- 前端请求
/api/auth/github/bootstrap; - bootstrap 在跳往 GitHub 之前就返回
invalid_state; - 后续没有 GitHub 授权页,也没有 GitHub callback;
- 账号状态接口仍然可以返回 200。
这说明 state 不是自然等待五分钟后过期,而是在“浏览器页面 → Auth Worker”的交接阶段没有通过校验。更重要的是,HAR 中已经出现了包含 bootstrap 逻辑的生产前端资源,所以这不是单纯的用户缓存没有更新。
为什么只在一部分浏览器里出现
link-mode 原来的 bootstrap 请求依赖当前登录态重新认证。可是前端的本站 Bearer Token 保存在浏览器端,顶层导航到 Auth Worker 时不会自动复制原页面的 Authorization 请求头。后端只能看到一个可能存在、也可能因为跨站 Cookie 策略而不可用的 __Host-session。
于是出现了一个很隐蔽的组合:
前端:我有一个有效的 Bearer 登录态
顶层导航:我只会带浏览器决定发送的 Cookie
后端:这个请求里没有能证明绑定用户的登录态
页面:把 invalid_state 翻译成“回跳已过期”这不是降低 state 校验就能解决的问题。直接相信 ticket 里的 user ID,会把“修复回跳”变成账号越权风险。
修复方式:同源 popup handoff
修复后的绑定流程在用户点击按钮的同步阶段先打开一个 popup,避免异步安全确认完成后再开窗而被浏览器拦截。Auth Worker 返回一个不可直接信任为登录凭据的短时 bootstrap ticket,popup 回到第一方 handoff 页面后:
- handoff 只向精确匹配的 opener origin 发送 ready 消息;
- 主页面只在精确匹配 popup 且来源正确时发送内存中的 Bearer;
- popup 通过同源 POST 把 Bearer、ticket、时间戳和 Nonce 交给 Auth Worker;
- 服务端重新验证 ticket、签名 state、provider、授权地址、绑定模式和用户一致性;
- 通过后才设置 state/PKCE Cookie,并把第三方授权地址返回给主页面;
- Token 不进入 URL,popup 完成后关闭,主页面继续走正常 provider 授权。
这个 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 恢复会话。这个现象会让测试者误以为“匿名登录成功了”,实际上只是旧会话被恢复。
因此干净测试需要显式准备环境:
- 在浏览器站点数据设置中分别删除
2x.nz和acofork.com的条目; - 关闭仍指向这两个注册域名的旧标签页;
- 新开标签页进入登录页,确认页面确实处于未登录状态;
- 保留第三方平台的登录态,这样可以测试授权流程,而不必把所有外部账号一起登出;
- 分开测试“未登录 OAuth 登录”和“已登录 OAuth 绑定”,不要用一个流程的残留状态证明另一个流程;
- 记录最终页面状态和错误分类,不记录 URL 中的 code、ticket 或其他敏感值。
如果测试工具无法操作浏览器的站点数据管理界面,就不要把“退出按钮点过了”当成完整清理。应该明确标注测试边界,要求人工清理或换用隔离的浏览器配置后再复测。
结语:OAuth 的难点是边界,不是按钮
这次联调最后留下的不是一组 provider 按钮,而是一套边界意识:
- 密码长度给用户足够空间,防爆破交给限流和安全策略;
- 没有邮箱就不要自动创建无法恢复的账号;
- 第三方审核状态不等于接口可用;
- HTTP 200 不等于流程成功,必须继续看响应和最终状态;
- 跨 origin 交接要传递证明,不要传递裸 Token,更不能把 Token 放进 URL;
- 生产部署要核对实际项目名、版本和资源,而不是相信某个旧页面;
- 测试环境要把浏览器 Cookie 和本地数据当作真实依赖管理。
当这些规则都能被代码、日志和测试步骤分别证明时,“回跳已过期”就不再是一句让人束手无策的提示,而会变成一条可以定位、修复和回归的工程链路。
评论
评论来自 GitHub Discussions,登录 GitHub 后即可参与。