ousaa/ousaa-hmac-tp

ThinkPHP 通用 HMAC 签名认证包,服务端验签与客户端签名一体

Maintainers

Package info

gitlab.seastt.com/ousaa/php-packages/ousaa-hmac-tp

pkg:composer/ousaa/ousaa-hmac-tp

Transparency log

Statistics

Installs: 9

Dependents: 0

Suggesters: 0

v1.0.1 2026-08-07 14:38 UTC

This package is auto-updated.

Last update: 2026-08-07 14:39:56 UTC


README

ThinkPHP 通用 HMAC 签名认证包,服务端验签与客户端签名一体。

多个项目装同一个包、配好密钥即可互相调用,不必各自从零实现一遍签名逻辑。

适用范围

  • ThinkPHP 5.1 / 6.0 / 6.1 / 8.0 / 8.1(同一套代码,中间件五个版本共用一个类)
  • PHP >= 7.4

解决什么问题

项目之间调用接口时,常见做法是拿一个长期不变的 API Key 走 Authorization: Bearer。这种方式密钥每次请求都上线路,且完全不保护请求内容——中间人拿到密钥就能任意伪造请求,改动参数也无从察觉。

本包改用 HMAC-SHA256:密钥不出进程,请求方法、路径、查询参数、正文全部纳入签名,请求带时效,可选开启重放拦截。

安装

composer require ousaa/ousaa-hmac-tp

ThinkPHP 6.0 及以上:装完即可用。composer 会自动跑 think vendor:publish,把配置模板发布成 config/hmac.php,服务注册也已通过 extra.think.services 自动完成。打开配置填上 secret 就能跑。

项目的 composer.json 里若没有 post-autoload-dump 钩子,手动跑一次 php think vendor:publish 即可。

ThinkPHP 5.1:没有 vendor:publish 机制,把包里的配置模板复制过去:

cp vendor/ousaa/ousaa-hmac-tp/src/config.php config/hmac.php

下文引用的 example/ 示例不随 dist 包分发,请到代码仓库里查看。

配置

服务端验签配置放在应用项目的 config/hmac.php(TP6+ 已自动发布,TP5.1 按上面复制)。

<?php

return [
    // 参与签名的额外字段,有序。空数组 = 不带任何额外字段
    'extra' => ['app_id'],

    // 签名密钥。可以是字符串,也可以是 callable(array $extra): ?string
    'secret' => env('HMAC_SECRET'),

    // API 场景的时间窗口(秒)
    'tolerance' => 300,

    // 是否对已用签名查重(防重放),走 think\facade\Cache
    'nonce' => false,

    // 验签失败的处理。null = 抛 HmacException
    'on_failure' => null,
];

几点说明:

  • extra 是字段名列表,不是键值对。 字段名决定它在传输中的载体:API 场景 app_id 走请求头 X-App-Id,链接场景走查询参数 app_id。字段顺序即参与签名的顺序,两端必须一致。
  • secret 支持回调,入参是已解析出的额外字段,用来按调用方身份查各自的密钥;认不出就返回 null,请求会被拒绝。包本身不查表、不读数据库,怎么查由你的项目决定。
  • 这份配置只服务于服务端验签。 调用别人时地址和密钥直接传给 HmacClient,不读这里。

服务端验签

把中间件挂到路由上即可。五个 ThinkPHP 版本写法一致(写全限定类名):

use Ousaa\HmacTp\Think\Middleware\HmacAuth;
use think\facade\Route;

Route::group('api/v1', function () {
    Route::post('order', 'order/create');
})->middleware(HmacAuth::class);

验签通过后,配置里声明的额外字段会以 hmacExtra 写入请求:

public function create($request)
{
    $appId = $request->hmacExtra['app_id'];

    return json(['code' => 0]);
}

验签失败默认抛 Ousaa\HmacTp\Exception\HmacException,交由应用自己的异常处理接管。想自定义响应就配 on_failure

'on_failure' => function (\Ousaa\HmacTp\Exception\HmacException $e, $request) {
    return json(['code' => $e->getCode(), 'msg' => $e->getMessage()], 401);
},

回调必须返回框架的 Response 对象。包不替你构造响应——各版本 Response::create() 签名不同,交给项目自己处理更可靠。

不走中间件时用 Verifier 自己校验,用法见 example/server-verify.php

客户端调用

内置基于 Guzzle 的请求客户端,开箱即用:

use Ousaa\HmacTp\Client\HmacClient;

$client = HmacClient::make(env('SITE_B_URL'), env('SITE_B_SECRET'))
    ->extra(['app_id' => 'site_a']);

$response = $client->get('/api/v1/order', ['order_no' => 'X20240101']);
$response = $client->post('/api/v1/order', ['amount' => 100]);

if ($response->ok()) {
    $data = $response->json();
} else {
    $reason = $response->error();
}

响应对象提供 ok() / failed() / status() / json() / body() / headers() / header() / error() / raw()

支持的请求方法:get / post / put / patch / delete,以及发送任意正文的 raw()。数组正文按 JSON 编码,其他类型用 raw() 自己给字符串和 Content-Type。

按需调参:

