ousaa / ousaa-hmac-tp
ThinkPHP 通用 HMAC 签名认证包,服务端验签与客户端签名一体
Requires
- php: >=7.4
- guzzlehttp/guzzle: ^7.0
Requires (Dev)
- phpunit/phpunit: ^9.6
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 段的算法(最容易踩坑的一段,跨语言实现请严格照做):
- 把查询串解析成扁平的键值对,键和值都做百分号解码
- 按键名做字节序升序排序
- 每对按 RFC3986 编码后用
=连接,再用&拼起来
第 1 步不能用 PHP 的 parse_str()(以及其他语言里行为类似的函数):它会把键名中的空格、点、方括号统统替换成下划线,于是签名方发的 a b 到验签方变成了 a_b,两端消息体必然对不上。包内提供 Hmac::parseQuery() 做这件事,服务端要拿原始查询参数也用它。
嵌套数组先按 a[0]=x 的形式编码,再走上面三步,两端因此得到一致的扁平形态。
API 场景全部走请求头:
| 请求头 | 内容 |
|---|---|
X-Timestamp | 时间戳 |
X-Nonce | 随机串 |
X-Sign | 签名 |
| 由字段名推导 | 额外字段,app_id → X-App-Id |
链接场景全部走查询参数:e 是过期时间戳,sign 是签名,额外字段用字段名本身作参数名。计算 QUERY 段时只剔除 sign 一个参数,e 和额外字段都参与签名。TIMESTAMP / NONCE / BODY 三段填空串。
错误码
HmacException 的 getCode() 取以下常量:
| 常量 | 值 | 含义 |
|---|---|---|
MISSING_PARAM | 40101 | 缺少必要的签名参数 |
TIMESTAMP_EXPIRED | 40102 | 时间戳超出容忍窗口 |
URL_EXPIRED | 40103 | 链接已过签发方设定的有效期 |
SECRET_NOT_FOUND | 40104 | 密钥未配置或未能解析出来 |
REPLAY | 40105 | 同一签名被重复使用 |
SIGN_MISMATCH | 40106 | 签名与本地计算结果不一致 |
已知限制
重放拦截不是原子的。 开启 nonce 后,查重走 think\facade\Cache 的 has() + set() 两步,中间存在竞态:并发发出的同一个签名理论上可能都通过。跨 ThinkPHP 5.1 到 8.1 五个版本没有统一可用的原子写入接口(5.1 的 Redis 驱动不暴露底层 handler),所以没做。缓存键就是签名值本身,留存时长等于 tolerance。
想收紧的话,把 tolerance 从 300 调小到 30~60 秒,能显著压缩可重放的时间窗口。用文件缓存驱动时尤其建议这么做——文件驱动是惰性删除、没有后台回收,过期键要等下次被读到才清理。
带签名的链接无法单条撤销。 签名式链接不落库、无状态,签出去在有效期内就一直可用。要提前作废只能换密钥,而换密钥会让该密钥签发的全部链接一起失效。需要单条撤销的场景请用项目自己的有状态 token。
License
MIT