九月的认证里程碑里,邮箱验证码、找回密码和 Google 登录一起收尾。review 第一轮揪出的东西里,有一条跟限流有关,写下来是因为它跟「配几个数字」完全不是一回事。

先看代码顺序

better-auth 的 email-otp 插件在端点里先调 resolveOTP(email-otp/routes.mjs:31),无条件插一行验证码;发信的 sendVerificationOTP 回调在那之后才跑。而消费时,consumeVerificationValue(internal-adapter.mjs:818-860)永远只认「该 identifier 最新的一行」,并在消费时把全部行 deleteMany 掉。

旧代码把限流判断挂在发信回调里,也就是挂在那次插入之后。一旦命中:

  1. resolveOTP 已经写了一行新的码;
  2. 回调判断限流,拒发,这行码从没进过任何人的信箱;
  3. 用户输入手里那封信上的旧码,匹配到的是最新那行,INVALID_OTP;
  4. 两行一起被删光。

人被卡死:信箱里的码已死,冷却窗内又要不到新码,而错误信息说「验证码错误」,把账算在用户头上。

挪到插入之前

修法是挡在 hooks.before。dispatch 支持 before 钩子返回一个不含 context 键的对象来短路(runBeforeHooks 末尾 + dispatchAuthEndpoint 的 else if (before)),endpoint handler 完全不跑,那一行根本不会被写。

三条不变式都留着,而且各有断言:

  • 被限流的响应与「邮箱没注册」的响应逐字节一致(状态码 + body + content-type 都比);
  • 被限流不发信;
  • 窗口内第一次仍然只发一封。判断跑在插入之前以后,原来那个 assertOtpRateExcludingCurrent 的 -1 补偿也一并删了。

限流层自己会变成注册预言机

第二版的问题更隐蔽。resolveOTP 给已注册邮箱留一行持久的 verification 行,但 better-auth 对未注册邮箱会把刚插的行立刻删掉(防枚举,request-password-reset 和 forget-password/email-otp 都这样)。如果命中限流时抛 429,连打两次同一个地址,状态码序列 (200,429) 还是 (200,200) 就直接告诉攻击者这个邮箱有没有账号。

在 identifier 格式修对之前,计数器是哑的,不保护也不泄露;修对之后计数器第一次真的开始数到已注册邮箱,副作用才变得可观测。

路径命中限流时理由
找回密码静默不发信,端点照常 200「被限流」与「邮箱不存在」必须不可区分
邮箱验证429sign-up 端点本来就用 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL 单次泄露同一事实,而 /verify 的重发按钮需要真实反馈

这张表还有个附注:谁哪天把 sign-up 那个泄露堵上,必须同时把邮箱验证这条 429 也改成静默短路,否则就是堵了一半。

被拒的请求不能留行

小时配额数的是 verification 表的行。旧实现里被拒的请求也会留一行,于是攻击者连打 5 次就能把受害者的配额填满——只有 1 封信真的发出去,人却被锁一小时。

判断挪到 resolveOTP 之前以后,被拒不留行。回归里专门有一条:

expect(await countOtpRows('forget-password', email)).toBe(1)

改回去会数到 2。配额数的重新是「真发出去的信」。

抢救一条一次性的路径

同一轮里还有抢注仲裁。Google 登录撞上一个未验证邮箱时,会在 databaseHooks.account.create.after 里把抢注者的密码删掉。函数只推敲过「两次 delete 之间崩掉」,没推敲过「函数体里任何一处抛错」:

create.after → queueAfterTransactionHook(with-hooks.mjs:33 没给 onError,原样 re-throw)→ createWithHooks → linkAccount → 泛用 catch,返回 unable to link account。而 account 行已经提交,抛错撤不掉它。

用户看到一次登录失败,重试 → 走已链接分支(link-account.mjs 135–181),那条分支不触发钩子、却会把 emailVerified 置 true。终局:邮箱已验证、受害者用 Google 登进来了、抢注者的密码照常能用,而且事后无法辨认——它跟「本来就已验证的账号接上 Google」长得一模一样。

修法是把函数体整个包 try/catch,吞掉、不上抛,只打一条带 userId 的 [auth] 抢注仲裁失败 … 日志。不上抛的理由写进注释了:抛也撤不回已提交的链接,只会多一次莫名的登录失败,而重试走的是那条连日志都不打的分支,等于把洞藏得更深。测试 mock 掉 db.transaction 让它抛,断言返回 false、日志里有 userId、密码还在。

「请先验证邮箱」得点得动

同一轮还有人踩到另一条:注册后关掉标签页,第二天回来发帖被 403 挡住,提示「请先验证邮箱后再发布内容。」——一句没有任何可点内容的死话,/verify 全站只从注册页和 Google 按钮链出去。就算摸到了 /verify,页面开口就说「验证码已发到你的邮箱」,而这一页从不在加载时发码,那句是假话。

改成 email_unverified 渲染成「文案 + 指向 /verify 的链接」,换了八个用户侧渲染点。/verify 的文案改成「填入你收到的 6 位验证码……没收到就点下面的按钮要一个」,三语同改;重发按钮从 ghost 提到 outline。不做进页自动发码,那会让每次刷新烧掉一次按邮箱的小时配额。

空串不是「没配」

部署侧还有一条同类的。文档四处写着「漏传邮件变量不报错,只是行为不对,MAIL_TRANSPORT 没传会默认成 console」。这是假的:Compose 把宿主未设置的变量替换成空串,而 ?? 'console' 和 .default() 都只认 undefined,parseMailEnv({ MAIL_TRANSPORT: '' }) 直接抛,表现是 api 容器起不来。

崩溃是更好的行为,所以不给空串开豁免,改成把契约写清:这组变量必须存在且非空,看到容器起不来先查邮件配置。mail/config.test.ts 两条断言钉住它。

测试和 e2e 的脚本里则硬前缀 MAIL_TRANSPORT=console。shell 环境变量优先于 --env-file,没有这个前缀的话,本地把 MAIL_TRANSPORT 配成 resend 之后:跑一次测试就往真实邮箱发一批信,e2e 还会因为读不到 stdout 里的验证码直接失败。

收尾

限流不是一组数字,是写序里的一个位置。位置错了,它拦住的不是攻击者,是用户手里那封信。

后来这几条都变成了断言:429 的字节、只发一封、被拒不留行、失败留 userId、空串必崩。它们单独看都挺琐碎,但合起来才说明限流被放在了正确的地方。