203 lines
6.1 KiB
PHP
203 lines
6.1 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Payment\Support;
|
|
|
|
use Illuminate\Support\Str;
|
|
|
|
/**
|
|
* ابزار امضای HMAC مطابق EXTERNAL_PAYMENT_PARTNER.md (Montify).
|
|
* فقط سمت سرور استفاده شود.
|
|
*/
|
|
class MontifyHmacSigner
|
|
{
|
|
public function __construct(private string $clientId, private string $clientSecret)
|
|
{
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed>|null $body
|
|
* @return array<string, string>
|
|
*/
|
|
public function signRequest(string $method, string $path, ?array $body): array
|
|
{
|
|
$timestamp = (string) time();
|
|
$nonce = (string) Str::uuid();
|
|
$bodyString = $body !== null ? $this->stableStringify($body) : '';
|
|
$bodyHash = hash('sha256', $bodyString);
|
|
$canonical = implode("\n", [
|
|
strtoupper($method),
|
|
$path,
|
|
$bodyHash,
|
|
$timestamp,
|
|
$nonce,
|
|
]);
|
|
$signature = hash_hmac('sha256', $canonical, $this->clientSecret);
|
|
|
|
return [
|
|
'Content-Type' => 'application/json',
|
|
'Accept' => 'application/json',
|
|
'X-Client-Id' => $this->clientId,
|
|
'X-Timestamp' => $timestamp,
|
|
'X-Nonce' => $nonce,
|
|
'X-Signature' => $signature,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $query
|
|
*/
|
|
public function verifyReturnSignature(array $query): bool
|
|
{
|
|
return $this->matchReturnSignatureVariant($query) !== null;
|
|
}
|
|
|
|
/**
|
|
* نام قالبی که امضا با آن خواند، یا null اگر هیچکدام نخواند.
|
|
*
|
|
* پارتنر ممکن است مقادیر عددی را بهصورت number امضا کند یا پارامتر خالی
|
|
* (مثل track_number) را اصلاً در payload نگذارد؛ همه حالتها بررسی میشود.
|
|
*
|
|
* @param array<string, mixed> $query
|
|
*/
|
|
public function matchReturnSignatureVariant(array $query): ?string
|
|
{
|
|
$signature = (string) ($query['signature'] ?? '');
|
|
if ($signature === '' || !ctype_xdigit($signature)) {
|
|
return null;
|
|
}
|
|
|
|
unset($query['signature']);
|
|
|
|
foreach ($this->payloadVariants($query) as $name => $payload) {
|
|
$expected = hash_hmac('sha256', $this->stableStringify($payload), $this->clientSecret);
|
|
if ($this->hashEqualsHex($signature, $expected)) {
|
|
return $name;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $body
|
|
*/
|
|
public function verifyWebhookSignature(array $body, ?string $signatureHeader): bool
|
|
{
|
|
$signature = (string) ($signatureHeader ?? '');
|
|
if ($signature === '' || !ctype_xdigit($signature)) {
|
|
return false;
|
|
}
|
|
|
|
// بدنه همانطور که آمده (نوعها از JSON) اولویت دارد، بعد حالتهای نرمالشده
|
|
$candidates = array_merge(
|
|
['raw' => $body],
|
|
$this->payloadVariants($body)
|
|
);
|
|
|
|
foreach ($candidates as $payload) {
|
|
$expected = hash_hmac('sha256', $this->stableStringify($payload), $this->clientSecret);
|
|
if ($this->hashEqualsHex($signature, $expected)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* حالتهای ممکن payload برای مقایسه امضا (بدون تکرار).
|
|
*
|
|
* @param array<string, mixed> $payload
|
|
* @return array<string, array<string, mixed>>
|
|
*/
|
|
private function payloadVariants(array $payload): array
|
|
{
|
|
$strings = [];
|
|
foreach ($payload as $key => $value) {
|
|
if (!is_string($key) || $key === '' || is_array($value) || $value === null) {
|
|
continue;
|
|
}
|
|
$strings[$key] = is_bool($value) ? ($value ? 'true' : 'false') : (string) $value;
|
|
}
|
|
|
|
$withoutEmpty = array_filter($strings, static fn ($value) => $value !== '');
|
|
|
|
$variants = [
|
|
'strings' => $strings,
|
|
'strings_without_empty' => $withoutEmpty,
|
|
'numeric' => $this->castNumericValues($strings),
|
|
'numeric_without_empty' => $this->castNumericValues($withoutEmpty),
|
|
];
|
|
|
|
$unique = [];
|
|
$seen = [];
|
|
foreach ($variants as $name => $variant) {
|
|
$fingerprint = $this->stableStringify($variant);
|
|
if (isset($seen[$fingerprint])) {
|
|
continue;
|
|
}
|
|
$seen[$fingerprint] = true;
|
|
$unique[$name] = $variant;
|
|
}
|
|
|
|
return $unique;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string> $payload
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function castNumericValues(array $payload): array
|
|
{
|
|
foreach (['amount', 'timestamp'] as $key) {
|
|
if (isset($payload[$key]) && is_string($payload[$key]) && ctype_digit($payload[$key])) {
|
|
$payload[$key] = (int) $payload[$key];
|
|
}
|
|
}
|
|
|
|
return $payload;
|
|
}
|
|
|
|
public function isTimestampFresh(int|string|null $timestamp, int $skewSeconds = 300): bool
|
|
{
|
|
if ($timestamp === null || $timestamp === '') {
|
|
return false;
|
|
}
|
|
|
|
return abs(time() - (int) $timestamp) <= $skewSeconds;
|
|
}
|
|
|
|
/**
|
|
* @param mixed $value
|
|
*/
|
|
public function stableStringify($value): string
|
|
{
|
|
if ($value === null || !is_array($value)) {
|
|
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
}
|
|
|
|
if (array_is_list($value)) {
|
|
$parts = array_map(fn ($item) => $this->stableStringify($item), $value);
|
|
|
|
return '[' . implode(',', $parts) . ']';
|
|
}
|
|
|
|
ksort($value);
|
|
$parts = [];
|
|
foreach ($value as $key => $item) {
|
|
$parts[] = '"' . $key . '":' . $this->stableStringify($item);
|
|
}
|
|
|
|
return '{' . implode(',', $parts) . '}';
|
|
}
|
|
|
|
private function hashEqualsHex(string $a, string $b): bool
|
|
{
|
|
if (!ctype_xdigit($a) || !ctype_xdigit($b) || strlen($a) !== strlen($b)) {
|
|
return false;
|
|
}
|
|
|
|
return hash_equals(strtolower($a), strtolower($b));
|
|
}
|
|
}
|