$client = HmacClient::make(env('SITE_B_URL'), env('SITE_B_SECRET'))
    ->timeout(10)              // 读写超时,默认 10 秒
    ->connectTimeout(5)        // 连接超时,默认 5 秒
    ->retry(3, 200)            // 最大尝试 3 次(含首次),间隔 200 毫秒
    ->headers(['Accept-Language' => 'zh-CN'])
    ->client($myGuzzleClient); // 注入自己的 Guzzle 客户端(代理、证书、连接池等)

只有连接失败和 5xx 才重试,4xx 属于业务失败不重试;每次尝试都会重新生成时间戳与随机串,不会因沿用旧签名撞上时间窗口或被当成重放。客户端默认不跟随重定向——跟随会带着原路径的签名去请求新地址,必然验签失败。

只想要签名头、请求用自己的客户端发:

use Ousaa\HmacTp\Signer;

$headers = Signer::make($secret)
    ->extra(['app_id' => 'site_a'])
    ->headers('POST', '/api/v1/order', ['page' => '1'], '{"amount":100}');

这条路子要自己保证三件事:传进去的正文与实际发出的字节完全一致;传进去的路径是对端看到的完整路径;重发时重新取一次签名头。完整示例见 example/client-request.php

带签名的跳转链接

签发一条对方点开就能用的链接,有效期由签发方写进链接本身,双方不需要事先同步任何状态:

use Ousaa\HmacTp\UrlSigner;

$url = UrlSigner::make($secret)
    ->extra(['app_id' => 'site_a'])
    ->sign('https://b.example.com/entry/report?shop_id=1', 3600);

// https://b.example.com/entry/report?app_id=site_a&e=1743565200&shop_id=1&sign=3f4a...

校验方给中间件传 url 切到链接场景:

Route::get('entry/report', 'entry/report')->middleware(HmacAuth::class, 'url');

或者自己校验:

$extra = UrlSigner::make($secret)->verify($url, ['app_id']);

链接场景与 API 场景的差异:签名走查询参数而非请求头;有效期是 query 里的 e(绝对时间戳,和其他参数一样参与签名,改不动);服务端只判断有没有过期,不看 tolerance、不做重放拦截。链接按 GET 签名,换成 POST 提交同一条链接会验签失败。

完整示例见 example/signed-url.php

签名规范

要用其他语言实现互通的客户端,照这个来。

message = implode("\n", [
    场景标识,          // API 请求为 'api',带签名链接为 'url'
    ...extra 的值,     // 按配置声明的字段顺序取值,可为空
    METHOD,            // 请求方法,大写
    PATH,              // 请求路径,带前导 /,不含查询串
    QUERY,             // 查询参数按键名升序后 RFC3986 编码拼接,无参数则为空串
    TIMESTAMP,         // 秒级时间戳
    NONCE,             // 一次性随机串
    BODY,              // 请求正文原文,无正文则为空串
])

sign = hash_hmac('sha256', message, secret)   // 小写 hex

后 6 段固定不变,extra 由双方约定。

QUERY 段的算法(最容易踩坑的一段,跨语言实现请严格照做):

  1. 把查询串解析成扁平的键值对,键和值都做百分号解码
  2. 按键名做字节序升序排序
  3. 每对按 RFC3986 编码后用 = 连接,再用 & 拼起来

第 1 步不能用 PHP 的 parse_str()(以及其他语言里行为类似的函数):它会把键名中的空格、点、方括号统统替换成下划线,于是签名方发的 a b 到验签方变成了 a_b,两端消息体必然对不上。包内提供 Hmac::parseQuery() 做这件事,服务端要拿原始查询参数也用它。

嵌套数组先按 a[0]=x 的形式编码,再走上面三步,两端因此得到一致的扁平形态。

API 场景全部走请求头:

请求头内容
X-Timestamp时间戳
X-Nonce随机串
X-Sign签名
由字段名推导额外字段,app_idX-App-Id

链接场景全部走查询参数:e 是过期时间戳,sign 是签名,额外字段用字段名本身作参数名。计算 QUERY 段时只剔除 sign 一个参数,e 和额外字段都参与签名。TIMESTAMP / NONCE / BODY 三段填空串。

错误码

HmacExceptiongetCode() 取以下常量:

常量含义
MISSING_PARAM40101缺少必要的签名参数
TIMESTAMP_EXPIRED40102时间戳超出容忍窗口
URL_EXPIRED40103链接已过签发方设定的有效期
SECRET_NOT_FOUND40104密钥未配置或未能解析出来
REPLAY40105同一签名被重复使用
SIGN_MISMATCH40106签名与本地计算结果不一致

已知限制

重放拦截不是原子的。 开启 nonce 后,查重走 think\facade\Cachehas() + set() 两步,中间存在竞态:并发发出的同一个签名理论上可能都通过。跨 ThinkPHP 5.1 到 8.1 五个版本没有统一可用的原子写入接口(5.1 的 Redis 驱动不暴露底层 handler),所以没做。缓存键就是签名值本身,留存时长等于 tolerance

想收紧的话,把 tolerance 从 300 调小到 30~60 秒,能显著压缩可重放的时间窗口。用文件缓存驱动时尤其建议这么做——文件驱动是惰性删除、没有后台回收,过期键要等下次被读到才清理。

带签名的链接无法单条撤销。 签名式链接不落库、无状态,签出去在有效期内就一直可用。要提前作废只能换密钥,而换密钥会让该密钥签发的全部链接一起失效。需要单条撤销的场景请用项目自己的有状态 token。

License

MIT