Files
boursecu_server/app/Services/Payment/Support/MontifyHmacSigner.php
T

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));
}
}