☰
短信验证码接口防刷设计:从限频到双因子令牌的完整方案
2026/10/13 17:18:25 网站建设 项目流程

短信验证码接口防刷设计,这个题目我在不同团队里前前后后折腾过好几轮了。老实说,每次以为把防护做到位了,没过多久总会被新的刷法打脸。短信通道不像别的业务接口,它直接和成本、用户体验、账号安全三件事挂钩,被刷爆一次,损失的不仅是几万条短信费,还有用户对产品的信任。

这篇文章我不打算给你讲一堆纸上谈兵的概念,而是把我自己踩过的坑、验证过有效的方案、以及那些看着合理实则没用的“伪防护”都拆开来讲。尽量让刚接触这块的开发者能直接照着搭一套,也让有经验的同行看看有没有可以互相补充的地方。

1. 短信接口被刷的常见路径:先搞清攻击者到底在打什么

在做任何防护之前,先得搞清楚一个核心问题:攻击者刷你的短信接口,图的是什么?

1.1 三种典型的刷接口动机

第一种是恶意消耗成本。这是最无脑也最常见的。攻击者拿到你的接口地址后,用脚本批量调用,短时间内把手机号段里的号码都遍历一遍。一次攻击可能消耗你几万甚至几十万条短信费用。这种攻击不挑业务,只要有暴露的发送接口就能打。

第二种是撞库和批量注册。攻击者手里有从其他平台泄露的账号密码库,拿你的短信接口当验证工具。如果你的接口在发送验证码之前不校验手机号是否已注册、不校验当前请求是否来自真实用户,那攻击者就能通过“尝试发送验证码→观察是否发送成功”来判断某个手机号在你的平台上是否存在,为后续的撞库攻击做信息收集。

第三种是骚扰特定用户。这个比较恶意,攻击者盯上某个具体手机号,反复触发发送验证码,让受害者的手机被短信轰炸。这种攻击的检测难度更高,因为从接口层面看,这些请求可能都来自不同的IP、不同的设备指纹,频次也不一定很高。

1.2 最容易被打的薄弱点都在哪里

我自己复盘的结论是,大部分被刷的接口都死在这几个地方:

  • 接口没有做任何前置校验,只要拿到地址就能直接调。
  • 只防了手机号维度,没防IP维度。攻击者用代理池分散请求来源,手机号维度根本看不出异常。
  • 频控逻辑写在应用层,而且用的是非原子操作。并发一高,判断就失效了。
  • 验证码前置环节缺失,用户不需要经过任何“我是真人”的证明就能触发短信发送。
  • 日志不完整,事后想溯源攻击来源,发现连IP都没有记录。

这些薄弱点不是单一存在的,通常是一起出现。所以防护方案也必须是一整套组合拳,而不是加一个限流就完事。

2. 第一道防线:手机号与IP的滑动窗口限频

限频是所有防刷方案的基础,但限频怎么做,差别非常大。

2.1 为什么固定窗口限频不靠谱

很多团队一开始用的是这种方案:以手机号为维度,存储一个计数器,比如“1分钟内最多发1条,1小时最多发5条”。到期清零。

这里有个经典的并发穿透问题:假设你设了1分钟最多发1条,攻击者在第59秒和第61秒各发一次,因为窗口已经切换了,两次请求都会被放行。如果攻击者把请求分布在每次窗口切换的临界点,那实际发送量会翻倍。

更致命的是计数器的原子性。如果用先读再写的方式更新计数,在高并发下会出现多个请求同时读到旧值、同时通过校验的情况。这就是为什么很多人明明配了限频,压测一打就穿。

2.2 用Redis ZSET实现真正的滑动窗口

我推荐用Redis的ZSET结构来做滑动窗口限频,核心逻辑是:把每次请求的时间戳作为score存入ZSET,查询时只统计当前时间往前推窗口长度内有多少个时间戳。

