diff --git a/app/Console/Commands/MontifyCheckReturnCommand.php b/app/Console/Commands/MontifyCheckReturnCommand.php new file mode 100644 index 0000000..42dd98b --- /dev/null +++ b/app/Console/Commands/MontifyCheckReturnCommand.php @@ -0,0 +1,100 @@ +argument('url'); + $queryString = str_contains($input, '?') ? substr($input, strpos($input, '?') + 1) : $input; + + parse_str($queryString, $query); + if (!is_array($query) || $query === []) { + $this->error('پارامتری در ورودی پیدا نشد.'); + + return self::FAILURE; + } + + $clientId = $config->getString('external_payment_client_id', ''); + $clientSecret = $config->getString('external_payment_client_secret', ''); + if ($clientSecret === '') { + $this->error('external_payment_client_secret در تنظیمات خالی است.'); + + return self::FAILURE; + } + + $signature = strtolower((string) ($query['signature'] ?? '')); + unset($query['signature']); + + $strings = []; + foreach ($query as $key => $value) { + if (is_array($value)) { + continue; + } + $strings[$key] = (string) $value; + } + $withoutEmpty = array_filter($strings, static fn ($value) => $value !== ''); + + $signer = new MontifyHmacSigner($clientId, $clientSecret); + $castNumeric = static function (array $payload) { + foreach (['amount', 'timestamp'] as $key) { + if (isset($payload[$key]) && ctype_digit((string) $payload[$key])) { + $payload[$key] = (int) $payload[$key]; + } + } + + return $payload; + }; + + $variants = [ + 'strings' => $strings, + 'strings_without_empty' => $withoutEmpty, + 'numeric' => $castNumeric($strings), + 'numeric_without_empty' => $castNumeric($withoutEmpty), + ]; + + $this->line(''); + $this->info('امضای دریافتی: ' . ($signature !== '' ? $signature : '— (خالی)')); + $this->line('طول client_secret: ' . strlen($clientSecret) . ' کاراکتر'); + $this->line(''); + + $matched = null; + foreach ($variants as $name => $payload) { + $canonical = $signer->stableStringify($payload); + $expected = hash_hmac('sha256', $canonical, $clientSecret); + $ok = $signature !== '' && hash_equals($expected, $signature); + if ($ok && $matched === null) { + $matched = $name; + } + + $this->line(($ok ? '[OK] ' : '[خطا] ') . $name); + $this->line(' canonical: ' . $canonical); + $this->line(' expected : ' . $expected); + $this->line(''); + } + + if ($matched !== null) { + $this->info('امضا با قالب «' . $matched . '» می‌خواند.'); + + return self::SUCCESS; + } + + $this->error('هیچ قالبی با امضا نخواند — احتمالاً client_secret اشتباه است یا پارامتری از URL حذف شده.'); + + return self::FAILURE; + } +} diff --git a/app/Http/Controllers/pay/WalletPaymentController.php b/app/Http/Controllers/pay/WalletPaymentController.php index 280894a..d624423 100644 --- a/app/Http/Controllers/pay/WalletPaymentController.php +++ b/app/Http/Controllers/pay/WalletPaymentController.php @@ -5,6 +5,8 @@ namespace App\Http\Controllers\pay; use App\Http\Controllers\Controller; use App\Models\Withdraw; use App\Services\ConfigService; +use App\Services\Payment\Contracts\SupportsStatusQuery; +use App\Services\Payment\WalletDepositCreditor; use App\Services\Payment\WalletPaymentManager; use Illuminate\Http\Request; use InvalidArgumentException; @@ -20,6 +22,7 @@ class WalletPaymentController extends Controller public function __construct( private WalletPaymentManager $payments, private ConfigService $config, + private WalletDepositCreditor $creditor, ) { } @@ -105,25 +108,17 @@ class WalletPaymentController extends Controller throw new UnprocessableEntityHttpException('پرداخت آنلاین فعال نیست'); } + // همه پارامترهای برگشت عیناً وارد امضا می‌شوند؛ whitelist کردن باعث + // مغایرت امضا می‌شود اگر پارتنر پارامتر اضافه‌ای بفرستد. $payload = []; - foreach ([ - 'status', - 'reference_id', - 'session_id', - 'amount', - 'track_number', - 'timestamp', - 'signature', - ] as $key) { - if (!$request->has($key)) { + foreach ($request->all() as $key => $value) { + if (!is_string($key) || $key === '' || is_array($value) || $value === null) { continue; } - $value = $request->input($key); - if ($value === null || is_array($value)) { - continue; + if (count($payload) >= 30) { + break; } - // track_number خالی هم ممکن است در امضا باشد - $payload[$key] = (string) $value; + $payload[$key] = is_bool($value) ? ($value ? 'true' : 'false') : (string) $value; } if (($payload['signature'] ?? '') === '' || ($payload['reference_id'] ?? '') === '') { @@ -136,6 +131,12 @@ class WalletPaymentController extends Controller $result = $gateway->handleReturnPayload($payload); if (!($result['ok'] ?? false)) { + // redirect مرورگر قابل اعتماد نیست؛ منبع حقیقت، استعلام مستقیم از درگاه است. + $fallback = $this->statusFallback((string) $payload['reference_id']); + if ($fallback) { + return $fallback; + } + return response()->json([ 'ok' => false, 'message' => $result['message'] ?? 'خطا در تأیید پرداخت', @@ -148,18 +149,77 @@ class WalletPaymentController extends Controller return response()->json(['ok' => false, 'message' => 'سفارش یافت نشد'], 404); } - $customerId = (int) auth('customer')->id(); - if ((int) $withdraw->customer_id !== $customerId) { + if (!$this->ownsWithdraw($withdraw)) { return response()->json(['ok' => false, 'message' => 'دسترسی غیرمجاز'], 403); } - $sts = (int) $withdraw->sts; - $paid = $sts === 2; + $paid = (int) $withdraw->sts === 2; + return $this->paymentStateResponse( + $withdraw, + $paid ? 'success' : ((string) ($payload['status'] ?? '') === 'success' ? 'pending' : 'failed') + ); + } + + /** + * استعلام وضعیت یک پرداخت از درگاه — برای polling فرانت وقتی نتیجه هنوز قطعی نیست. + */ + public function status(Request $request) + { + $referenceId = trim((string) $request->input('reference_id', '')); + if ($referenceId === '') { + return response()->json(['ok' => false, 'message' => 'شناسه پرداخت ارسال نشده'], 422); + } + + $response = $this->statusFallback($referenceId); + if ($response) { + return $response; + } + + return response()->json(['ok' => false, 'message' => 'سفارش یافت نشد'], 404); + } + + /** + * استعلام مستقیم وضعیت از درگاه با بررسی مالکیت سفارش. + */ + private function statusFallback(string $referenceId): ?\Illuminate\Http\JsonResponse + { + $withdraw = $this->creditor->findByReference($referenceId); + if (!$withdraw || !$this->ownsWithdraw($withdraw)) { + return null; + } + + if ((int) $withdraw->sts !== 2) { + $gateway = $this->payments->resolve(); + if ($gateway instanceof SupportsStatusQuery) { + $gateway->syncStatusByReference($referenceId); + $withdraw = $withdraw->fresh() ?: $withdraw; + } + } + + $sts = (int) $withdraw->sts; + + return $this->paymentStateResponse( + $withdraw, + match ($sts) { + 2 => 'success', + 3 => 'failed', + default => 'pending', + } + ); + } + + private function ownsWithdraw(Withdraw $withdraw): bool + { + return (int) $withdraw->customer_id === (int) auth('customer')->id(); + } + + private function paymentStateResponse(Withdraw $withdraw, string $status): \Illuminate\Http\JsonResponse + { return response()->json([ 'ok' => true, - 'paid' => $paid, - 'status' => $paid ? 'success' : ((string) ($payload['status'] ?? '') === 'success' ? 'pending' : 'failed'), + 'paid' => (int) $withdraw->sts === 2, + 'status' => $status, 'amount' => (int) $withdraw->cash, 'reference_id' => 'WD-' . $withdraw->id, 'withdraw_id' => $withdraw->id, diff --git a/app/Services/Payment/Contracts/SupportsStatusQuery.php b/app/Services/Payment/Contracts/SupportsStatusQuery.php new file mode 100644 index 0000000..981eec5 --- /dev/null +++ b/app/Services/Payment/Contracts/SupportsStatusQuery.php @@ -0,0 +1,17 @@ + $rawQuery + * @return array{ok: bool, withdraw: ?Withdraw, http_status?: int, message?: string} + */ + public function handleReturnPayload(array $rawQuery): array; + /** * پردازش webhook. * diff --git a/app/Services/Payment/Gateways/MontifyPartnerGateway.php b/app/Services/Payment/Gateways/MontifyPartnerGateway.php index 674645e..b3213d6 100644 --- a/app/Services/Payment/Gateways/MontifyPartnerGateway.php +++ b/app/Services/Payment/Gateways/MontifyPartnerGateway.php @@ -3,6 +3,7 @@ namespace App\Services\Payment\Gateways; use App\Services\ConfigService; +use App\Services\Payment\Contracts\SupportsStatusQuery; use App\Services\Payment\Contracts\WalletPaymentGatewayInterface; use App\Services\Payment\Support\MontifyHmacSigner; use App\Services\Payment\WalletDepositCreditor; @@ -15,7 +16,7 @@ use RuntimeException; * درگاه پارتنر Montify (HMAC) — فقط credentials روی سرور. * @see docs/EXTERNAL_PAYMENT_PARTNER.md */ -class MontifyPartnerGateway implements WalletPaymentGatewayInterface +class MontifyPartnerGateway implements WalletPaymentGatewayInterface, SupportsStatusQuery { public function __construct( private ConfigService $config, @@ -169,10 +170,14 @@ class MontifyPartnerGateway implements WalletPaymentGatewayInterface $query = $this->normalizeReturnQuery($rawQuery); $signer = $this->signer(); - if (!$signer->verifyReturnSignature($query)) { + if ($signer->matchReturnSignatureVariant($query) === null) { \Log::warning('montify.return_invalid_signature', [ 'keys' => array_keys($query), 'reference_id' => $query['reference_id'] ?? null, + 'status' => $query['status'] ?? null, + 'amount' => $query['amount'] ?? null, + 'timestamp' => $query['timestamp'] ?? null, + 'has_track_number' => array_key_exists('track_number', $query), ]); return ['ok' => false, 'withdraw' => null, 'http_status' => 401, 'message' => 'امضای نامعتبر']; @@ -262,6 +267,73 @@ class MontifyPartnerGateway implements WalletPaymentGatewayInterface return $normalized; } + /** + * منبع حقیقت مستقل از redirect: استعلام مستقیم وضعیت از Montify. + * + * @return array{status: string, paid: bool} + */ + public function syncStatusByReference(string $referenceId): array + { + if ($referenceId === '' || !$this->isConfigured()) { + return ['status' => 'unknown', 'paid' => false]; + } + + $path = '/external-payment/status'; + $headers = $this->signer()->signRequest('GET', $path, null); + + try { + $response = Http::withHeaders($headers) + ->acceptJson() + ->timeout(20) + ->get($this->baseUrl() . $path, ['reference_id' => $referenceId]); + } catch (\Throwable $e) { + report($e); + + return ['status' => 'unknown', 'paid' => false]; + } + + $data = $response->json(); + if (!$response->successful() || !is_array($data)) { + \Log::warning('montify.status_failed', [ + 'reference_id' => $referenceId, + 'http_status' => $response->status(), + 'body' => is_array($data) ? $data : $response->body(), + ]); + + return ['status' => 'unknown', 'paid' => false]; + } + + $status = strtolower((string) ($data['status'] ?? '')); + if ($status !== 'paid') { + return ['status' => $status !== '' ? $status : 'unknown', 'paid' => false]; + } + + $withdraw = $this->creditor->findByReference($referenceId); + if (!$withdraw) { + return ['status' => 'paid', 'paid' => false]; + } + + $amount = (int) ($data['amount'] ?? 0); + if ($amount > 0 && (int) $withdraw->cash !== $amount) { + \Log::warning('montify.status_amount_mismatch', [ + 'reference_id' => $referenceId, + 'remote_amount' => $amount, + 'local_amount' => (int) $withdraw->cash, + ]); + + return ['status' => 'amount_mismatch', 'paid' => false]; + } + + $trackNumber = (string) ($data['track_number'] ?? ''); + $this->creditor->markPaidAndCredit( + $withdraw, + $trackNumber !== '' ? $trackNumber : (string) ($data['token'] ?? ''), + 'واریز درگاه Montify' + ); + + return ['status' => 'paid', 'paid' => true]; + } + public function handleWebhook(Request $request): array { $body = $request->all(); diff --git a/app/Services/Payment/Support/MontifyHmacSigner.php b/app/Services/Payment/Support/MontifyHmacSigner.php index 4236eb9..46e0ea8 100644 --- a/app/Services/Payment/Support/MontifyHmacSigner.php +++ b/app/Services/Payment/Support/MontifyHmacSigner.php @@ -47,26 +47,35 @@ class MontifyHmacSigner * @param array $query */ public function verifyReturnSignature(array $query): bool + { + return $this->matchReturnSignatureVariant($query) !== null; + } + + /** + * نام قالبی که امضا با آن خواند، یا null اگر هیچ‌کدام نخواند. + * + * پارتنر ممکن است مقادیر عددی را به‌صورت number امضا کند یا پارامتر خالی + * (مثل track_number) را اصلاً در payload نگذارد؛ همه حالت‌ها بررسی می‌شود. + * + * @param array $query + */ + public function matchReturnSignatureVariant(array $query): ?string { $signature = (string) ($query['signature'] ?? ''); if ($signature === '' || !ctype_xdigit($signature)) { - return false; + return null; } unset($query['signature']); - // فقط اسکالرها — همه string طبق مستند پارتنر - $payload = []; - foreach ($query as $key => $value) { - if (!is_string($key) || is_array($value) || $value === null) { - continue; + foreach ($this->payloadVariants($query) as $name => $payload) { + $expected = hash_hmac('sha256', $this->stableStringify($payload), $this->clientSecret); + if ($this->hashEqualsHex($signature, $expected)) { + return $name; } - $payload[$key] = (string) $value; } - $expected = hash_hmac('sha256', $this->stableStringify($payload), $this->clientSecret); - - return $this->hashEqualsHex($signature, $expected); + return null; } /** @@ -74,13 +83,79 @@ class MontifyHmacSigner */ public function verifyWebhookSignature(array $body, ?string $signatureHeader): bool { - if ($signatureHeader === null || $signatureHeader === '') { + $signature = (string) ($signatureHeader ?? ''); + if ($signature === '' || !ctype_xdigit($signature)) { return false; } - $expected = hash_hmac('sha256', $this->stableStringify($body), $this->clientSecret); + // بدنه همان‌طور که آمده (نوع‌ها از JSON) اولویت دارد، بعد حالت‌های نرمال‌شده + $candidates = array_merge( + ['raw' => $body], + $this->payloadVariants($body) + ); - return $this->hashEqualsHex($signatureHeader, $expected); + 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 $payload + * @return array> + */ + 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 $payload + * @return array + */ + 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 diff --git a/docs/WALLET_ONLINE_PAYMENT.md b/docs/WALLET_ONLINE_PAYMENT.md index ea754f9..5fbd475 100644 --- a/docs/WALLET_ONLINE_PAYMENT.md +++ b/docs/WALLET_ONLINE_PAYMENT.md @@ -130,9 +130,18 @@ Auth مشتری: `auth:customer` + `customer.active` (به‌جز return/webhook |-----|------|------|-----| | GET | `/wallet/payment/options` | مشتری | `{ online_enabled }` | | POST | `/wallet/payment/initiate` | مشتری | Body: `{ value }` (ریال) → `{ payment_url }` | +| POST | `/wallet/payment/verify-return` | مشتری | verify امضای بازگشت + شارژ | +| GET | `/wallet/payment/status` | مشتری | `?reference_id=WD-123` → استعلام مستقیم از درگاه | | GET | `/wallet/payment/return/{provider}` | عمومی | برگشت مرورگر؛ verify + گزارش | | POST | `/wallet/payment/webhook/{provider}` | عمومی (HMAC) | شارژ کیف | +### verify-return و status + +- فرانت **همه** پارامترهای query صفحه بازگشت را بدون فیلتر می‌فرستد؛ حذف حتی یک پارامتر امضا را می‌شکند. +- سرور امضا را با چند قالب payload می‌سنجد (مقادیر string، بدون مقادیر خالی، `amount`/`timestamp` عددی) تا اختلاف‌های جزئی پیاده‌سازی پارتنر باعث رد شدن پرداخت واقعی نشود. دستکاری مقادیر همچنان رد می‌شود. +- اگر امضا نخواند یا نتیجه هنوز قطعی نباشد، سرور `GET /external-payment/status` را از Montify می‌پرسد (`SupportsStatusQuery`) و در صورت `paid` کیف را idempotent شارژ می‌کند. redirect مرورگر هیچ‌وقت منبع حقیقت نیست. +- مالکیت `Withdraw` قبل از هر پاسخی با کاربر لاگین‌شده تطبیق داده می‌شود. + ### سازگاری قدیمی | مسیر قدیمی | معادل | @@ -263,6 +272,8 @@ app/Http/Controllers/pay/ | `مشکل در ارتباط با درگاه` | ساعت سرور (NTP)، `stableStringify`، PATH دقیق، body خام همان hash | | شارژ دوبل | باید idempotent باشد (`sts=2` + lock) | | secret در Network | مسیر باید `api/get_settings` باشد نه admin؛ فیلتر `Setting::forPublicApi` | +| `امضای نامعتبر` در verify-return | `php artisan montify:check-return "<کل URL بازگشت>"` را روی سرور بزنید؛ خروجی نشان می‌دهد کدام قالب می‌خواند یا اینکه `client_secret` غلط است. لاگ `montify.return_invalid_signature` هم کلیدهای دریافتی را دارد | +| پرداخت موفق ولی کیف شارژ نشد | `GET /wallet/payment/status?reference_id=WD-…`؛ لاگ `montify.status_failed` و رسیدن webhook را بررسی کنید | جزئیات امضای HMAC و خطاهای Montify: [`EXTERNAL_PAYMENT_PARTNER.md`](./EXTERNAL_PAYMENT_PARTNER.md) @@ -275,6 +286,7 @@ server/docs/WALLET_ONLINE_PAYMENT.md ← این سند server/docs/EXTERNAL_PAYMENT_PARTNER.md ← پروتکل Montify server/app/Services/Payment/... server/app/Http/Controllers/pay/WalletPaymentController.php +server/app/Console/Commands/MontifyCheckReturnCommand.php ← عیب‌یابی امضای بازگشت server/routes/customer.php server/app/Models/Setting.php ← فیلتر secret server/database/migrations/2026_07_24_000001_add_external_payment_settings.php diff --git a/routes/customer.php b/routes/customer.php index 5faf157..b3a068f 100644 --- a/routes/customer.php +++ b/routes/customer.php @@ -85,6 +85,7 @@ Route::middleware(['auth:customer', 'customer.active'])->group(function () use ( Route::get('/wallet/payment/options', [WalletPaymentController::class, 'options']); Route::post('/wallet/payment/initiate', [WalletPaymentController::class, 'initiate']); Route::post('/wallet/payment/verify-return', [WalletPaymentController::class, 'verifyReturn']); + Route::get('/wallet/payment/status', [WalletPaymentController::class, 'status']); // سازگاری قدیمی Route::post('/wallet/external-payment/initiate', [WalletPaymentController::class, 'initiate']);