HaniBlindBox/server/php/app/api/middleware/SignatureVerifyMiddleware.php
2026-01-01 20:46:07 +08:00

291 lines
8.0 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* 请求签名验证中间件
*
* User: system
* Date: 2024/06/18
* Update: 2024/06/19 - 增加POST请求签名验证和防重放攻击功能
**/
namespace app\api\middleware;
use think\facade\Config;
use think\exception\HttpResponseException;
use think\Response;
class SignatureVerifyMiddleware
{
/**
* Redis实例
* @var \Redis
*/
protected $redis;
/**
* Redis键前缀
*/
const REDIS_KEY_PREFIX = 'api_nonce:';
/**
* Nonce过期时间
*/
const NONCE_EXPIRE_TIME = 600; // 10分钟
/**
* 时间戳允许的误差(秒)
*/
const TIMESTAMP_TOLERANCE = 60; // 1分钟
/**
* 构造函数初始化Redis连接
*/
public function __construct()
{
$this->redis = (new \app\common\server\RedisHelper())->getRedis();
}
/**
* 处理请求签名验证
*
* @param \think\Request $request
* @param \Closure $next
* @return \think\Response
*/
public function handle($request, \Closure $next)
{
// 获取当前请求路径
$path = $request->pathinfo();
// 检查是否在白名单内
if ($this->isWhitelistedPath($path, $request)) {
return $next($request);
}
// 根据请求方法进行签名验证
$method = strtoupper($request->method());
if ($method == "GET") {
$this->verifySignature($request, $request->get());
} elseif ($method == "POST") {
$this->verifySignature($request, $request->post());
}
// 继续执行下一个中间件
$response = $next($request);
return $response;
}
/**
* 检查请求路径是否在白名单中
*
* @param string $path 请求路径
* @param \think\Request $request 请求对象
* @return bool 是否在白名单中
*/
protected function isWhitelistedPath($path, $request)
{
// 检查是否有内部标识
$params = $request->param();
if (isset($params['is_test']) && $params['is_test'] === 'true') {
return true;
}
// 获取白名单路径
$whitelistPaths = $this->getWhitelistPaths();
// 检查路径是否在白名单内
foreach ($whitelistPaths as $whitePath) {
if ($this->pathMatch($whitePath, $path)) {
return true;
}
}
// 检查IP白名单
$ipWhitelist = $this->getIpWhitelist();
$clientIp = $request->ip();
if (in_array($clientIp, $ipWhitelist)) {
return true;
}
return false;
}
/**
* 验证请求签名
* @param \think\Request $request 请求对象
* @param array $params 请求参数
* @return void
*/
protected function verifySignature($request, $params)
{
// 检查是否有必要的签名参数
if (!isset($params['timestamp']) || !isset($params['sign']) || !isset($params['nonce'])) {
$this->error('缺少必要的签名参数');
}
// 检查时间戳是否在允许范围内1分钟误差
$timestamp = intval($params['timestamp']);
$now = time();
if (abs($now - $timestamp) > self::TIMESTAMP_TOLERANCE) {
$this->error('请求时间戳超出允许范围');
}
// 检查nonce是否被使用过防重放攻击
$nonce = $params['nonce'];
$nonceKey = self::REDIS_KEY_PREFIX . $nonce;
if ($this->redis->exists($nonceKey)) {
$this->error('无效的请求nonce已被使用');
}
// 记录nonce到Redis有效期10分钟足够覆盖时间戳可接受的误差范围
$this->redis->setex($nonceKey, self::NONCE_EXPIRE_TIME, $timestamp);
// 从请求中获取签名
$requestSign = $params['sign'];
// 拷贝参数,移除不需要的参数
$signParams = $params;
unset($signParams['s']); // 移除URL参数
unset($signParams['sign']); // 移除签名参数
// 按照键名对参数进行排序
ksort($signParams);
// 组合参数为字符串
$signStr = '';
foreach ($signParams as $key => $value) {
// 处理数组或对象类型的参数
if (is_array($value) || is_object($value)) {
$value = json_encode($value, JSON_UNESCAPED_UNICODE);
}
$signStr .= $key . '=' . $value . '&';
}
// 获取当前请求的域名和时间戳,组合为密钥
$host = $request->host();
$timestamp = $signParams['timestamp'];
$appSecret = $host . $timestamp;
// 添加密钥
$signStr = rtrim($signStr, '&') . $appSecret;
// 生成本地签名使用MD5签名算法
$localSign = md5($signStr);
// 比对签名
if ($requestSign !== $localSign) {
$this->error('签名验证失败');
}
}
/**
* 返回错误信息
* @param string $msg 错误信息
* @param int $code 错误码
* @return void
*/
protected function error($msg, $code = 0)
{
$result = [
'status' => $code,
'msg' => $msg,
'data' => null
];
$response = Response::create($result, 'json', $code);
throw new HttpResponseException($response);
}
/**
* 获取路径白名单
*
* @return array 白名单路径列表
*/
protected function getWhitelistPaths()
{
// 1. 默认白名单路径(如支付回调通知等)
$defaultWhitelist = [
'notify/*', // 支付回调等通知
'health', // 健康检查
'debug', // 调试接口
'generate_urllinks',
'webhook/*', // 添加webhook路径
'internal/*', // 内部接口
];
// 2. 从配置文件中获取白名单路径
try {
$configWhitelist = Config::get('api.whitelist_paths', []);
if (!empty($configWhitelist) && is_array($configWhitelist)) {
return array_merge($defaultWhitelist, $configWhitelist);
}
} catch (\Exception $e) {
\think\facade\Log::error('获取API白名单路径配置失败: ' . $e->getMessage());
}
return $defaultWhitelist;
}
/**
* 获取IP白名单
*
* @return array IP白名单列表
*/
protected function getIpWhitelist()
{
// 默认IP白名单
$defaultIpWhitelist = [
'127.0.0.1', // 本地回环地址
'::1', // IPv6本地回环地址
];
// 从配置文件中获取IP白名单
try {
$configIpWhitelist = Config::get('api.ip_whitelist', []);
if (!empty($configIpWhitelist) && is_array($configIpWhitelist)) {
return array_merge($defaultIpWhitelist, $configIpWhitelist);
}
} catch (\Exception $e) {
\think\facade\Log::error('获取API白名单IP配置失败: ' . $e->getMessage());
}
return $defaultIpWhitelist;
}
/**
* 路径匹配检查
*
* @param string $pattern 白名单路径模式
* @param string $path 请求路径
* @return bool 是否匹配
*/
protected function pathMatch($pattern, $path)
{
// 完全匹配
if ($pattern === $path) {
return true;
}
// 通配符匹配 (例如: 'notify/*')
if (strpos($pattern, '*') !== false) {
$pattern = str_replace('*', '.*', $pattern);
$pattern = '/^' . str_replace('/', '\/', $pattern) . '$/i';
return preg_match($pattern, $path);
}
return false;
}
/**
* 中间件结束调度
*
* @param \think\Response $response
* @return void
*/
public function end(\think\Response $response)
{
// 可以在这里对响应做额外处理
}
}