diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md b/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md index cce347b9..794174ce 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [Unreleased] + +### Fixed + +- 비회원 NHN KCP 주문완료/주문상세에서 매출전표 조회가 인증 사용자 전용 라우트에 막히지 않도록 게스트 주문 토큰과 결제 성공 후 임시 영수증 쿠키 접근을 지원했습니다. + ## [1.0.0-beta.5] - 2026-06-23 ### Fixed diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Concerns/IssuesReceiptCookie.php b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Concerns/IssuesReceiptCookie.php new file mode 100644 index 00000000..84666529 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Concerns/IssuesReceiptCookie.php @@ -0,0 +1,69 @@ +signReceiptToken($orderNumber, $expiresTs); + $tokenValue = $orderNumber.'|'.$expiresTs.'|'.$signature; + + /** @var CookieJar $jar */ + $jar = app(CookieJar::class); + + $jar->queue(new Cookie( + name: self::RECEIPT_COOKIE_NAME, + value: $tokenValue, + expire: $expiresTs, + path: '/', + domain: null, + secure: request()->isSecure(), + httpOnly: true, + raw: false, + sameSite: Cookie::SAMESITE_LAX, + )); + } + + protected function verifyReceiptCookie(?string $cookieValue, string $expectedOrderNumber): bool + { + if (! is_string($cookieValue) || $cookieValue === '') { + return false; + } + + $parts = explode('|', $cookieValue); + if (count($parts) !== 3) { + return false; + } + + [$orderNumber, $expiresTs, $signature] = $parts; + + if ($orderNumber !== $expectedOrderNumber) { + return false; + } + + if (! ctype_digit($expiresTs) || (int) $expiresTs < time()) { + return false; + } + + if (! ctype_xdigit($signature) || strlen($signature) !== 64) { + return false; + } + + return hash_equals($this->signReceiptToken($orderNumber, (int) $expiresTs), $signature); + } + + private function signReceiptToken(string $orderNumber, int $expiresTs): string + { + return hash_hmac('sha256', $orderNumber.'|'.$expiresTs, (string) config('app.key', '')); + } +} diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentCallbackController.php b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentCallbackController.php index 94e5f13f..4156ebb5 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentCallbackController.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/PaymentCallbackController.php @@ -18,6 +18,7 @@ use Modules\Sirsoft\Ecommerce\Helpers\DeviceDetector; use Modules\Sirsoft\Ecommerce\Models\Order; use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService; use Plugins\Sirsoft\PayNhnkcp\Concerns\PreventsReplayCallback; +use Plugins\Sirsoft\PayNhnkcp\Concerns\IssuesReceiptCookie; use Plugins\Sirsoft\PayNhnkcp\Concerns\RecordsPaymentWindowClosure; use Plugins\Sirsoft\PayNhnkcp\Concerns\ResolvesEasyPayDisplay; use Plugins\Sirsoft\PayNhnkcp\Concerns\SanitizesPgResponse; @@ -37,6 +38,7 @@ use Plugins\Sirsoft\PayNhnkcp\Services\NhnKcpApiService; class PaymentCallbackController { use PreventsReplayCallback; + use IssuesReceiptCookie; use RecordsPaymentWindowClosure; use ResolvesEasyPayDisplay; use SanitizesPgResponse; @@ -217,6 +219,8 @@ class PaymentCallbackController 'transaction_id' => $order->payment?->transaction_id, ]); + $this->queueReceiptCookie($ordrIdxx); + return redirect($this->resolveSuccessUrl($ordrIdxx)); } @@ -302,6 +306,7 @@ class PaymentCallbackController // Replay 가드: 동일 tno 가 이미 paid 상태면 중복 처리하지 않고 성공 페이지로 복귀 if ($this->wasAlreadyPaid($tno)) { $this->logReplayDetected($tno, $ordrIdxx, 'authCallback (card)'); + $this->queueReceiptCookie($ordrIdxx); return redirect($this->resolveSuccessUrl($ordrIdxx)); } @@ -359,6 +364,8 @@ class PaymentCallbackController // (기본 PG가 타 PG일 때 주문이 해당 PG provider로 생성되는 경우 대비) $order->payment()->update(['pg_provider' => 'nhnkcp']); + $this->queueReceiptCookie($ordrIdxx); + return redirect($this->resolveSuccessUrl($ordrIdxx)); } catch (LockTimeoutException $e) { @@ -369,6 +376,8 @@ class PaymentCallbackController $latestOrder = $this->orderService->findByOrderNumber($ordrIdxx); if (($latestOrder?->payment?->isPaid() ?? false) || $latestOrder?->order_status === OrderStatusEnum::PAYMENT_COMPLETE) { + $this->queueReceiptCookie($ordrIdxx); + return redirect($this->resolveSuccessUrl($ordrIdxx)); } @@ -740,6 +749,8 @@ class PaymentCallbackController ])); } + $this->queueReceiptCookie($ordrIdxx); + return redirect($this->resolveSuccessUrl($ordrIdxx)); } diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/UserReceiptController.php b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/UserReceiptController.php index ce69d344..723e5336 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/UserReceiptController.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Controllers/UserReceiptController.php @@ -8,11 +8,14 @@ use App\Services\PluginSettingsService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Modules\Sirsoft\Ecommerce\Services\GuestOrderAuthService; +use Plugins\Sirsoft\PayNhnkcp\Concerns\IssuesReceiptCookie; use Plugins\Sirsoft\PayNhnkcp\Concerns\ResolvesEasyPayDisplay; use Plugins\Sirsoft\PayNhnkcp\Services\NhnKcpApiService; class UserReceiptController { + use IssuesReceiptCookie; use ResolvesEasyPayDisplay; private const PLUGIN_IDENTIFIER = 'sirsoft-pay_nhnkcp'; @@ -30,6 +33,7 @@ class UserReceiptController public function __construct( private readonly PluginSettingsService $pluginSettingsService, private readonly NhnKcpApiService $apiService, + private readonly GuestOrderAuthService $guestOrderAuthService, ) {} /** @@ -41,20 +45,37 @@ class UserReceiptController * 결제 영수증 URL(신용카드)과 현금영수증 URL(현금/계좌이체)을 반환한다. * receipt_url 이 비어있으면 transaction_id (tno) 로 KCP 영수증 URL 을 동적 생성. * - * @param Request $request 인증된 사용자 요청 + * @param Request $request 회원 또는 비회원 영수증 요청 * @param string $orderNumber 주문번호 * @return JsonResponse receipt_url / cash_receipt_url 또는 404 */ public function show(Request $request, string $orderNumber): JsonResponse { $user = $request->user(); - - $payment = DB::table('ecommerce_order_payments as p') + $query = DB::table('ecommerce_order_payments as p') ->join('ecommerce_orders as o', 'o.id', '=', 'p.order_id') ->where('o.order_number', $orderNumber) - ->where('o.user_id', $user->id) ->where('p.pg_provider', 'nhnkcp') - ->whereNotNull('p.transaction_id') + ->whereNotNull('p.transaction_id'); + + if ($user) { + $query->where('o.user_id', $user->id); + } else { + $order = $this->guestOrderAuthService->verifyToken($request->header('X-Guest-Order-Token'), $orderNumber); + + if ($order) { + $query->whereNull('o.user_id')->where('o.id', $order->id); + } elseif ($this->verifyReceiptCookie($request->cookie(self::RECEIPT_COOKIE_NAME), $orderNumber)) { + $query->whereNull('o.user_id') + ->where('o.id', function ($sub) use ($orderNumber) { + $sub->select('id')->from('ecommerce_orders')->where('order_number', $orderNumber); + }); + } else { + return response()->json(['error' => 'Not found'], 404); + } + } + + $payment = $query ->select([ 'p.transaction_id', 'p.payment_meta', diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/src/Providers/PayNhnkcpServiceProvider.php b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Providers/PayNhnkcpServiceProvider.php new file mode 100644 index 00000000..d3729e65 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/src/Providers/PayNhnkcpServiceProvider.php @@ -0,0 +1,24 @@ +name('payment.retry'); -Route::middleware(['auth:sanctum'])->group(function () { - Route::get('/user/orders/{orderNumber}/receipt', [UserReceiptController::class, 'show']) - ->name('user.orders.receipt'); +Route::get('/user/orders/{orderNumber}/receipt', [UserReceiptController::class, 'show']) + ->middleware('optional.sanctum') + ->name('user.orders.receipt'); +Route::middleware(['auth:sanctum'])->group(function () { // 테스트 모드 가상계좌 모의입금 정보 (입금대기 상태일 때만 데이터 반환) Route::get('/user/orders/{orderNumber}/vbank-mock-deposit-info', [UserVbankMockDepositController::class, 'show']) ->name('user.orders.vbank-mock-deposit-info'); diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/UserReceiptControllerTest.php b/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/UserReceiptControllerTest.php index e8f07439..b31af577 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/UserReceiptControllerTest.php +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/UserReceiptControllerTest.php @@ -5,15 +5,26 @@ declare(strict_types=1); namespace Plugins\Sirsoft\PayNhnkcp\Tests\Feature\Controllers; use App\Models\User; +use Illuminate\Cookie\Middleware\EncryptCookies; +use Illuminate\Support\Facades\Hash; use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory; use Modules\Sirsoft\Ecommerce\Database\Factories\OrderPaymentFactory; use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum; use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum; use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum; +use Modules\Sirsoft\Ecommerce\Services\GuestOrderAuthService; use Plugins\Sirsoft\PayNhnkcp\Tests\PluginTestCase; class UserReceiptControllerTest extends PluginTestCase { + protected function setUp(): void + { + parent::setUp(); + + // 운영에서는 PayNhnkcpServiceProvider::boot 가 동일하게 등록한다. + EncryptCookies::except(['nhnkcp_receipt_token']); + } + public function test_receipt_response_includes_easy_pay_display_label(): void { app()->setLocale('ko'); @@ -62,4 +73,121 @@ class UserReceiptControllerTest extends PluginTestCase $data['payment_method_display_label'] ?? null, ); } + + public function test_guest_receipt_requires_valid_token(): void + { + $order = $this->createGuestOrder(); + $this->createKcpPayment($order, 'KCP_TNO_GUEST_001'); + + $this->getJson("/api/plugins/sirsoft-pay_nhnkcp/user/orders/{$order->order_number}/receipt") + ->assertNotFound(); + + $this->withHeader('X-Guest-Order-Token', (time() + 1800).'|'.str_repeat('0', 64)) + ->getJson("/api/plugins/sirsoft-pay_nhnkcp/user/orders/{$order->order_number}/receipt") + ->assertNotFound(); + } + + public function test_guest_receipt_returns_receipt_url_with_valid_header_token(): void + { + app()->setLocale('ko'); + + $order = $this->createGuestOrder(); + $this->createKcpPayment($order, 'KCP_TNO_GUEST_002'); + + $response = $this->withHeader('X-Guest-Order-Token', $this->issueGuestToken($order)) + ->getJson("/api/plugins/sirsoft-pay_nhnkcp/user/orders/{$order->order_number}/receipt") + ->assertOk() + ->assertJsonPath('receipt_url', 'https://testadmin8.kcp.co.kr/assist/bill.BillActionNew.do?cmd=card_bill&tno=KCP_TNO_GUEST_002&order_no='.$order->order_number.'&trade_mony=1000'); + + $this->assertContains($response->json('payment_method_label'), ['신용카드', 'Credit Card']); + } + + public function test_guest_receipt_returns_receipt_url_with_valid_cookie(): void + { + $order = $this->createGuestOrder(); + $this->createKcpPayment($order, 'KCP_TNO_GUEST_003'); + + $this->withUnencryptedCookie('nhnkcp_receipt_token', $this->issueReceiptCookie($order->order_number)) + ->withCredentials() + ->getJson("/api/plugins/sirsoft-pay_nhnkcp/user/orders/{$order->order_number}/receipt") + ->assertOk() + ->assertJsonPath('receipt_url', 'https://testadmin8.kcp.co.kr/assist/bill.BillActionNew.do?cmd=card_bill&tno=KCP_TNO_GUEST_003&order_no='.$order->order_number.'&trade_mony=1000'); + } + + public function test_guest_token_for_other_order_cannot_access_receipt(): void + { + $order1 = $this->createGuestOrder(); + $order2 = $this->createGuestOrder(); + $this->createKcpPayment($order1, 'KCP_TNO_GUEST_004'); + $this->createKcpPayment($order2, 'KCP_TNO_GUEST_005'); + + $this->withHeader('X-Guest-Order-Token', $this->issueGuestToken($order1)) + ->getJson("/api/plugins/sirsoft-pay_nhnkcp/user/orders/{$order2->order_number}/receipt") + ->assertNotFound(); + } + + public function test_authenticated_user_cannot_access_guest_order_receipt_via_token(): void + { + $user = User::factory()->create(); + $order = $this->createGuestOrder(); + $this->createKcpPayment($order, 'KCP_TNO_GUEST_006'); + + $this->actingAs($user) + ->withHeader('X-Guest-Order-Token', $this->issueGuestToken($order)) + ->getJson("/api/plugins/sirsoft-pay_nhnkcp/user/orders/{$order->order_number}/receipt") + ->assertNotFound(); + } + + private function createGuestOrder() + { + return OrderFactory::new()->create([ + 'user_id' => null, + 'guest_lookup_password_hash' => Hash::make('test1234'), + 'order_number' => 'ORD-KCP-GUEST-' . random_int(10000, 99999), + 'order_status' => OrderStatusEnum::PAYMENT_COMPLETE, + 'total_amount' => 1000, + 'total_due_amount' => 0, + 'total_paid_amount' => 1000, + 'paid_at' => now(), + ]); + } + + private function createKcpPayment($order, string $transactionId): void + { + OrderPaymentFactory::new()->create([ + 'order_id' => $order->id, + 'payment_status' => PaymentStatusEnum::PAID, + 'payment_method' => PaymentMethodEnum::CARD, + 'pg_provider' => 'nhnkcp', + 'transaction_id' => $transactionId, + 'paid_amount_local' => 1000, + 'paid_at' => now(), + 'payment_meta' => [], + ]); + } + + private function issueGuestToken($order): string + { + $svc = app(GuestOrderAuthService::class); + $rc = new \ReflectionClass($svc); + $signMethod = $rc->getMethod('sign'); + $signMethod->setAccessible(true); + $suffixMethod = $rc->getMethod('passwordHashSuffix'); + $suffixMethod->setAccessible(true); + + $expiresTs = time() + 1800; + $suffix = $suffixMethod->invoke($svc, $order); + $sig = $signMethod->invoke($svc, $order->order_number, (int) $order->id, $expiresTs, $suffix); + + return $expiresTs.'|'.$sig; + } + + private function issueReceiptCookie(string $orderNumber): string + { + $expiresTs = time() + 300; + $payload = $orderNumber.'|'.$expiresTs; + $signature = hash_hmac('sha256', $payload, (string) config('app.key', '')); + + return $payload.'|'.$signature; + } } diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Unit/Concerns/IssuesReceiptCookieTest.php b/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Unit/Concerns/IssuesReceiptCookieTest.php new file mode 100644 index 00000000..e3f9f09f --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/tests/Unit/Concerns/IssuesReceiptCookieTest.php @@ -0,0 +1,92 @@ +subject = new class { + use IssuesReceiptCookie { + verifyReceiptCookie as public; + } + }; + } + + public function test_verify_succeeds_with_fresh_token_matching_order_number(): void + { + $orderNumber = 'ORD-RECEIPT-12345'; + $cookieValue = $this->buildToken($orderNumber, time() + 300); + + $this->assertTrue($this->subject->verifyReceiptCookie($cookieValue, $orderNumber)); + } + + public function test_verify_fails_when_cookie_missing(): void + { + $this->assertFalse($this->subject->verifyReceiptCookie(null, 'ORD-RECEIPT-99999')); + $this->assertFalse($this->subject->verifyReceiptCookie('', 'ORD-RECEIPT-99999')); + } + + public function test_verify_fails_when_token_format_invalid(): void + { + // 3-파트 미충족 + $this->assertFalse($this->subject->verifyReceiptCookie('ORD-X|123', 'ORD-X')); + // 만료시각이 숫자 아님 + $this->assertFalse($this->subject->verifyReceiptCookie('ORD-X|abc|'.str_repeat('0', 64), 'ORD-X')); + // 서명이 hex 아님 + $this->assertFalse($this->subject->verifyReceiptCookie('ORD-X|'.(time() + 300).'|invalid_sig', 'ORD-X')); + } + + public function test_verify_fails_when_token_expired(): void + { + $orderNumber = 'ORD-RECEIPT-EXPIRED'; + $expiredCookie = $this->buildToken($orderNumber, time() - 60); + + $this->assertFalse($this->subject->verifyReceiptCookie($expiredCookie, $orderNumber)); + } + + public function test_verify_fails_when_order_number_mismatched(): void + { + $tokenFor = 'ORD-RECEIPT-AAA'; + $accessing = 'ORD-RECEIPT-BBB'; + $cookieValue = $this->buildToken($tokenFor, time() + 300); + + // cookie 의 payload 가 다른 주문번호이므로 reject + $this->assertFalse($this->subject->verifyReceiptCookie($cookieValue, $accessing)); + } + + public function test_verify_fails_when_signature_tampered(): void + { + $orderNumber = 'ORD-RECEIPT-TAMPER'; + $expiresTs = time() + 300; + // 정상 서명 대신 0으로 가득 찬 위조 서명 + $tampered = $orderNumber.'|'.$expiresTs.'|'.str_repeat('0', 64); + + $this->assertFalse($this->subject->verifyReceiptCookie($tampered, $orderNumber)); + } + + private function buildToken(string $orderNumber, int $expiresTs): string + { + $payload = $orderNumber.'|'.$expiresTs; + $signature = hash_hmac('sha256', $payload, (string) config('app.key', '')); + + return $orderNumber.'|'.$expiresTs.'|'.$signature; + } +}