-- ratio_limit.lua -- KEYS[1]: 频控键,例如 sms:freq:mobile:13800138000 -- KEYS[2]: 当前时间戳(毫秒) -- KEYS[3]: 窗口大小(毫秒),例如60000 -- KEYS[4]: 窗口内最大次数,例如1 -- 返回1表示允许发送,返回0表示拦截 local key = KEYS[1] local now = tonumber(KEYS[2]) local window = tonumber(KEYS[3]) local max_count = tonumber(KEYS[4]) -- 移除窗口外的记录 redis.call('ZREMRANGEBYSCORE', key, 0, now - window) -- 统计窗口内记录数 local current = redis.call('ZCARD', key) if current < max_count then redis.call('ZADD', key, now, now .. '-' .. math.random(1000000)) redis.call('PEXPIRE', key, window) return 1 end return 0

这段脚本用Lua写在Redis里执行,保证判断和写入是原子操作,高并发下不会出现穿透。手机号维度的频控建议这样设置:

  • 同一手机号:1分钟最多1条,1小时最多5条,24小时最多10条。
  • 同一IP在无登录态下:1小时最多10条。注意IP维度的限制要比手机号宽松,因为正常用户的出口IP可能是共享的。

2.3 分布式环境下频控键的设计细节

频控键的设计直接影响到限频的粒度。我见过有人直接用手机号做key,结果同一个手机号在不同业务场景下的发送被互相干扰。更合理的做法是把业务场景和手机号一起作为维度。

所谓业务场景,就是验证码的类型,比如注册、登录、找回密码、修改手机号绑定等。不同场景的频控策略是可以不一样的:注册场景可以严格一些,登录场景要考虑用户被挤下线后重新登录的需求。

频控键我推荐这样设计:sms:freq:{scene}:{mobile},同时保留一个sms:freq:{scene}:{ip}:{mobile}级别的键,用来限制“同一IP对同一手机号”的请求频率。攻击者即使有一整个代理池,只要目标手机号是固定的,这个维度也能限制住。

2.4 纯粹依赖限频的局限

这里必须说实话:仅靠限频远远不够。攻击者用几百个手机号、几百个IP同时打,每个手机号都保持在“1分钟1条”的频控红线内,整体请求量仍然能把你的短信通道打满。

限频的意义在于提高攻击成本、争取响应时间,而不是一劳永逸。真正把绝大多数自动化脚本挡在门外的,是下面要讲的验证码升级和设备指纹。

3. 第二道防线:行为设备指纹与验证码升级

这部分是防刷设计的核心战场。如果说限频是“拦速率”,那么这一层就是“辨真伪”。

3.1 前端埋点与设备指纹的采集逻辑

设备指纹的核心思路是:在用户请求发送验证码之前,前端先采集设备信息,生成一个很难伪造的标识,后端用这个标识和历史行为做比对。

采集的信息不是越多越好,关键是你得有办法验证这些信息的一致性。我从实际经验里推荐采集这几类:

  • HTTP头部的完整UA信息,包括操作系统、浏览器版本、语言、时区偏移。
  • Canvas指纹,通过让浏览器绘制特定图形,因为不同显卡驱动、不同字体渲染会产生微小差异。
  • WebGL渲染信息,这个和设备硬件有强关联。
  • 屏幕分辨率和可用分辨率的组合。
  • 时区、语言列表、触控支持情况。

这些信息会通过前端JS被压缩成一段加密字符串,作为请求参数传给后端。后端拿到后,会计算出一个device_id,并维护这个设备的首次出现时间、历史请求频次、关联的手机号数量和账号数量。

3.2 在什么条件下必须升级为图形验证码

设备指纹不是万能的,攻击者可以做模拟浏览器指纹,也可以使用真实的浏览器环境配合自动化工具。判断标准不能只看设备指纹是否合法,还要看组合后的风险等级。

