30 秒的验证码为什么总在提交时失效
现象
写了个脚本自动填登录表单,密码那关一直很顺,卡在两步验证:TOTP 六位数填进去、点确认,返回「验证码错误」。手动在手机上取一个码填进去就成功。
同一套代码、同一个种子,为什么手动的行、自动的不行?问题不在算法,在时间窗口。
TOTP 的窗口是滑动的,不是固定的
TOTP 的算法是 HMAC(secret, floor(unix_time / 30)),每 30 秒产生一个新码。关键在于:码的有效期不是从你取码那一刻开始算 30 秒,而是跟着绝对时间走。
假设现在是 14:39:28,距下一个窗口只剩 2 秒。这时候取到码,走完这些步骤:
- DOM 定位六个输入框 ≈ 0.2s
- 逐个派发 input / keyup 事件 ≈ 0.3s
- 点击「验证」 ≈ 0.1s
- 请求飞到服务端 ≈ 0.3~1s
快到 1 秒的链路,放在 2 秒余量里就是运气。服务端收到时已经跨过 30 秒边界,校验的是新窗口的码,自然对不上。
手动操作反而快:人看一眼手机、敲六位数字、回车,全程一两秒,但通常是看到码就立刻动手,不会先做别的。自动化脚本容易在中间插入截图、日志、状态检查,把余量吃光。
怎么判断自己踩了这个坑
取码时顺手打印剩余秒数:
import time
remain = 30 - (int(time.time()) % 30)
if remain < 8:
time.sleep(remain + 0.5) # 等下一个窗口,拿一个"新鲜"的码
这个判断很便宜,能消掉大部分偶发失败。阈值取 8 秒是经验值:链路稳定时 3 秒够,网络慢或页面重时给到 8 秒更稳。
第二个坑:六个输入框不能粘贴
很多站点的 TOTP 输入框是六个独立的 input[type=tel],而不是一个 length=6 的输入框。这类控件有三个特征:
- 直接对第一个框
fill("123456")只会得到一位 - 剪贴板粘贴无效,或者只粘进第一个框
- 用
.value =赋值不会触发校验,按钮仍是灰的
正确做法是对每个框单独设值,并且用原生 setter 派发事件:
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype, 'value').set;
inputs.forEach((inp, i) => {
inp.focus();
setter.call(inp, code[i]);
inp.dispatchEvent(new Event('input', { bubbles: true }));
inp.dispatchEvent(new Event('change', { bubbles: true }));
inp.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: code[i] }));
});
特别是 keyup,很多前端组件靠它判断「这一位填完了、跳下一格」。少了这个事件,值填进去了但组件状态还是空的。
第三个坑:确认按钮不是 button
那次失败的最后一个原因是点错了元素。页面里文本为「验证」的元素有三个:
div.button-base-box 宽度 106 外层容器,点击无效
div.dialogFooterBtn 宽度 106 底部容器,点击无效
div.dialog-btn.btn-next 宽度 106 真正的按钮
三个都是 div,尺寸一样,只有 class 能区分。querySelectorAll('button') 抓不到它们,el.click() 对前两个无效,用真实鼠标点击落到正确坐标才行。
判断办法:拿 class 里有没有框架自己的按钮标识(这里是 dialog-btn),别只看文本和尺寸。
一个可用的检查顺序
- 先确认种子和算法对:用同一个 secret 在本地算码,和手机上显示的码比对。对不上说明 secret 或算法(SHA1/SHA256、位数、周期)错了。
- 再确认时间:取码前后各
date -u一次,服务端和本地时差超过 30 秒,任何码都不会对。 - 最后才是窗口余量。余量不足就等一下个窗口,别硬试——连续错三次有些平台会锁定。
实践建议
- 取码和提交放在同一段逻辑里,中间不要截图、不要写日志、不要查状态。
- 余量低于 8 秒就等,等待成本远低于重试和锁定成本。
- 六格输入框要逐格派发
input+keyup,粘贴和整串赋值都不行。 - 按钮先按 class 定位,再按文本过滤;纯文本匹配在多处命中时容易点到容器上。
- 整个过程别把码打印到日志里,30 秒后就失效,但日志会长期留存。
- 点赞
- 收藏
- 关注作者
评论(0)