ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PHP短信验证码API实战:从生成、发送到防刷的完整实现

PHP短信验证码API实战:从生成、发送到防刷的完整实现

直接往外喷的内容别急着学,先把验证码这套写完,很多项目卡在这了。PHP短信验证码API,说难真不难,但网上抄来的示例十个里有八个是调不通的。标题里写了“最常见验证码发送写法”,我按我实际在项目里反复用、基本不改直接复制的那套逻辑来讲,把发送验证码从0到能跑、从能跑到稳定,给你捋一遍。

1. 整体设计与方案选型:为什么验证码模块要单独拎出来做

短信验证码这个东西,功能上就是“生成一串数字、发给用户、存下来、用户填回来、比对一下”这么个闭环。但你要是把这几步散落在登录接口、注册接口、找回密码接口里各写一份,后期等着哭。最常见的正确做法是:把验证码相关的逻辑收敛成一个独立的服务模块,所有需要验证码的业务入口统一调它。

1.1 业务上先拆清楚:验证码不该和业务代码混在一起

我在实际项目中见到最多的烂摊子,就是登录页面里直接写了发送短信的代码,注册页里又复制了一份,找回密码页再改改参数又整一份。一旦短信服务商要求换接口、加签名、改模板,你要全项目去搜“sms”关键词,搜索出来好几处,改完这处漏那处,测试阶段就炸。

合理的拆法是三层:

  • 第一层是统一的发送封装类,只负责一件事:把验证码发出去,返回成功或失败。
  • 第二层是业务处理器,负责生成随机码、把码存到缓存或数据库、调用发送封装类、记录发送状态。
  • 第三层是接口入口,严格做参数校验、频率限制、IP限制,再交给业务处理器。

这样做的好处,不只是代码干净。排查问题的时候,你可以非常快地判断是“发送环节挂了”还是“业务逻辑挂了”,不用在一坨代码里翻来翻去。

核心流程是这样的:

  1. 前端(或小程序端)请求发送验证码接口,带上手机号、场景标识(如register、login、resetpwd)。
  2. 后端先做校验:手机号格式对不对、这个号今天发了多少次、这个IP是不是在刷、上次发送距现在够不够60秒。
  3. 生成6位数字验证码,存入缓存,设定有效时间(常规5分钟)。
  4. 调用短信服务商的API,把模板参数填好,提交发送。
  5. 返回给前端“已发送”的结果,前端引导用户去填验证码。
  6. 用户提交表单时,后端比对用户输入的验证码和缓存里的验证码,相等继续走业务,否则提示错误。

这个流程看着简单,但每一步都有坑。后面我会把每一步的代码细节和为什么这么写的原因讲到位。

1.2 选型对比:为什么优先走API接口而不是其他发送方式

短信发送的方式,大方向上就这么几种:服务商提供的HTTP API、服务商提供的SDK包、自建短信网关。自建网关这个基本不用考虑,需要对接大量运营商通道,实名制审核、通道稳定性、到达率问题能把小团队拖死。SDK包适合项目里只有一个服务商、且不想写HTTP请求细节的情况,但缺点是SDK质量参差不齐,有的服务商SDK还停留在老旧的PHP写法上,有的甚至不兼容PHP8。

我个人的习惯是:统一走HTTP API,不用SDK。原因很实在:

  • HTTP API是各家短信服务商最通用的能力,换上任何一家,你只需要改请求地址、参数名、签名算法,核心代码不变。
  • 依赖的只是PHP自带的cURL扩展,不用因为某个服务商的SDK去改PHP版本或扩展配置。
  • 方便做统一的日志记录、超时控制、重试机制。

这里说的“最常见写法”,指的就是这种:cURL发起HTTPS请求、参数用JSON或标准表单方式提交、返回结果统一解析。这套写法你学会了,换任何一家服务商,你改的只是API地址和密钥,整体骨架完全不用动。

2. 核心代码解析:验证码从生成到发出去的每个环节

这段我直接贴能用的代码,然后逐行讲我为什么这么写。主题是“最常见的写法”,那我不搞花活,就写老老实实的标准写法,但一些容易忽略的细节我全部补上。

2.1 验证码生成与缓存存储:别用数据库,用缓存

先讲生成验证码和存验证码这一步。这是整个流程里代码量最少、但最容易出错的部分。