我常用的判定逻辑是打分的:

  • 设备指纹首次出现且直接请求短信接口:高风险,要求先过滑块或图形验证码。
  • 设备指纹在1小时内关联超过2个手机号:高风险,要求完成拼图验证。
  • 设备请求的User-Agent和操作系统语言与手机号归属地明显不匹配:风险提升。
  • 设备指纹合法且过去7天有稳定的登录记录:低风险,直接放行。

这里要特别说明一下,验证码的成本是由普通用户承担的,所以设计目标是“让99%的真实用户无感知,让1%的可疑用户付出额外操作成本”。如果每个用户每次发验证码都要玩拼图,用户流失率会直线上升。

3.3 图形验证码的服务端校验细节

图形验证码的校验经常被忽略一个点:必须一次性使用,且和会话ID绑定。不能存在“这个验证码答案5分钟内有效,可以反复提交”的情况。

另一个容易犯的错是将验证码校验和短信发送分成两个接口,但攻击者可以绕过验证码接口直接调用短信接口。正确的做法是把验证码校验结果作为短信发送的前置令牌,从流程上强制绑定。

3.4 行为轨迹验证码的采集逻辑

相对于传统图形验证码,行为式验证码(比如滑块、点选)是目前防自动化效果较好的一类方案。服务端通过校验滑块的轨迹数据,判断拖动过程是否符合人类操作特征:是否有加速减速、是否有轻微抖动、轨迹点的时间间隔是否符合物理规律。

这套方案的问题在于部分老旧的WebView环境不支持,以及无障碍用户的操作轨迹经常被误判为机器。所以我的建议是做成降级链路,行为验证失败后,可以降级为传统数字图片验证码,保证功能的可用性。

4. 第三道防线:双因子令牌机制,把发送权与请求权分离

这是我认为整个防刷体系里最有价值的一环,也是很多团队没做或者没做对的一环。

4.1 核心流程重构:先换令牌,再发短信

传统流程是:前端点击“获取验证码”→后端直接调用短信服务发送。

这个流程最大的问题在于后端无法区分请求是否经过了前置的完整性校验。攻击者不需要关心你的页面逻辑,只关心你的短信接口地址,只要能成功POST一次,短信就发出去了。

双因子令牌机制的思路是:把“发送短信”这个动作分成两步。

第一步,客户端先请求一个pre_auth_token。这个请求需要携带设备指纹、行为验证码的校验结果、以及当前的时间戳。服务端校验通过后,签发一个短时效的token,token绑定本次请求的手机号,有效期建议不超过2分钟,且只能使用一次。

第二步,客户端拿着这个token,再请求短信发送接口。发送接口拿到token后,先校验token是否存在且未被使用、是否与当前请求的手机号一致、是否过期。全部通过后,才真正调用短信通道。

# 伪代码示例,用于说明token生成和校验的流程 import hashlib import time import redis r = redis.Redis(host='127.0.0.1', port=6379, db=0) def issue_pre_auth_token(mobile: str, device_id: str, scene: str) -> str: payload = f"{mobile}:{device_id}:{scene}:{time.time()}:{secrets.token_hex(8)}" token = hashlib.sha256(payload.encode()).hexdigest() # 将token写入Redis,有效期120秒,绑定手机号和场景 key = f"sms:pauth:{token}" r.hset(key, mapping={ 'mobile': mobile, 'device_id': device_id, 'scene': scene, 'issued_at': time.time() }) r.expire(key, 120) return token def verify_pre_auth_token(token: str, mobile: str, scene: str) -> bool: key = f"sms:pauth:{token}" data = r.hgetall(key) if not data: return False if data.get(b'mobile').decode() != mobile: return False if data.get(b'scene').decode() != scene: return False # 校验通过后立即删除token,确保一次性使用 r.delete(key) return True

4.2 双因子机制到底解决了什么问题

双因子机制解决的核心问题是接口身份的不可伪造性。

有些团队会问:我前端已经做了设备指纹采集,后端也校验了,为什么还要多这一步?答案是:如果发送接口本身不做token校验,攻击者依然可以直接POST短信接口。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询