生成验证码、安全保存、发到马来西亚手机号、核对用户输入 —— 可以直接贴上的完整代码,并带上防止机器人刷短信费的限制。
短信验证码流程里,用户发两个请求,你的服务器发一个:
HantarSMS 只负责发送。验证码除了写在短信里,从不离开你的系统,所以以后换服务商也不用改核对逻辑。下面所有示例都只发马来西亚号码;API 接受 0123456789、60123456789 或 +60123456789。代码里的注释和短信内容保留英文,短信内容可以换成中文,但要控制在 66 个字以内(见 OTP 短信模板)。
PHP 8,使用 cURL 和 PDO(MySQL)。密钥放在环境变量里,不要写进代码。先建一张表:
CREATE TABLE otp_codes (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
phone VARCHAR(15) NOT NULL,
code_hash VARCHAR(255) NOT NULL,
expires_at DATETIME NOT NULL,
attempts TINYINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
INDEX (phone, created_at)
);
发送函数。Idempotency-Key 就是让重试变安全的关键:
<?php
function hantarsms_send(string $to, string $text, string $idemKey): array {
$ch = curl_init('https://hantarsms.com/api/v1/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'X-API-Key: ' . getenv('HANTARSMS_KEY'),
'Content-Type: application/json',
'Idempotency-Key: ' . $idemKey,
'User-Agent: myshop/1.0',
],
CURLOPT_POSTFIELDS => json_encode(['to' => $to, 'text' => $text, 'reference' => 'otp']),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE); // 0 = timeout / no connection
curl_close($ch);
return ['status' => $status, 'body' => $body === false ? null : json_decode($body, true)];
}
// Normalise to 60XXXXXXXXX so rate limits are counted per real number.
function my_phone(string $raw): ?string {
$n = preg_replace('/\D/', '', $raw);
if (str_starts_with($n, '0')) $n = '6' . $n;
return preg_match('/^601\d{8,9}$/', $n) ? $n : null;
}
签发验证码 —— 先检查限制,再发短信:
function issue_otp(PDO $db, string $phone): bool {
// One code per 60 seconds and five per hour for each number.
$q = $db->prepare("SELECT SUM(created_at > NOW() - INTERVAL 60 SECOND) AS last_min, COUNT(*) AS last_hour
FROM otp_codes WHERE phone = ? AND created_at > NOW() - INTERVAL 1 HOUR");
$q->execute([$phone]);
$r = $q->fetch(PDO::FETCH_ASSOC);
if ($r['last_min'] > 0 || $r['last_hour'] >= 5) return false;
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$db->prepare("INSERT INTO otp_codes (phone, code_hash, expires_at, created_at)
VALUES (?, ?, NOW() + INTERVAL 5 MINUTE, NOW())")
->execute([$phone, password_hash($code, PASSWORD_DEFAULT)]);
$key = 'otp-' . $db->lastInsertId();
$text = "MyShop: your verification code is $code. Valid for 5 minutes. Do not share it.";
$res = hantarsms_send($phone, $text, $key);
if ($res['status'] === 0 || $res['status'] >= 500) {
// Timed out or server error: retry once with the SAME key.
// If the first request actually went through, this returns it instead of sending again.
$res = hantarsms_send($phone, $text, $key);
}
return $res['status'] === 201;
}
核对用户输入:
function check_otp(PDO $db, string $phone, string $input): bool {
$q = $db->prepare("SELECT id, code_hash, attempts FROM otp_codes
WHERE phone = ? AND expires_at > NOW() ORDER BY id DESC LIMIT 1");
$q->execute([$phone]);
$row = $q->fetch(PDO::FETCH_ASSOC);
if (!$row || $row['attempts'] >= 5) return false; // no live code, or too many guesses
$db->prepare("UPDATE otp_codes SET attempts = attempts + 1 WHERE id = ?")->execute([$row['id']]);
if (!password_verify($input, $row['code_hash'])) return false;
$db->prepare("UPDATE otp_codes SET expires_at = NOW() WHERE id = ?")->execute([$row['id']]); // single use
return true;
}
为什么要存哈希:数据库外泄时,有效的验证码不会跟着外泄。为什么要计尝试次数:不计的话,脚本可以对一个号码把 100 万个六位数验证码全试一遍。
同样的流程,用 Laravel 的 HTTP 客户端。把密钥写进 .env 的 HANTARSMS_KEY,再通过 config/services.php 读取。只在连接错误时重试 —— 重试 402(余额不足)或 422(号码不对)不会得到不同的答案。
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
$res = Http::withHeaders([
'X-API-Key' => config('services.hantarsms.key'),
'Idempotency-Key' => "otp-{$otp->id}",
])
->timeout(15)
->retry(2, 500, fn ($e) => $e instanceof ConnectionException, throw: false)
->post('https://hantarsms.com/api/v1/messages', [
'to' => $otp->phone,
'text' => "MyShop: your verification code is {$code}. Valid for 5 minutes. Do not share it.",
]);
if ($res->status() !== 201) {
Log::warning('OTP SMS not sent', ['error' => $res->json('error.code')]);
}
用 Hash::make() 保存验证码、Hash::check() 核对,每个号码和每个 IP 的限次用 Laravel 的 RateLimiter。
想用现成插件?HantarSMS WordPress 插件有验证码登录、WooCommerce 订单短信和营销群发。想自己写的话,把下面这段存成 wp-content/plugins/myshop-otp/myshop-otp.php,在 wp-config.php 里加上 define('HANTARSMS_KEY', '...');,然后启用。它会加一个 REST 接口给你的注册或结账表单调用,并提供一个在表单提交时核对验证码的函数。
<?php
/**
* Plugin Name: MyShop SMS OTP
*/
function myshop_phone(string $raw): ?string {
$n = preg_replace('/\D/', '', $raw);
if (str_starts_with($n, '0')) $n = '6' . $n;
return preg_match('/^601\d{8,9}$/', $n) ? $n : null;
}
add_action('rest_api_init', function () {
register_rest_route('myshop/v1', '/otp', [
'methods' => 'POST',
'permission_callback' => '__return_true',
'callback' => 'myshop_send_otp',
]);
});
function myshop_send_otp(WP_REST_Request $req) {
$phone = myshop_phone((string) $req->get_param('phone'));
if (!$phone) {
return new WP_Error('bad_phone', 'Enter a Malaysian mobile number.', ['status' => 400]);
}
$ipKey = 'otp_ip_' . md5($_SERVER['REMOTE_ADDR'] ?? '');
if (get_transient("otp_wait_$phone") || (int) get_transient($ipKey) >= 10) {
return new WP_Error('too_many', 'Please wait before requesting another code.', ['status' => 429]);
}
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
set_transient("otp_code_$phone", [
'hash' => wp_hash_password($code),
'exp' => time() + 5 * MINUTE_IN_SECONDS,
'tries' => 0,
], 5 * MINUTE_IN_SECONDS);
set_transient("otp_wait_$phone", 1, MINUTE_IN_SECONDS);
set_transient($ipKey, (int) get_transient($ipKey) + 1, HOUR_IN_SECONDS);
$res = wp_remote_post('https://hantarsms.com/api/v1/messages', [
'timeout' => 15,
'headers' => [
'X-API-Key' => HANTARSMS_KEY,
'Content-Type' => 'application/json',
'Idempotency-Key' => 'otp-' . wp_generate_uuid4(),
],
'body' => wp_json_encode([
'to' => $phone,
'text' => "MyShop: your verification code is $code. Valid for 5 minutes. Do not share it.",
]),
]);
if (is_wp_error($res) || wp_remote_retrieve_response_code($res) !== 201) {
return new WP_Error('send_failed', 'Could not send the code. Try again in a minute.', ['status' => 502]);
}
return ['sent' => true];
}
// Call this when the form is submitted.
function myshop_check_otp(string $rawPhone, string $input): bool {
$phone = myshop_phone($rawPhone);
$row = $phone ? get_transient("otp_code_$phone") : false;
if (!$row || $row['tries'] >= 5 || time() > $row['exp']) return false;
$row['tries']++;
set_transient("otp_code_$phone", $row, max(1, $row['exp'] - time())); // keep the original expiry
if (!wp_check_password($input, $row['hash'])) return false;
delete_transient("otp_code_$phone"); // single use
return true;
}
两个 WordPress 特有的注意点。网站在 Cloudflare 或其他代理后面的话,REMOTE_ADDR 是代理的地址,按 IP 限次要从代理的请求头里取访客真实 IP。另外给调用这个接口的表单加上 CAPTCHA —— 接口是公开的,每次通过限制的调用都要花一条短信的钱。
| 返回 | 含义 | 你的代码该怎么做 |
|---|---|---|
| 201 | 已接收并扣费 | 显示输入验证码的页面 |
402 insufficient_balance | 账户额度用完了 | 提醒自己充值;告诉用户稍后再试 |
422 no_valid_recipients | 不是马来西亚手机号 | 请用户检查号码 |
429 rate_limit_exceeded | 超过这把密钥的发送上限 | 等 Retry-After;如果这是你真实的高峰,请我们调高上限 |
| 超时或 5xx | 结果未知 | 用同一个 Idempotency-Key 重试 |
完整错误码列表、查询送达状态的方法,以及 Python、Node.js、Java 示例,见 API 文档。现成的短信写法见 OTP 短信模板。
有:免费的 HantarSMS WordPress 插件提供短信验证码登录、WooCommerce 订单短信和营销群发,还提供 hantarsms_otp_send() 和 hantarsms_otp_verify() 给你自己的表单用。如果你想每一行代码都自己掌握,本页那个简短插件就涵盖了验证码部分。
不要。保存哈希值(PHP 用 password_hash、Laravel 用 Hash::make、WordPress 用 wp_hash_password),再用对应的验证函数比对。这样数据库外泄时,有效的验证码不会跟着外泄。
用同一个 Idempotency-Key 重试。如果第一次请求其实已经成功,API 会返回那次的结果,而不是再发第二条短信,所以用户只收到一个验证码、你只付一次钱。
按手机号和 IP 地址限次,公开表单加 CAPTCHA,只保留需要用到的额度。每把 API 密钥默认也有每分钟 60 个请求的发送上限。
代码本身可以,但 HantarSMS 只发马来西亚手机号,所以示例会拒绝所有不是 601 开头的号码。