最常见、也是我推荐的做法,不存数据库,直接放缓存。为什么?验证码的生命周期只有几分钟,生命周期极短,数据库一张表反复写入删除,纯属浪费。缓存里设定过期时间,到期自动消失,连删除逻辑都省了。如果你项目用了Redis,直接Redis搞定;如果没有Redis,文件缓存或者一套简单的缓存封装也可以,甚至PHP的APCu都行。重点在于:给验证码一个不可忽略的过期时间,而且比对之后要立刻删除,防止暴力重放。

我写过不下几十套验证码生成逻辑,最后固定成下面这种写法:

<?php /** * 生成6位数字验证码 * 之所以用random_int而不是mt_rand,是为了密码学意义上的安全 * 验证码虽然不强依赖随机数强度,但用更安全的生成方式没坏处 */ function generateCode(int $length = 6): string { // 计算最小值 10^(length-1) ,例如6位就是100000 $min = pow(10, $length - 1); $max = pow(10, $length) - 1; try { return (string) random_int($min, $max); } catch (Exception $e) { // random_int在随机源异常时会抛异常,理论上很少发生 // 但一旦发生,直接回落成时间戳加随机数方式,保证接口不死 return (string) time() . mt_rand(100, 999); } }

这里我必须强调为什么用random_int而不是mt_rand。验证码是个四位或六位数字,看起来随机不重要,但验证码是安全凭证。mt_rand的随机性在统计学上是有规律的,对普通场景无所谓,可因为验证码暴力破解只需要试一万次或一百万次,如果你同时没有做尝试次数限制,随机数算法强度不够会放大风险。我见过有人用rand(1000,9999)生成4位验证码而且没有次数限制的,这种接口被爆破只是时间问题。random_int贵一点,但验证码这种低频操作不在乎这点性能开销。

生成了验证码,就该存了。我常用的键名设计是:

$key = 'sms:code:' . $scene . ':' . $mobile;

把场景标识和手机号一起拼进键名,就能做到同一个手机号在注册和找回密码两个场景下各存各的验证码,互不干扰。如果你不区分场景,用户先注册收了一个验证码,然后跑去找回密码,找回密码的校验可能会拿注册的验证码去对,逻辑不严谨。

存缓存这里有个关键点:验证码本身不能明文直接裸放,至少做个简单处理。常见做法是直接存明文然后比对时用===,这个应付普通项目够用,但更稳妥的做法是存哈希。我一般这样做:

// 存入缓存(以Redis举例) // 存的不是验证码明文,而是加了固定盐的hash $salt = 'your_custom_static_salt'; $cache->set($key, password_hash($code . $salt, PASSWORD_DEFAULT), 300); // 校验时 $savedHash = $cache->get($key); if ($savedHash && password_verify($inputCode . $salt, $savedHash)) { // 验证通过后立即删除缓存,防止重放 $cache->delete($key); }

这样做的道理:万一Redis被拖库或者说日志里意外打了日志,明文验证码会直接泄露。哈希后至少验证码没有直接裸奔。有人觉得验证码有效期5分钟,泄露就泄露了,无所谓,但安全这种东西,多一层是一层,成本几乎为零,为什么不加呢。

2.2 发送短信的HTTP调用封装:用cURL发HTTPS请求的标准写法

接下来是重头戏,真正把验证码通过短信服务商发出去。这里我直接把最常用、最标准的POST JSON格式写法给你。目前绝大多数短信服务商都支持POST一个JSON对象到指定API地址,参数包括手机号、模板ID、模板参数、签名等。

下面是核心代码:

<?php /** * 短信发送封装类 * 以最常见的POST JSON格式对接 */ class SmsSender { private string $apiUrl = 'https://api.example.com/sms/send'; private string $accessKey; private string $secretKey; private string $signName; private string $templateCode; public function __construct(array $config) { $this->apiUrl = $config['apiUrl'] ?? $this->apiUrl; $this->accessKey = $config['accessKey']; $this->secretKey = $config['secretKey']; $this->signName = $config['signName']; $this->templateCode = $config['templateCode']; } /** * 发送短信 * @param string $mobile 手机号 * @param array $templateParams 模板参数,如 ['code' => '123456'] * @return array 统一返回数组,包含success、message、requestId */ public function send(string $mobile, array $templateParams): array { // 组装请求参数 $params = [ 'accessKey' => $this->accessKey, 'mobile' => $mobile, 'signName' => $this->signName, 'templateCode' => $this->templateCode, 'templateParams' => json_encode($templateParams, JSON_UNESCAPED_UNICODE), 'timestamp' => time(), ]; // 生成签名 $params['sign'] = $this->generateSign($params); // 发送请求 $result = $this->postJson($this->apiUrl, $params); return $this->parseResult($result); } /** * 生成签名,不同服务商算法不同,这里以最常见的MD5签名举例 */ private function generateSign(array $params): string { ksort($params); $str = ''; foreach ($params as $key => $value) { $str .= $key . '=' . $value . '&'; } $str .= 'key=' . $this->secretKey; return strtoupper(md5($str)); } /** * 使用cURL发送POST JSON请求 */ private function postJson(string $url, array $params): array { $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($params, JSON_UNESCAPED_UNICODE)); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json; charset=utf-8', ]); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 10); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); $response = curl_exec($ch); $error = curl_error($ch); curl_close($ch); if ($error) { return [ 'success' => false, 'message' => '请求短信服务失败: ' . $error, ]; } $data = json_decode($response, true); if (json_last_error() !== JSON_ERROR_NONE) { return [ 'success' => false, 'message' => '短信服务返回数据格式异常', ]; } return $data; } /** * 解析服务商返回结果,统一格式 */ private function parseResult(array $data): array { // 这里根据服务商实际返回结构解析 // 大多数服务商会返回code字段,如200表示成功,其他表示失败 $code = $data['code'] ?? ($data['status'] ?? ''); $message = $data['message'] ?? ($data['msg'] ?? '未知错误'); $requestId = $data['requestId'] ?? ($data['request_id'] ?? ''); $success = $code === '200' || $code === 200 || $code === 'OK'; return [ 'success' => $success, 'message' => $message, 'requestId' => $requestId, ]; } }

代码不长,但有几个点我单独拎出来说,因为都是实际坑过人的地方:

第一个坑是SSL证书校验。我在本地测试时候把CURLOPT_SSL_VERIFYPEER设为false,是因为有些本机PHP环境没有配置完整的CA证书链,不关掉就发不出去。但是上生产环境,我强烈建议你把它改成true,并配置好CA证书路径。生产环境里关闭SSL校验等于把请求裸奔在网络上,虽然加了签名参数,但你发给服务商的请求内容(手机号、验证码)全是可以被中间人看到的。正确做法是下载服务商文档里推荐的CA证书bundle,或者直接用系统的CA证书文件。很多资料图省事直接写false,你抄下来之后要记得在生产环境改掉。

第二个坑是签名算法。各家服务商的签名算法五花八门,有的MD5,有的HMAC-SHA256,有的要特定顺序拼接。我写的这个ksort之后按key=value拼接再md5的算法,是很多服务商通用的做法。不同服务商的差别主要在拼接的格式和是否做URL编码。你在换服务商的时候,最需要小心的就是这一块,鉴权不过的话服务商会直接返回签名错误,这是最常见的对接失败原因。

第三个坑是请求超时。CURLOPT_TIMEOUT我设置成10秒,CURLOPT_CONNECTTIMEOUT设置成5秒。短信接口是业务关键链路,用户在上面等结果,时间太长体验差。但也要注意不能太短,比如2秒,因为短信服务商处理请求通常需要1到3秒,你设置2秒超时可能要不就请求还没发到对方服务器就被打断,要不就是对方已成功发送但你没收到响应,然后你这边显示失败,用户重复提交,造成重复发送,成本白白浪费。

2.3 业务层的完整发送流程:把生成、存储、发送、限制串起来

底层封装好了,业务层要把流程串起来。我写业务层代码的习惯是:一个方法对应一个场景,方法之间不互相调用,控制层只负责调用这个方法,逻辑都在方法里。

<?php /** * 验证码业务处理器 */ class SmsCodeService { private SmsSender $smsSender; private CacheInterface $cache; private array $config; public function __construct(SmsSender $smsSender, CacheInterface $cache, array $config) { $this->smsSender = $smsSender; $this->cache = $cache; $this->config = $config; } /** * 发送验证码 * @param string $mobile 手机号 * @param string $scene 场景标识,如register、login、resetpwd * @return array */ public function sendCode(string $mobile, string $scene): array { // 1. 校验手机号格式 if (!preg_match('/^1[3-9]\d{9}$/', $mobile)) { return ['success' => false, 'message' => '手机号格式不正确']; } // 2. 场景白名单校验,防止传任意值 $allowedScenes = ['register', 'login', 'resetpwd']; if (!in_array($scene, $allowedScenes, true)) { return ['success' => false, 'message' => '场景参数非法']; } // 3. 频率限制:同一手机号两次发送间隔至少60秒 $lastSendKey = 'sms:lastsend:' . $scene . ':' . $mobile; $lastSendTime = $this->cache->get($lastSendKey); if ($lastSendTime && (time() - $lastSendTime) < 60) { return ['success' => false, 'message' => '发送太频繁,请稍后再试']; } // 4. 每日发送次数限制,防止短信轰炸 $dayKey = 'sms:daycount:' . $scene . ':' . $mobile . ':' . date('Ymd'); $dayCount = (int) $this->cache->get($dayKey); if ($dayCount >= 10) { return ['success' => false, 'message' => '今日发送次数已达上限']; } // 5. 生成验证码并存储 $code = generateCode(6); $storeKey = 'sms:code:' . $scene . ':' . $mobile; $salt = 'your_custom_static_salt'; $this->cache->set($storeKey, password_hash($code . $salt, PASSWORD_DEFAULT), 300); // 6. 调用短信发送 $result = $this->smsSender->send($mobile, ['code' => $code]); // 7. 发送成功后才更新频率限制和当天计数 if ($result['success']) { $this->cache->set($lastSendKey, time(), 60); $this->cache->set($dayKey, $dayCount + 1, 86400); } else { // 发送失败,删除已存储的验证码,防止用户用这个码或留着占用缓存 $this->cache->delete($storeKey); } return $result; } /** * 校验验证码 * @param string $mobile 手机号 * @param string $scene 场景标识 * @param string $inputCode 用户输入的验证码 * @return bool */ public function verifyCode(string $mobile, string $scene, string $inputCode): bool { if (empty($inputCode)) { return false; } $storeKey = 'sms:code:' . $scene . ':' . $mobile; $savedHash = $this->cache->get($storeKey); if (!$savedHash) { return false; } $salt = 'your_custom_static_salt'; $ok = password_verify($inputCode . $salt, $savedHash); // 无论对错,只要校验过一次就删掉,防止暴力循环 $this->cache->delete($storeKey); return $ok; } }

这段代码有几个细节我想重点讲。

发送成功后才更新频率限制和当天计数,这个顺序很多人写反了。有人先把次数加一,再调用短信接口,结果短信接口因为服务商欠费或者签名错误挂了,用户的发送次数却被扣了。用户反馈说我明明没收到短信,你告诉我今天不能发?这个体验非常差。正确顺序就是先发短信,成功了再记录限制信息。

还有一个细节是发送失败要把验证码缓存删掉。很多人不管发送结果,先把验证码存进去。如果服务商返回失败,那用户就根本收不到短信,但这个验证码已经存在缓存里了。5分钟之内有人拿这个手机号和场景尝试,假设他没有收到验证码但是乱填了一个,因为缓存里有一个验证码,他要猜对的概率就存在。虽然设置了次数限制,但没必要增加这个风险面。发送失败就删掉重来,逻辑干净。

3. 实操过程:对接服务商并跑通整个发送场景

代码写得再漂亮,真正去对接的时候你才会发现,文档里没写全的细节才是真正卡你一天的东西。这个部分我从零开始,带你走一遍实际对接的全流程,所有该准备的信息、该测的点、该注意的事项,我全摊开讲。

3.1 准备阶段:申请短信签名和模板的具体流程

不管用哪家短信服务商,你都要先完成三类信息的准备:AccessKey和SecretKey(有的叫AppID和AppSecret)、短信签名、短信模板。

短信签名就是用户收到短信时看到的那一对方括号里的名称,如【某公司】。签名是要审核的,审核内容是:签名是否和你的应用或公司主体相关。如果是个人的项目,签名可以起个跟产品相关的名字,比如【某测试应用】里面包含产品名字,审核一般能过。如果你是一个还没上线的应用,签名审核时通常需要提供应用商店的截图、软著或者公司营业执照之类的证明材料,这点我最开始对接的时候完全没料想到,等审核被驳回才知道要准备这些。

短信模板是短信正文的格式化版本,里面会用${code}这样的占位符。比如:

您的验证码为${code},5分钟内有效。请勿泄露给他人。

模板也需要审核,主要看文案是否合规。这里有个经验:模板里的变量名不要起太长,也不要用中文变量名,不同服务商对变量名格式要求不同,但通用的规则是字母开头、数字组合。另外,“请勿泄露”这类的安全提示词建议加上,审核通过率会高,也对用户负责。

审核时间一般几分钟到一个工作日不等。所以对接的第一步永远是先申请签名和模板,不要等代码写完了再去申请,那样你会被审核流程硬生生卡住一整天。代码写得再快,审核不通过你也发不出短信。

3.2 联调测试:模拟发送和真实发送各自怎么测

拿到密钥、签名、模板之后,先别急着写完整业务代码。先用最简单的单文件脚本验证连通性。

<?php // 联调测试脚本 require 'SmsSender.php'; $config = [ 'apiUrl' => 'https://api.example.com/sms/send', 'accessKey' => '你的AccessKey', 'secretKey' => '你的SecretKey', 'signName' => '【某测试应用】', 'templateCode' => 'SMS_123456789', ]; $sender = new SmsSender($config); $result = $sender->send('13800138000', ['code' => '123456']); var_dump($result);

这个脚本里的手机号一定不要用真实的手机号,要用服务商提供的测试号码。大多数服务商都有专门用于测试的号码,用测试号码发送时,服务商不会真实下发短信,但在控制台里能看到发送状态和请求日志,这样就不用浪费真实的短信条数来测试。

如果测试号码返回成功,你再去用自己的手机号做一次真实发送。真实验证的时候,重点不是看短信到没到,而是记下这些信息:

  • 从发出请求到手机收到短信,中间耗时大概多少秒。超过10秒说明服务商的通道今天可能有问题,要留意。
  • 收到的短信正文格式是否正常,有没有出现乱码或者变量没替换的情况。
  • 短信签名是否显示正常,有的服务商在测试阶段签名前缀处理不一样。

我遇到过一次的情况是:测试号码模拟发送全部正常,真实发送也提示成功,但手机收到的短信里变量没有被替换,手机上直接显示“您的验证码为${code},5分钟内有效”。这个就是模板变量名错误或者提交参数名称对不上导致的,服务商端不会校验变量是否真的被替换了,只检查格式合法就受理了。所以真实发送测试时一定要看手机收到的消息内容,别只看接口返回。

3.3 生产注意事项:密钥管理、日志记录、错误处理

联调通过之后,把代码集成到业务系统里,这一步有几个生产环境特有的问题要处理。

密钥管理这块不能偷懒。AccessKey和SecretKey不要直接写在PHP代码文件里。最基础的做法是放到配置文件里,并且配置文件的路径不能跟着代码仓库一起提交,要在部署环境里单独维护。更好的做法是用环境变量或专门的密钥管理服务来存储。

这里说的不是矫情。代码仓库万一泄露,就等于把短信接口的钥匙交出去了,别人拿到你的密钥可以用你的签名去发垃圾短信,最终账单算在你头上。真要出了这种事,你不仅要联系服务商紧急禁用密钥,还要去解释那些垃圾短信和你没关系,流程非常折腾。所以密钥管理这块从一开始就要认真对待。

日志记录要区分成功和失败。成功的日志记录什么?记录手机号、场景、模板、requestId、耗时。失败日志记录什么?记录手机号、场景、错误码、错误信息、服务商返回原文。这个日志的价值是后续排查问题用的,比如用户投诉说没收到短信,你查日志一看,服务商返回的是“模板不存在”,那就说明环境配置搞错了,不是通道问题。

我自己一般会为短信服务单独建一张日志表。字段包括:id、mobile、scene、template_code、content、status、response_code、response_message、request_id、created_at。这张表不参与业务逻辑,就纯查记录,所以你不需要担心它拖慢主流程。

错误处理要设计重试机制和降级方案。短信服务商偶尔会抽风,接口返回5xx或者超时。碰到这种情况,直接给用户返回“发送失败”,让用户再点一次,用户体验很差。常规做法是:对网络层面的错误做一次重试,间隔个1秒再请求一次,但如果服务商明确返回了“签名错误”“模板错误”“余额不足”这类业务错误,一定不要重试,重试多少次都是失败,纯浪费。

还有一个小小的建议:短信发送接口做一个简单的熔断。如果连续10次请求都是网络错误,那大概率是服务商通道出问题了,这时候直接拒绝后续发送请求,返回“短信服务暂不可用,请稍后再试”,给服务商留点恢复时间,也避免用户反复点发送把请求堆死。

4. 常见问题与排查技巧:短信验证码接口的疑难杂症

这部分是实战中踩坑的精华汇总。我按问题现象来分类,每个问题给出排查思路和解决方案,方便你在遇到问题的时候直接对照着查。

4.1 发送接口报错:从签名鉴权到模板匹配的排查顺序

短信接口对接初期,大多数报错都集中在鉴权失败和模板参数错误这两类。我把典型的报错和对应原因整理成一个速查表。

报错现象常见原因解决思路
签名错误 / SignatureErrorSecretKey写错、签名算法拼接顺序不对、参数漏加重新核验密钥,逐字符检查;对照服务商文档检查签名拼接规则
缺少参数 / MissingParameter提交的参数字段名与服务商要求不一致看服务商文档里要求的字段名,注意大小写和下划线
模板不存在templateCode填错、模板审核未通过在服务商控制台确认模板ID和审核状态
变量不匹配模板里定义了${code}但提交的是param或code参数名不同确认提交的模板参数字段名和模板占位符一一对应
手机号格式错误手机号带+86前缀或包含空格发送前统一格式化;服务商一般只接受纯11位手机号
没有签名或签名未审批签名漏传、签名未通过审核在控制台查看签名审核状态

排查这类问题有个顺序。先看服务商返回的错误码和错误信息,不要只看结果。很多新手对接时只看success=false就慌了,不去看具体message。服务商的返回信息已经告诉你是哪一步错了,你自己就不用瞎猜了。拿到具体的英文错误码之后,去服务商文档里查对应的中文解释和解决方案,比自己翻代码要快得多。

4.2 验证码收不到的几种常见原因

短信接口返回成功,但用户就是收不到短信,这是最让人头疼的一类问题。我按出现频率来排一下常见原因。

第一种是手机号被运营商拦截。现在各大运营商对短信的拦截策略特别多,含链接的短信基本会被拦截,还有很多包含“验证码”关键词的短信会被某些手机系统自动收进“骚扰拦截”分类里。用户感知就是没收到。这个你没法从代码层面解决,只能引导用户去拦截记录里找。

第二种是模板含敏感词被审核通过但下发时受限。有些模板文案虽然审核过了,但发送的时候运营商端还会做二次内容过滤,比如“验证码”加“金额”加“链接”这种组合容易触发二次审核。处理方式就是让文案尽量简洁,不要塞一堆营销词汇。

第三种是服务商通道选择问题。有些服务商有多个通道,不同通道对不同运营商的到达率不一样。你测试时用某运营商的手机号正常,但用户用别的运营商收不到,那就可能是通道的覆盖问题。这时候需要换通道或者让服务商排查。

第四种是接口返回成功但上游实际失败。这种情况服务商侧会有一个状态回执,异步通知到你的回调地址。生产环境一定要配置短信状态回调,把最终的状态同步到日志表里。否则你永远只知道服务商受理了请求,而不知道短信最终是不是真的下发成功了。

4.3 验证码被刷或被爆破的应急处理方案

短信接口上线后,第一个盯上你的往往不是正儿八经的用户,而是扫描接口的脚本。验证码接口一旦没有防护,就会被拿去刷短信,造成两种后果:一是你账户里的短信余量被刷光,产生费用;二是你的签名被用户投诉多了,服务商可能暂停你的发送权限。

我给出的基础防护措施,上线前必须全部做上:

  1. 同一手机号60秒内只能请求一次发送。
  2. 同一手机号每天发送验证码的次数上限,建议5到10次。
  3. 同一IP每天发送验证码的次数上限,建议20到50次。
  4. 如果应用已登录,可以要求用户30秒内不能重复发送。
  5. 配合图形验证码或滑动验证:前端点击发送验证码之前,先完成一个人机验证,这是目前防刷最有效的方式。脚本自己过不了图形验证码,你再做频率限制,双保险。

对校验接口,也就是用户提交验证码的接口,要做尝试次数限制。我给一个建议:同一个手机号同一个场景,5分钟内最多尝试5次,超过5次直接锁定,让用户重新获取验证码。这个限制直接杜绝了暴力枚举的可能。就算你的验证码是6位(100万种组合),在没有次数限制的情况下最多试100万次也能试出来,但有了5次限制,爆破基本不可能。

有一个细节是,验证码校验接口要上频率限制,不能只在发送接口上做。很多人防刷只防发送,忘记了校验接口。攻击脚本绕过发送环节,直接对校验接口做暴力碰撞,也能把所有可能取值的验证码试一遍。即使验证码只有6位,试错100万次也就不到一天的事,没有限制就很危险。

4.4 调试技巧:如何快速定位问题到底出在哪一环

我最后分享一个我自己定位短信问题的通用套路。整个短信链路是:前端 → 后端接口 → 缓存 → 短信服务商 → 运营商 → 用户手机。任何一个环节出问题,都表现为“用户没收到验证码”或“接口报错”。我的排查顺序是:

  1. 先看后端日志,确认接口有没有收到请求、参数是什么。
  2. 确认服务商返回的结果,是成功还是具体的错误码。
  3. 如果服务商返回成功,看服务商控制台的发送状态回执,确认是不是最终下发成功。
  4. 如果最终下发成功,引导用户查手机拦截记录。

按这个顺序排查,可以省掉大量时间。最怕的是你上来就怀疑代码写错了,一行行读代码,结果实际是服务商通道抽风或者手机拦截导致的。先确认问题在哪一层,再动手解决,比盲目改代码靠谱得多。

5. 安全加固与代码健壮性:不局限于“能跑”的写法

网上很多示例代码到发送成功就完事了,但实际撑起一个线上项目的验证码模块,要做到位的东西还不少。我在这个部分把常见的安全加固策略和代码健壮性处理一起讲了。

5.1 防止接口被恶意调用:从IP到账号维度的立体防护

前面第4.3节讲的频率限制是基础防护,但仅有基础防护还不够。一个恶意脚本一天换无数个IP来刷你的接口,你单纯限IP是防不住的,这时要用一些更立体的策略。

首先,校验手机号对应的用户账号是否存在。比如注册场景,如果你已经存在该手机号的账号,可以直接拒绝发送验证码;找回密码场景,如果手机号不在库中,也拒绝发送。这样做的目的是减少攻击面,让脚本无法任意指定目标手机号来骚扰。

其次,对短信发送接口增加人机验证环节。目前比较成熟的做法是:点击发送验证码前,先完成图形滑块验证,拿到一个凭证,然后带着这个凭证去请求发送接口。后端校验凭证,校验通过才放行。这一步能把纯脚本的攻击挡在门外,能把99%的刷量直接拦住。

再次,是短信模板与场景的强绑定。比如找回密码场景发的是“找回密码验证码”模板,如果脚本一直拿找回密码模板发验证码,虽然每次都成功,但一天下来也会有很多用户被骚扰。对这种场景,还要限制每个手机号每小时最多请求一次,每天最多三次,更严格。

5.2 代码健壮性细节:防止并发、超时、异常拖垮主流程

并发问题也是短信模块容易踩的坑。最常见的是并发重复请求:用户手快,连点了两下发送按钮,两个请求同时到达后端。两个请求都通过了频率校验,都生成了验证码,都发了短信,最后的缓存里存的是第二个验证码。用户收到两条短信,第一条作废。这种体验只能算一般,但更严重的是:如果两条短信都计费,重复的成本是会累积的。

解决方式很简单,用Redis的原子操作做互斥。发送前设置一个带过期时间的键,只有设置成功的那个请求才能继续,其他请求直接返回“操作太频繁”。

$lockKey = 'sms:lock:' . $scene . ':' . $mobile; $lockOk = $this->cache->set($lockKey, 1, 5); // 5秒过期,防止死锁 if (!$lockOk) { return ['success' => false, 'message' => '操作太频繁,请稍后再试']; }

这里关键在于set操作要带NX参数(Redis的SET if Not eXists),保证同时只有一个请求能设置成功。如果你先get再set,两个请求都读到不存在,就都能设置成功,锁就失效了。

异常处理方面,我给一个建议:把短信发送统一放进try-catch里,任何异常都不能让主业务流程崩掉。用户注册的时候,如果短信服务商超时导致异常抛出去,用户注册页直接变成500错误,这是不能接受的。正确做法是捕获所有异常,返回一个业务上的失败提示,让用户重试,不要暴露错误堆栈。

5.3 测试验证码与上线切换:环境隔离的写法

开发环境和测试环境如果共用一套短信配置,后果就是你会在凌晨收到一堆真实的短信验证码,再浪费掉一堆短信费用。我建议在配置里增加一个isMock开关,开发环境和测试环境设为true,此时不发真实短信,而是把验证码记录到日志里,前端直接看日志拿到验证码进行测试。

// 在发送前判断 if ($this->config['isMock'] ?? false) { // 模拟模式:不调用短信服务商,把验证码打到日志里 error_log('[MOCK SMS] ' . $mobile . ' code=' . $code); return ['success' => true, 'message' => '模拟发送成功(开发模式)']; } // 正式模式才走真实发送 $result = $this->smsSender->send($mobile, ['code' => $code]);

这套写法的好处是,开发和测试阶段根本不需要真实短信条数,也不需要等服务商审核模板就能把整个流程调通。而且上线的时候,只需要把配置里的isMock改成false,其他代码一行不用动。我每次做新项目都用这套,省掉了大量测试短信的成本和时间。

注意:模拟模式下日志里会记录验证码明文。日志文件的权限要严格设置,不能让其他开发人员之外的权限直接读取。最好只在开发机器上开启,测试环境的日志也要定期清理。

6. 几个我实际用下来的建议与体会

代码部分讲完了,说点没有代码、但比代码更影响你使用体验的经验。

短信验证码这东西,我最大的体会是:代码本身不是瓶颈,瓶颈全在服务商接入细节和防护策略上。你在网上随便搜的示例,大概率只是演示了发送这一步,但生产环境要能扛住真实流量,需要把频率限制、人机验证、日志监控、熔断全部做上。很多项目前期不做防护,等被人刷了一波短信账单之后再来补,花的钱远比一开始就写完防护要多。

关于服务商的选择,我给一个建议:优先看三样,一是通道到达率,这个可以问客服要近期统计数据;二是API文档质量,文档里如果连完整的请求响应示例都没有,对接起来会很痛苦;三是处理售后工单的速度,短信这种业务出问题的时候很紧急,比如用户集中反映收不到,你的服务商要能快速响应排查。价格其实各家差距不大,没必要为了几分钱选一个通道明显不稳的。

关于调试和排查,我再说一个建议:坚决在前端把发送按钮做成倒计时60秒,并在后端用缓存强制保证两次发送间隔不少于60秒。双重限制之下,就算前端被绕过,后端依然兜底。这个兜底逻辑是必须的,因为接口可以被任何人直接调用,前端限制只是体验优化,后端限制才是安全底线。

另外一个容易被忽略的点:短信验证码的文案里一定要带应用名。短信是直接发到用户手机上的,用户不一定记得你产品叫什么,在内容里带上应用名,用户看到“某应用提醒您:您的验证码为123456”才会确认这是正规产品发的短信,避免被当作垃圾短信举报,也能降低误拦截概率。

最后,我个人在项目中的做法,是把所有短信发送的配置项做成环境变量,并且后端逻辑里不硬编码任何模板相关的文案内容。这样每次换服务商,或者同一个项目对接多套短信配置时,代码一行都不改,只有配置文件在变。我从第一次对接短信到现在,累计换过三家服务商,每次都是只改配置、不动代码,这就验证了这套封装结构的合理性。

这篇文章就是围绕“最常见验证码发送写法”展开的。如果你正准备在项目里接入短信验证码,照着这套思路去写,从生成验证码、存缓存、调服务商发送到校验和防刷,一层层做上去,你这个模块上线后基本可以做到不用再操心。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进