文章目录
在分布式系统或跨公司业务对接中,API 的安全性始终是首要考虑的问题。如何确保请求的身份真实、数据完整、防重放,同时保护敏感信息,是每个开发人员必须面对的挑战。
本文以 PHP(ThinkPHP 6) 作为实现案例,结合本人在实际项目开发中的实际经验,分享一套基于 HMAC-SHA256 签名 + AES-256-GCM 可选加密 的通用方案。文章将详细讲解设计思路,并给出服务端验证与客户端调用的完整代码,帮助读者快速落地。
为什么需要签名与加密?
在我的实际业务开发中,我需要对外提供一个下单接口,对方用 Java 开发,我司用 PHP 7。如果只依赖 HTTPS 传输,仍然存在以下风险:
- 身份伪造:任何知道接口地址的人都可以发起请求。
- 数据篡改:虽然 HTTPS 防窃听,但无法防止中间人修改请求内容(如修改订单金额)。
- 重放攻击:攻击者截获合法请求后,反复发送相同请求。
解决方案:
- 签名 :使用
HMAC-SHA256对请求关键参数(包括请求体摘要)进行签名,服务端验证签名,确保请求由合法方发出且内容未被修改。 - 防重放:通过时间戳和一次性随机数(Nonce)限制请求的有效期和唯一性。
- 加密(可选) :对敏感数据(如身份证号)进行
AES-256-GCM加密,保证机密性。
这套方案不依赖特定语言特性,所有语言的标准库都能完美支持。
总体设计
方案概述
| 机制 | 作用 | 关键点 |
|---|---|---|
| 签名 | 验证请求身份及完整性 | 使用 HMAC-SHA256,待签名字符串包含 AccessKey、BodyMD5、Nonce、Secret、Timestamp,按字典序排序后拼接 |
| 防重放 | 防止重复请求 | 时间戳(300秒差值) + Nonce 缓存(10分钟) |
| 加密 | 保护请求体机密性 | AES-256-GCM,IV 12 字节随机,认证标签 16 字节,密文与 IV、标签拼接后 Base64 编码 |
| 签名与加密关系 | 签名基于原始明文计算,加密仅对请求体生效 | 确保签名能覆盖加密前的数据完整性 |
接入凭证
为每个调用方分配一对凭证:
| 参数名 | 说明 |
|---|---|
| AccessKey | 标识调用方身份,明文传输,可公开。 |
| SecretKey | 签名密钥,绝对保密,不通过网络传输,仅双方存储。长度建议 32 字节(256 位)。 |
请求头要求
所有请求必须携带以下 HTTP Header:
| Header | 类型 | 必填 | 说明 |
|---|---|---|---|
Content-Type |
string | 是 | application/json; charset=utf-8 |
X-Access-Key |
string | 是 | 调用方 AccessKey |
X-Timestamp |
int | 是 | 请求发起时的 Unix 时间戳(秒级) |
X-Nonce |
string | 是 | 随机字符串(建议 16 位以上),用于防重放 |
X-Signature |
string | 是 | 签名值(小写十六进制) |
Content-MD5 |
string | 是 | 请求体(JSON 字符串)的 MD5 值(小写十六进制) |
X-Encrypted |
string | 否 | 如果请求体经过 AES-GCM 加密,则设为 true |
签名生成步骤
- 准备参数 :需要参与签名的参数有 5 个:
AccessKey、BodyMD5、Nonce、Secret、Timestamp。 - 计算 BodyMD5 :无论是否加密,BodyMD5 都是对原始 JSON 字符串(即加密前的明文)进行 MD5 计算。
- 构造待签名字符串 :将上述 5 个参数按参数名 ASCII 升序排序 ,拼接成
key=value的形式,用&连接。
示例:AccessKey=hello_test_001&BodyMD5=abc123&Nonce=xyz789&Secret=mysecret&Timestamp=1734567890 - 计算签名 :使用
HMAC-SHA256算法,密钥为 SecretKey,对上一步字符串进行加密,结果转为小写十六进制字符串。
防重放机制
- 时间戳校验 :服务端检查
X-Timestamp与当前时间的差值,允许误差 ≤ 300 秒(可配置)。 - Nonce 缓存 :服务端将
X-Nonce存入缓存(如 Redis),设置 10 分钟过期。同一 Nonce 在有效期内不可重复使用。
加密实现
若请求体包含敏感数据(如身份证号、银行卡号等),可进行对称加密 ,确保数据在传输过程中的机密性。我们采用 AES-256-GCM 模式,它集成了加密与认证,是目前最安全、跨语言支持最好的对称加密方案之一。
备注:以下模块的内容由Ai生成,仅供科普和学习。
不同加密模式对比
AES(Advanced Encryption Standard)是一种分组密码,固定处理 16 字节数据块。为了加密任意长度的数据,需要配合不同的工作模式,每种模式在安全性、性能、适用场景上各有差异。下表对比了几种常见模式:
| 模式 | 特点 | 安全性 | 是否需要填充 | 是否带认证 | 跨语言兼容性 |
|---|---|---|---|---|---|
| ECB | 最简单的模式,相同明文块产生相同密文块 | 不安全,存在明显模式特征 | 需要 | 否 | 容易,但强烈不建议使用 |
| CBC | 最经典的模式,每个明文块与前一个密文块异或后再加密 | 安全,但需额外保证完整性 | 需要(PKCS#7) | 否 | 很好,需配合 HMAC 使用 |
| CTR | 流密码模式,将 AES 转化为流式加密 | 安全,但需保证完整性 | 不需要 | 否 | 很好,需配合 HMAC 使用 |
| GCM | 认证加密模式,加密的同时生成认证标签 | 非常安全,防篡改 | 不需要 | 是(内建) | 优秀,主流语言均原生支持 |
为什么 AES-GCM 是最佳选择?
- 认证加密(AEAD) :GCM 模式在加密的同时生成一个认证标签(Tag),解密时会自动校验该标签,确保密文未被篡改。这一特性使得我们不需要再额外增加 HMAC 来保护数据完整性(但请求头的签名仍然保留,用于防重放和身份认证)。
- 无需填充:作为流式模式,GCM 可以直接处理任意长度的数据,无需进行 PKCS#7 填充。这避免了因填充不一致导致的跨语言兼容性问题(例如 Java 的 PKCS5Padding 与 PHP 的 PKCS#7 本质相同,但仍有细微差别)。
- 标准化与跨语言支持:AES-GCM 被广泛采纳为 TLS 1.3 的推荐加密套件,主流编程语言(PHP、Java、Go、Python、Node.js 等)均通过原生库或标准扩展提供支持,实现代码简洁且不易出错。
- 性能优秀:在支持 AES-NI 指令集的硬件上,AES-GCM 速度非常快。即使在纯软件实现中,其性能也优于同等级别的非对称加密或老旧模式(如 3DES)。
与其他对称加密算法的对比:
| 算法 | 类型 | 密钥长度 | 特点 | 跨语言支持 | 推荐度 |
|---|---|---|---|---|---|
| AES-256-GCM | 对称加密 | 32 字节 | 标准、安全、性能高 | 极好 | ⭐⭐⭐⭐⭐ |
| ChaCha20-Poly1305 | 流加密 | 32 字节 | 软件实现快,适合移动端 | 良好(需特定库) | ⭐⭐⭐⭐ |
| SM4-GCM | 国密算法 | 16 字节 | 中国标准,用于合规项目 | 一般(需额外库) | ⭐⭐⭐(特定场景) |
| RSA | 非对称 | 2048+ | 性能差,不适合大数据加密 | 好 | 仅用于密钥交换 |
| 3DES | 对称 | 168 位 | 已淘汰,存在理论攻击 | 好 | ❌ 不推荐 |
小结 :AES-256-GCM 在安全性、性能、跨语言支持三方面均表现优异,是 API 数据加密的首选。因此,我们确定采用该模式作为可选加密方案。
加密和解密的流程步骤
加密流程步骤:
- 生成随机 IV :IV(初始化向量)长度固定为 12 字节(96 位),使用安全随机数生成器(如
random_bytes、SecureRandom)产生,每次加密都不同。 - 加密数据:使用 AES-256-GCM 模式对原始 JSON 字符串进行加密,获得密文(Ciphertext)和一个 16 字节的认证标签(Tag)。
- 组装密文包 :将 IV、Tag、密文按顺序拼接:
IV(12) + Tag(16) + Ciphertext。这一结构是自描述的,方便解密时分离。 - Base64 编码:将拼接后的二进制数据转换为 Base64 字符串,作为最终的请求体(Body)。
- 请求头标识 :在 HTTP 头部添加
X-Encrypted: true,告知服务端该请求体已被加密,需要进行解密处理。
解密流程步骤:
- Base64 解码:收到请求体后,先进行 Base64 解码,得到二进制数据。
- 分离 IV、Tag、密文:根据固定长度提取 IV(前 12 字节)、Tag(接着 16 字节)和剩余密文。
- 解密:使用相同的 SecretKey、IV、Tag,调用 AES-256-GCM 解密函数,获取原始明文。
- JSON 校验:解密后的内容必须是合法的 JSON 字符串,否则视为非法请求。
PHP代码示例
php
// 加密函数
private function encryptAesGcm($plaintext, $key)
{
$iv = random_bytes(12);
$tag = null;
$ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16);
if ($ciphertext === false) {
return false;
}
return [$ciphertext, $iv, $tag];
}
// 组装并编码
list($ciphertext, $iv, $tag) = encryptAesGcm($jsonBody, $secretKey);
$bodyToSend = base64_encode($iv . $tag . $ciphertext);
// 解密函数
$decoded = base64_decode($rawBody);
$iv = substr($decoded, 0, 12);
$tag = substr($decoded, 12, 16);
$ciphertext = substr($decoded, 28);
$plaintext = openssl_decrypt($ciphertext, 'aes-256-gcm', $secretKey, OPENSSL_RAW_DATA, $iv, $tag);
关键注意事项:
- IV 不可重复使用:对于同一密钥,每次加密必须使用不同的 IV,否则会严重削弱安全性。我们使用随机生成即可保证。
- 标签长度固定为 16 字节:虽然 GCM 支持多种标签长度,但为了跨语言统一,我们固定使用 128 位(16 字节)。
- 签名基于原始明文 :
Content-MD5和签名计算时,都必须使用加密前的原始 JSON 字符串,而不是加密后的密文。这样才能确保签名能够覆盖数据的真实内容。 - 加密与签名的关系:加密只保护请求体的机密性,签名保护整个请求的完整性和身份。两者各司其职,共同构建多层安全防护。
通过上述设计,即便在不加密的情况下,签名机制也能保证请求不被篡改;而在需要传输敏感信息时,加密层提供了额外的机密性保障。
PHP 服务端实现
这里,我将基于ThinkPHP6框架来实现一个完整的案例Demo,仅供参考。因为我在之前已经有过多次PHP项目案例的分享,代码就放在我的 https://gitee.com/rxbook/rx-php-box 这个项目里面。
配置文件
在项目中,我们将这两项配置在 .env 文件中,假设我们这里的业务是对接Hello API(注意区分测试与正式环境):
[HELLO_API]
CLIENT_ACCESS_KEY = hello_test_001
CLIENT_SECRET_KEY = 941b0b2d05dee26f5a5cc759ff358ee7d85a4106784df35cd91a75cb3676074d
TIMESTAMP_TOLERANCE = 300
ENABLE_ENCRYPTION = true
创建配置文件,用于读取 .env 中的凭证和参数:config/hello_api.php
php
<?php
return [
'client_access_key' => env('HELLO_API.CLIENT_ACCESS_KEY'),
'client_secret_key' => env('HELLO_API.CLIENT_SECRET_KEY'),
'timestamp_tolerance' => env('HELLO_API.TIMESTAMP_TOLERANCE', 300),
'enable_encryption' => env('HELLO_API.ENABLE_ENCRYPTION', true),
];
中间件实现
中间件负责统一处理验签、防重放和解密,并将解析后的参数注入请求对象。
文件位置 :app/middleware/api/HelloApiAuth.php
完整代码:https://gitee.com/rxbook/rx-php-box/blob/master/app/middleware/api/HelloApiAuth.php
核心逻辑实现:
1、获取请求头
首先从 HTTP 头部获取签名相关的字段,并检查必要字段是否存在,若缺失则返回 400 错误。
php
$accessKey = $request->header('X-Access-Key');
$timestamp = $request->header('X-Timestamp');
$nonce = $request->header('X-Nonce');
$signature = $request->header('X-Signature');
$contentMd5 = $request->header('Content-MD5');
$isEncrypted = $request->header('X-Encrypted') === 'true';
MyLog::writeLog($request->header(), 'HelloApiAuth_handle_header');
if (empty($accessKey) || empty($timestamp) || empty($nonce) || empty($signature)) {
return $this->error(400, 'Missing required headers');
}
2、校验时间戳
检查请求时间戳与当前时间差值是否在允许范围内,防止重放攻击。
php
$now = time();
$tolerance = config('hello_api.timestamp_tolerance');
if (abs($now - $timestamp) > $tolerance) {
return $this->error(403, 'Timestamp expired');
}
3、校验 Nonce
将 Nonce 存入 Redis(或任何缓存),设置 10 分钟过期,确保同一 Nonce 不被重复使用。
php
$nonceKey = 'api_nonce_' . $accessKey . '_' . $nonce;
if (RedisUtils::init()->get($nonceKey)) {
return $this->error(403, 'Nonce already used');
}
RedisUtils::init()->set($nonceKey, 1, 600);
4、获取请求体并校验 Content-MD5
计算请求体的 MD5,与客户端提供的 Content-MD5 头比对,确保请求体在传输过程中未被篡改。
php
$rawBody = $request->getContent();
$bodyMd5 = md5($rawBody);
if (!empty($contentMd5) && $contentMd5 !== $bodyMd5) {
return $this->error(422, 'Content-MD5 mismatch');
}
5、验证 AccessKey 并获取 SecretKey
根据 AccessKey 获取对应的 SecretKey。实际项目中可能从数据库读取,这里简化从配置读取。
php
$configAccessKey = config('hello_api.api_client_access_key');
if ($accessKey !== $configAccessKey) {
return $this->error(401, 'Invalid AccessKey');
}
$secretKey = config('hello_api.api_client_secret_key');
6、验签
按照相同规则构造待签名字符串,计算签名,并与请求头中的签名比对。使用 hash_equals 防止时序攻击。
php
$signParams = [
'AccessKey' => $accessKey,
'BodyMD5' => $bodyMd5,
'Nonce' => $nonce,
'Secret' => $secretKey,
'Timestamp' => $timestamp,
];
ksort($signParams);
$signStr = http_build_query($signParams, '', '&');
$expectedSignature = hash_hmac('sha256', $signStr, $secretKey);
if (!hash_equals($expectedSignature, $signature)) {
return $this->error(401, 'Signature verification failed');
}
7、解密请求体(可选)
如果请求头携带 X-Encrypted: true,则对请求体进行 Base64 解码,分离 IV、标签、密文,然后使用 AES-256-GCM 解密。解密后的明文必须是一个合法的 JSON。
php
if ($isEncrypted && config('hello_api.enable_encryption', false)) {
$decoded = base64_decode($rawBody);
if (strlen($decoded) < 28) {
return $this->error(400, 'Invalid encrypted data');
}
$iv = substr($decoded, 0, 12);
$tag = substr($decoded, 12, 16);
$ciphertext = substr($decoded, 28);
$plaintext = openssl_decrypt($ciphertext, 'aes-256-gcm', $secretKey, OPENSSL_RAW_DATA, $iv, $tag);
if ($plaintext === false) {
return $this->error(422, 'Decryption failed');
}
$rawBody = $plaintext;
if (json_decode($rawBody) === null) {
return $this->error(422, 'Invalid JSON after decryption');
}
}
8、注入参数并继续
将解密后的 JSON 解析为数组,通过 withParsedBody 注入到请求对象中,这样控制器就可以通过 $request->post() 获取参数。
php
$request->withParsedBody(json_decode($rawBody, true) ?: []);
return $next($request);
控制器实现
文件位置 :app/admin/controller/demo/CreateHelloData.php
控制器中无需关心签名和加密细节,直接使用 $request->post() 获取参数即可。这里忽略后续业务逻辑,只负责保证中间件鉴权通过后,打印接收到的请求参数。
php
public function createOrders(Request $request){
try {
// 获取解密后的参数(数组格式)
$params = $request->post();
MyLog::writeLog($params, 'hello_createOrders_params');
return json([
'code' => 200,
'msg' => '接口正在开发中...',
'data' => 'hello', //todo
]);
} catch (\Exception $e) {
MyLog::record()->writeExceptionLog($e, 'hello_createOrders_exception');
return json([
'code' => 999,
'msg' => '请求失败',
'data' => $e->getMessage(),
]);
}
}
添加路由
文件位置:app/admin/route/Demo.php
记得给这个路由设置中间件,也就是->middleware(HelloApiAuth::class)
php
<?php
use think\facade\Route;
use app\middleware\api\HelloApiAuth;
Route::group('demo', function () {
Route::post('createHelloOrders', 'demo.CreateHelloData/createOrders')->middleware(HelloApiAuth::class);
});
接下来,如果直接通过postman请求,肯定是不会通过的:

