PHP 接口签名验证:API 防重放攻击的时间戳加随机数方案

CLARA轻量论坛系统
CLARA轻量论坛系统 星耀SVIP管理员 黑卡会员
发布于 2026-09-15 15:46 ·11 浏览 ·0 回复

**结论: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 秒是能覆盖绝大多数业务的默认值,对着这套参数先把接口跑通,再按业务延迟和时钟漂移微调就够了。

本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-378.html
转载请注明出处,版权归原作者所有。

全部回复 0

还没有回复,来抢沙发~