fix(plugin-auth): OTP 冷却按声明值真正生效 —— 发送历史保留时长不再被硬编码 1 小时截断 (#4808) - #4869
Conversation
…down it measures (#4808) `OtpSendGuard` enforces two dimensions with two different windows — a per-number cooldown (`cooldownSeconds`) and a per-number rolling-hour cap (`maxPerHour`) — but both were pruned, and stored with a TTL, at a flat one hour: the cap's window, borrowed for the cooldown. So `phoneOtp.cooldownSeconds` above 3600 was accepted, with no validation error and no warning, and then served as one hour, because the record the cooldown is measured from had already been dropped. A declared 2-hour cooldown was really 1 hour — half the declared anti-abuse strength on a PAID channel, silently (ADR-0049, declared != enforced). Same guard as #4790, a different defect; behaviour identical before and after #4806. History is now retained for `max(1 hour, cooldownSeconds)` — the longer of the two windows — with the TTL following it, so the entry outlives what it measures. The hourly cap keeps counting over its own rolling hour, so a long cooldown cannot make `maxPerHour` stricter than declared either. The bound is a rejection, not a higher truncation point: `cooldownSeconds` over MAX_COOLDOWN_SECONDS (86400 / 24h), negative or non-finite throws from `assertOtpCooldownSeconds()`, called from the `AuthManager` constructor so a bad config fails at boot rather than as a 500 on the first `/phone-number/send-otp`. Moving the truncation further out would only be the same defect one order of magnitude away. Default config is unchanged and pinned by a test: 60s cooldown, 5 per rolling hour, 3600s retention and TTL. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
复核通过 —— ACCEPT,已标 ready 并送合并队列这一单最有价值的部分是它纠正了 issue 本身,以及拒绝了 issue 倾向的那个方向并给出了更好的理由。两点都记录在案。 1. issue 只点到了一半 —— 真正截断冷却的是剪枝,不是 TTL我立单时写的是「历史 TTL 硬编码 1 小时」。dev 核实后发现:
两处都改了。只修 TTL 会得到一个看起来修好、实则照旧的 PR —— 而且它会通过一条只检查存储过期时间的测试。这正是本仓一直在关的那类「修了个相邻的东西」。 2. 拒绝方向 2 的理由比 issue 里我写的更准我在 issue 里把方向 2(配置校验期拒绝 > 3600)描述为「诚实、成本最低」。dev 不采纳,理由是:
这个判断对,我接受。把一个「本来就不该存在的耦合」固化成契约上的能力上限,是用文档掩盖设计问题。 3. 两个窗口被显式分开 —— 这是我准备提的问题,它先答了放宽保留时长最容易顺带犯的错,是让「每小时 5 次」的计数窗跟着变宽。代码注释里明确写死了:
新增 4. 上限是拒绝,不是又一次截断
而且校验从 顺带一个巧思:24 小时上限同时能抓住把 5. 反向验证 + 主路径未误伤,两者分开证明了把 时钟推进测试覆盖 59 / 61 / 119 分钟三点均被拒,并校验 61 分处 6. 测试抓到了作者自己过程中测试发现 dev 用 核对
Generated by Claude Code |
Fixes #4808
现状核实(先验证,再动手)
在
origin/main(cb5a75e,已含 PR #4806)上确认:OtpSendGuard.checkAndRecord()里有两处硬编码的一小时 —— 剪枝filter(t => now - t < HOUR_MS),以及写入时的store.set(key, ..., Math.ceil(HOUR_MS / 1000))。真正截断冷却的是剪枝那一处:即使存储的 TTL 是无限的,历史条目也会在 1 小时处被过滤掉,last随之消失,冷却判定无从下手。issue 只点到了 TTL,两处都要改。cooldownSeconds确实来自可配置项AuthManagerOptions.phoneOtp.cooldownSeconds(纯 TS 配置,不经packages/spec,本 PR 对 spec 零改动)。git show 8bd437f4b^里的旧实现同样是filter(t => now - t < HOUR_MS)+Math.ceil(HOUR_MS / 1000)的 TTL。CounterStore,但过期时间仍由 guard 自己作为store.set的第三个参数显式传入。所以 issue 的方案框架依旧成立,无需调整。为什么选方向 1(TTL 跟随配置),而不是方向 2(拒绝 > 3600)
关键认识是:这一小时根本不属于冷却,它是「每小时上限」的窗口,被借给了另一个维度。两个维度需要两个窗口,合用一个常量才是缺陷本身 —— 不是实现能力的固有上限。
改动
保留时长跟随配置。 历史保留
max(1 小时, cooldownSeconds)—— 「两个维度里还用得着它的那个更长的窗」;TTL 同步跟随,记录因此活得比它所度量的冷却更久。每小时上限仍在它自己的滚动一小时内计数(新增
withinHour),因此超长冷却不会反过来把maxPerHour收得比声明的更严。这一条今天是 belt-and-braces(落在更宽保留窗内的条目必然也落在冷却窗内,而冷却判定在前先行返回),代码里已如实注明 —— 写出来是为了让上限的窗口不会在下次任一窗口变动时,悄悄变成「冷却保留多久就算多久」。上限是拒绝,不是又一次截断。
MAX_COOLDOWN_SECONDS = 86400(24 小时),超出/负数/非有限值一律由assertOtpCooldownSeconds()抛错,错误信息给出值、上限和改法。把截断点挪到更高的数字只是把同一个缺陷往外推一个量级。设上限的理由:一条号码的历史会在共享缓存里驻留整个冷却期,而超过一天的封锁已经不是发送节流而是账号锁定(另一套机制、另一套管控);它同时把「误填成毫秒」挡在门外(5 分钟以上的意图都会被拒)。校验放在配置处,不是首次发送处。 guard 由
AuthManager.getOtpSendGuard()惰性构造,只在 guard 构造函数里校验的话,一个配置错误会表现为/phone-number/send-otp的 500。因此AuthManager构造函数(即AuthPlugin.init())也调用同一个校验函数 —— 一条消息、两个接缝、没有第二份规则。验收对照
DEFAULT config is unchanged: 60s cooldown, 5 per rolling hour, 1h retention—— 不传任何phoneOtp,断言 60s 冷却、5 条/小时上限、滚动窗生效,且每次写入的 TTL 仍是3600a 2-hour cooldown STILL rejects after the 1-hour mark—— 冷却设 2 小时,在 59 分 / 61 分(跨过旧的 1 小时边界) / 119 分推进时钟,三次均被拒,并断言 61 分处retryAfterSeconds ≈ 59 分;直到 121 分才放行测试
packages/plugins/plugin-auth全量 29 files / 658 tests 全绿,typecheck干净。新增 7 条测试(含跨节点共享存储下的长冷却、TTL 确实等于
7200而非3600、超长冷却不收紧每小时上限、超限/毫秒误填/负数/非有限值的拒绝、AuthManager启动期拒绝)。反向验证:把
retentionMs临时改回HOUR_MS以模拟修复前的行为,新增测试中的 3 条如期失败(2-hour cooldown STILL rejects…、跨节点那条、TTL 那条),而「默认配置不变」那条在改前改后都通过 —— 说明测试确实锁住了缺陷,且主路径未被误伤。changeset 等级:
patch按仓内同族做法(#4757 / #3456 / #3712 / #4728「守卫开始按声明值真正生效」均为 patch)。同一 guard 上的 #4790(PR #4806)同样是 patch,且其 changeset 明确列出了新增导出(
createLazyCounterStore()/counterStoreFromKv())—— 本 PR 的两个新增导出(MAX_COOLDOWN_SECONDS、assertOtpCooldownSeconds())同为纯增量,不改任何既有签名。关于「开始拒绝以前被接受的配置」这一行为收紧:被拒的那些值从未按声明工作过(要么被静默截断到 1 小时,要么被
Math.max(0, …)静默钳成 0 即关闭冷却),因此不存在依赖其旧行为的部署 —— 这是修复的一部分,而非独立的破坏性变更。范围
packages/spec零改动;未触碰packages/lint、skills/**、content/docs/**(含content/docs/releases/)。content/docs/permissions/authentication.mdx描述的是默认值(60s + 5/小时),本 PR 未改默认值,无需同步。🤖 Generated with Claude Code
https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny
Generated by Claude Code