客户端模拟调用
为了方便测试,我这里编写一个 ThinkPHP 命令行指令,模拟对方客户端的调用行为,包含签名生成和可选加密。
代码实现
在 config/console.php 中添加:
php
'commands' => [
'testCreateHelloOrders' => 'app\command\test\TestCreateHelloOrders',
],
指令实现:在app/command/dajia/TestCreateOrders.php中编写实现逻辑。
完整代码:https://gitee.com/rxbook/rx-php-box/blob/master/app/command/test/TestCreateHelloOrders.php
php
$access_key = config('hello_api.client_access_key');
$secret_key = config('hello_api.client_secret_key');
$apiUrl = 'http://rx-php-box.com/admin/demo/createHelloOrders'; //你的接口地址
$enableEncryption = true; // 是否启用加密
$testJson = '{"goods_id":123,"buy_num":2}';
准备原始 JSON 并加密(可选)
php
$rawBodyJson = json_encode($testData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$bodyToSend = $rawBodyJson;
if ($enableEncryption) {
$encrypted = $this->encryptAesGcm($rawBodyJson, $secretKey);
list($ciphertext, $iv, $tag) = $encrypted;
$bodyToSend = base64_encode($iv . $tag . $ciphertext);
}
加密函数使用 openssl_encrypt 实现 AES-256-GCM:
php
private function encryptAesGcm($plaintext, $key)
{
$iv = random_bytes(12);
$tag = null;
$ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16);
if ($ciphertext === false) {
return false;
}
return [$ciphertext, $iv, $tag];
}
计算 BodyMD5 并生成签名
php
$bodyMd5 = md5($rawBodyJson); // 基于原始明文
$timestamp = time();
$nonce = bin2hex(random_bytes(8));
$signParams = [
'AccessKey' => $accessKey,
'BodyMD5' => $bodyMd5,
'Nonce' => $nonce,
'Secret' => $secretKey,
'Timestamp' => $timestamp,
];
ksort($signParams);
$signStr = http_build_query($signParams, '', '&');
$signature = hash_hmac('sha256', $signStr, $secretKey);
构造请求头并发送HTTP请求
php
$headers = [
'Content-Type: application/json; charset=utf-8',
'X-Access-Key: ' . $accessKey,
'X-Timestamp: ' . $timestamp,
'X-Nonce: ' . $nonce,
'X-Signature: ' . $signature,
'Content-MD5: ' . $bodyMd5,
];
if ($enableEncryption) {
$headers[] = 'X-Encrypted: true';
}
$response = $this->sendCurl($apiUrl, $bodyToSend, $headers);
发送 cURL 请求的方法封装
php
private function sendCurl($apiUrl, $bodyToSend, $headers)
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $apiUrl);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $bodyToSend);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HEADER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$responseHeaders = substr($response, 0, $headerSize);
$responseBody = substr($response, $headerSize);
curl_close($ch);
return ['http_code' => $httpCode, 'headers' => $responseHeaders, 'body' => $responseBody];
}
运行效果
在命令行通过如下命令执行:
bash
php think testCreateHelloOrders
----------------------------------------------------
任务描述: 测试创建Hello订单
----------------------------------------------------
成功获取到结果: {"code":100,"msg":"接口正在开发中...","data":"hello"}
观察打印的日志:

