令牌防重放不仅依赖行级排他锁
为密码重置接口实现一次性令牌:从哈希存储到并发重放测试
密码重置令牌加了过期时间,不少人就觉得这道防线差不多齐了。可过期时间只回答了一个问题:令牌什么时候作废。它没回答另一个更要命的问题——如果两次请求在作废之前同时读到同一条有效记录,会怎样。
想象一张只能入场一次的票,两个人在同一秒递到检票员面前。如果检票员只是各自看一眼日期,都觉得“还没过期”,那俩人可能都进去了。放到系统里,就是密码被改了两次,而用户只发过一次重置。
真正的解法不是把有效期缩得更短,而是让“校验令牌、改密码、消耗令牌”变成一件不可拆开的事。数据库事务加上对令牌行的排他锁,起的就是这个作用:后到的请求必须等先到的结束,先到的提交删除之后,后来者手里那个“有效”的判断就失效了。
不过,光把锁加上,还不算完,几个坑很现实。
一是“删了零行”被当成成功。代码如果只看有没有抛异常,不看受影响行数,令牌明明已经没了,接口照样返回成功。二是判断放在事务外,事后再给删除操作单独包一层事务,也补不回来——之前的“有效”结论已经作出,删不删都晚了。三是邮件链接如果一打开就消费令牌,安全扫描器提前访问一下,用户点进去就只剩“链接无效”。所以展示表单和真正提交新密码,得分成两步。
还有账号层面的问题。如果产品要求“重置成功后作废该账号其他所有重置链接”,单条令牌的行锁就不够用了,得有配套的账号级并发控制。申请接口返回统一话术也只是防账号探测的一环,响应状态、可观察的耗时差异、发送频率都得看,限流也不能省。
验证这块,光让两个请求“差不多同时发出”没什么说服力。更靠谱的是用两个独立连接,在请求入口设同步点,反复跑:同一令牌顺序重放、并发重放、过期令牌、存在与不存在的邮箱分别申请。并发测试要确认恰好一次成功,最终密码只对应成功的那次请求,异常回滚时也不能出现“密码已改、令牌仍可用”。
上线前还有件事得说清楚:重置密码后,其他已登录的会话撤不撤。这该由产品和安全策略拍板,不能靠实现细节碰运气。真要求立即失效,就得和改密放进同一个事务里处理,而不是先回一句“已退出其他设备”,再靠异步任务慢慢补。
行级排他锁是必要的一环,但它锁住的只是一条记录,锁不住那些散落在事务外、测试里和产品决策中的漏洞。

参与讨论
邮件被扫描器点掉太真实了