PHP 接口签名验证:API 防重放攻击的时间戳加随机数方案
**结论:PHP 接口防重放的可靠做法是「时间戳 + 随机数 nonce + HMAC 签名」三件套——时间戳限制签名有效期,nonce 保证同一份签名只被消费一次,HMAC 保证参数没被篡改;三者缺任何一个,接口都存在被重放或被刷的风险。**
一、只做签名挡不住重放
签名只能证明「这份请求没被改过」,不能证明「这份请求是第一次来」。攻击者抓到一个合法请求,原样再发一遍,签名依然校验通过——这就是重放攻击。要堵住它,必须在签名之外再加两条约束:给签名一个有效期(时间戳),给每份签名一个一次性编号(nonce)。
判断标准很简单:**结论:只要接口是「有副作用」的(下单、扣款、发帖、发消息),时间戳和 nonce 两个都得有,缺一不可。**
二、客户端怎么拼待签串
**结论:待签串必须可复现——参数按 key 字典序排序,只用业务参数参与拼接(不含 sign 本身),空值是否参与、数组怎么序列化,都要在接口文档里写死。** 双方实现不一致,是签名校验失败的头号原因。
function buildSignStr(array $params): string
{
ksort($params); // 字典序,两边必须一致
$pairs = [];
foreach ($params as $k => $v) {
if ($k === 'sign' || $v === '' || $v === null) continue;
$pairs[] = $k . '=' . (is_array($v)
? json_encode($v, JSON_UNESCAPED_UNICODE)
: $v);
}
return implode('&', $pairs);
}
$params = [
'app_id' => 'ak_20240501',
'timestamp' => time(), // 秒级,统一 UTC
'nonce' => bin2hex(random_bytes(16)), // 32 位随机串
'order_no' => 'SO20240501001',
];
$signStr = buildSignStr($params);
$params['sign'] = hash_hmac('sha256', $signStr, $secret); // $secret 不下发客户端
nonce 用 `random_bytes()` 而不是 `rand()`,避免同一毫秒内不同请求撞号。
三、服务端校验顺序不能颠倒
**结论:正确顺序是「必填校验 → 时间戳窗口 → 验签 → 消费 nonce」,先验签再扣 nonce。** 如果先占用 nonce 再验签,攻击者用一堆伪造签名就能把 nonce 存储刷爆,等于给自己挖了个 DoS 入口。
// 1. 必填
foreach (['app_id', 'timestamp', 'nonce', 'sign'] as $f) {
if (!isset($req[$f]) || $req[$f] === '') return fail(400, "missing $f");
}
// 2. 时效窗口,±300 秒
if (abs(time() - (int)$req['timestamp']) > 300) return fail(401, 'expired');
// 3. 验签,恒定时间比较防时序攻击
$expect = hash_hmac('sha256', buildSignStr($req), $secretOf($req['app_id']));
if (!hash_equals($expect, strtolower($req['sign']))) return fail(401, 'bad sign');
// 4. 一次性消费
$key = "api:nonce:{$req['app_id']}:{$req['nonce']}";
if (!Redis::set($key, 1, ['nx', 'ex' => 600])) return fail(409, 'replay');
注意第 3 步别写 `==`,`hash_equals()` 是恒定时间比较,能防住按字符逐位爆破的时序攻击。
四、nonce 存哪里:Redis 优先,MySQL 兜底
**结论:有 Redis 就用 `SET key 1 NX EX 600`,一条原子命令搞定;没有 Redis 就用 MySQL 唯一索引,靠重复键报错(1062)识别重放。**
CREATE TABLE api_nonce (
app_id VARCHAR(32) NOT NULL,
nonce VARCHAR(64) NOT NULL,
expire_at INT UNSIGNED NOT NULL,
PRIMARY KEY (app_id, nonce)
) ENGINE=InnoDB;
try {
$db->prepare("INSERT INTO api_nonce(app_id,nonce,expire_at) VALUES(?,?,?)")
->execute([$appId, $nonce, time() + 600]);
} catch (PDOException $e) {
if ($e->getCode() == 23000) return fail(409, 'replay');
throw $e;
}
// 定时清理:DELETE FROM api_nonce WHERE expire_at < UNIX_TIMESTAMP();
结论:nonce 的 TTL 必须大于时间窗口的 2 倍。 窗口是 ±300 秒,nonce 至少留 600 秒——否则一个「时间上仍合法」的旧签名可能在 nonce 被清理后再次生效。
五、几个容易踩的坑
- 客户端时钟不准:别指望客户端时间准,要么接口返回服务端时间戳让客户端算偏移量,要么把窗口适当放宽到 ±600 秒。窗口越大,nonce 保留时间要同步加大。
- HTTPS 是前提:签名防篡改不防窃听,明文 HTTP 下签名串连同参数一起被看光。
- 密钥按应用隔离、可轮换:一个 `app_id` 一个 `secret`,别全站共用一把;支持新旧密钥并行一段时间做平滑轮换。
- 毫秒和秒不要混用:`timestamp` 统一秒级,文档写清楚,混用会让窗口判断永远失败或永远通过。
- GET 参数用 RFC3986 编码:PHP 的 `http_build_query` 会把空格编成 `+`,跨语言客户端(Java/Go/Python)常按 `%20` 处理,建议统一 `rawurlencode`。
- 重放 ≠ 幂等:支付、下单这类接口,除了 nonce 还得加业务幂等号(如订单号),防止用户网络抖动重复提交。
- 时间戳本身参与签名:否则攻击者可以改掉 timestamp 让过期签名复现。
结语
这套方案的落地要点就三句话:客户端「字典序拼串 + HMAC + 随机 nonce」,服务端「先校验时效、再验签、最后原子消费 nonce」,存储层「Redis SETNX EX 优先,MySQL 唯一索引兜底」。时间窗口 300 秒、nonce TTL 600 秒是能覆盖绝大多数业务的默认值,对着这套参数先把接口跑通,再按业务延迟和时钟漂移微调就够了。
转载请注明出处,版权归原作者所有。
星耀SVIP
管理员
黑卡会员