关键点总结
【签名与加密的协作】
-
签名的
BodyMD5始终基于原始明文计算,无论是否加密。 -
加密后,请求体为密文,但
Content-MD5头依然填写原始明文的 MD5。 -
服务端收到请求后,先验签(使用
Content-MD5),再解密(若X-Encrypted: true)。 -
签名算法 HMAC-SHA256、加密算法 AES-256-GCM 均为业界标准,Java、Go、Python 等语言均有原生支持。
【防重放机制】
- 时间戳允许偏差 300 秒(可配置)。
- Nonce 缓存使用 Redis,有效期 10 分钟,确保同一 Nonce 不被重复使用。
【AES-256-GCM 的组装格式】
- 加密时:
IV(12字节) + TAG(16字节) + 密文→ Base64 编码。 - 解密时:Base64 解码 → 分离 IV、TAG、密文 → 调用
openssl_decrypt。
特别说明:本文部分内容由DeepSeek生成,但是核心思路是我自己想出来滴!
原本我还想基于Go和Java再实现一套类似的功能,但由于时间原因,暂时先不写了,等以后有时间了再说。
本文涉及的源代码已经上传到了我的代码仓库:https://gitee.com/rxbook/rx-php-box
个人感悟
个人的一点感悟:目前Ai能帮助程序员解决很多底层的问题,但是「人类大脑的思维方式和对于业务需求的拆解与落地」可能是现在Ai所不能及的,我认为这也是当下形势程序员更大的价值!
比如我之前想到写技术博客的时候上传的图片的问题,我研究了怎么使用"图床":<blog.csdn.net/rxbook/article/details/143838674>
一开始使用的是Gitee图床,本来用的好好地,突然有一天不能用了,我就改为了Github图床(虽然网络不稳定,但也能用);
后来发现,发布到CSDN的图片,转存Github图片的时候会出问题:

