291 lines
8.0 KiB
PHP
291 lines
8.0 KiB
PHP
<?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)
|
||
{
|
||
// 可以在这里对响应做额外处理
|
||
}
|
||
} |