对于这个问题,我问了DeepSeek和豆包,还有好几个Ai平台,它们大部分给我的解决办法都是围绕着「如何实现CSDN转存Github的图片」、「如何实现markdown上传图片到Gitee图床」、「有没有更好的图床的实现方案」...


后来我也疲倦了,累了,突然脑子灵光一现:既然Github可以作为markdown上传图片的图床,Gitee的图片可以成功转存到CSDN,那么直接把这两者结合就可以了!所以,我就继续使用Github作为上传图片的图床,文章写完后,把本篇文章的图片从Github仓库pull下来,再复制到Gitee的仓库,再push到Gitee的仓库,然后再把文章中的图片链接的前缀从Github改成Gitee,问题就解决了啊!


好吧,我可真是的大聪明!

在和DeepSeek的沟通中,我是意外的有所收获:
一个本地仓库可以拥有多个不同的远程仓库别名,例如:
git remote add origin https://github.com/用户名/图片仓库.git # 第一个 git remote add gitee https://gitee.com/用户名/图片仓库.git # 第二个这样,你的本地仓库就有了两个远程:
origin(指向 GitHub)和gitee(指向 Giee)。
好吧,之前我都是手动的把图片从Github复制到Gitee,感谢DeepSeek告知我的这个功能,我现在可以直接设置两个远程仓库了。