fix(payment): 나이스페이 SignData 방어와 비회원 영수증 조회 보강
This commit is contained in:
1 parent
646efeb91c
commit
d18aaab1fd
13 files changed
+464
-15
No files matched your search
@@ -8,6 +8,7 @@
|
||||
|
||||
### Security
|
||||
|
||||
- SignData 발급 요청에 주문번호+IP 단위 rate limit과 구매자 이메일/전화번호 대조를 추가해 공개 서명 엔드포인트의 주문번호/금액 프로빙 가능성을 줄였습니다.
|
||||
- 콜백 URL 생성 시 요청 헤더 대신 `config('app.url')`을 기준으로 절대 URL을 만들도록 고정해 프록시 환경 오동작과 헤더 기반 리다이렉트 변조 위험을 줄였습니다.
|
||||
- 카드 승인 콜백과 결제창 닫힘 통보 처리에 주문/결제 행잠금 후 재검증을 추가해 동시 콜백에서 마일리지, 재고, 결제 상태가 중복 반영되지 않도록 보강했습니다.
|
||||
- 에스크로 배송등록 시 결제된 에스크로 거래의 저장 TID와만 바인딩하고, 결제 당시 MID와 테스트/운영 모드로 API 컨텍스트를 복원하도록 수정했습니다.
|
||||
@@ -17,6 +18,7 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- 비회원 나이스페이먼츠 주문완료/주문상세에서 매출전표 조회가 인증 사용자 전용 라우트에 막히지 않도록 게스트 주문 토큰과 결제 성공 후 임시 영수증 쿠키 접근을 지원했습니다.
|
||||
- 나이스페이먼츠 인증 단계 실패(`AuthResultCode != 0000`)는 실제 PG 승인 전이므로 주문/결제/주문옵션을 취소 상태로 바꾸지 않고 체크아웃 재시도가 가능하도록 수정했습니다.
|
||||
- 다통화 주문의 환율 스냅샷이 누락되거나 0인 경우 SignData, 결제 승인 콜백, 결제창 닫힘 보고에서 예외가 일반 승인 오류로 뭉개지지 않도록 결제 통화 설정 오류로 명시 처리했습니다.
|
||||
- 나이스페이먼츠 간편결제 주문이 주문완료, 마이페이지 주문상세, 관리자 주문목록/상세에서 실제 간편결제 수단 대신 `신용카드`로만 표시되던 문제를 수정했습니다.
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
(function(){"use strict";function Oe(e){return/^https?:\/\//i.test(e)||e.startsWith("/api/")?e:e.startsWith("/plugins/")?`/api${e}`:e.startsWith("plugins/")?`/api/${e}`:e}function De(e){return e.trim().slice(0,160)}async function Be(e,t="nicepay-window-closed"){if(!e.closeReportUrl)return;const n={oid:e.oid,price:Number(e.price),buyer_email:e.buyer_email??"",buyer_phone:e.buyer_phone??"",payment_method:e.payment_method??"",reason:De(t)||"nicepay-window-closed"};try{const a=window.G7Core?.api;if(typeof a?.post=="function"){await a.post(e.closeReportUrl,n);return}await fetch(Oe(e.closeReportUrl),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n),keepalive:!0})}catch(a){console.warn("[sirsoft-pay_nicepayments] failed to report payment window close",a)}}function Ge(e){return new Promise((t,n)=>{if(document.querySelector(`script[src="${e}"]`)){t();return}const a=document.createElement("script");a.src=e,a.onload=()=>t(),a.onerror=()=>n(new Error(`Failed to load script: ${e}`)),document.head.appendChild(a)})}function je(e,t){const n=document.createElement("form");n.id="nicepayForm",n.method="post",n.action=e,n.acceptCharset="utf-8";for(const[a,o]of Object.entries(t)){const r=document.createElement("input");r.type="hidden",r.name=a,r.value=o,n.appendChild(r)}return document.body.appendChild(n),n}const qe="/plugins/sirsoft-pay_nicepayments/payment/callback",Fe="https://web.nicepay.co.kr/v3/v3Payment.jsp";function Ye(){if(typeof navigator>"u")return!1;const e=navigator;if(e.userAgentData?.mobile!==void 0)return e.userAgentData.mobile;const t=(navigator.userAgent||"").toLowerCase();return!!(/android|iphone|ipod|windows phone|iemobile|blackberry|opera mini|mobile safari/.test(t)||(navigator.maxTouchPoints??0)>1&&!t.includes("windows")&&!t.includes("macintosh"))}async function J(e,t){const{pgPaymentData:n,paymentMethod:a}=e.params??{},o=window.__templateApp?.globalState?._local,r=a??o?.paymentMethod??"card";if(!n){console.error("[sirsoft-pay_nicepayments] pgPaymentData is required");return}const i=window.G7Core;try{const s=await i.api.get("/modules/sirsoft-ecommerce/payments/client-config/nicepayments");if(!s.data){console.error("[sirsoft-pay_nicepayments] Failed to fetch client config",s);return}const l=s.data,u=Ye();if(!u&&(await Ge(l.sdk_url),typeof window.goPay!="function")){i?.toast?.error?.("나이스페이먼츠 SDK를 불러올 수 없습니다. 잠시 후 다시 시도해주세요."),i?.state?.setLocal?.({isSubmittingOrder:!1,paymentMethod:r});return}const b=window.location.origin+l.sign_data_url,m=typeof localStorage<"u"?localStorage.getItem("auth_token"):null,p={"Content-Type":"application/json","X-Requested-With":"XMLHttpRequest",Accept:"application/json"};m&&(p.Authorization=`Bearer ${m}`);const P=await fetch(b,{method:"POST",credentials:"include",headers:p,body:JSON.stringify({amt:n.amount,moid:n.order_number})});if(!P.ok)throw new Error("SignData 생성에 실패했습니다.");const g=await P.json(),I=window.location.origin+qe,f=typeof r=="string"&&r.startsWith("nicepay_"),C=f?"CARD":{card:"CARD",vbank:"VBANK",bank:"BANK",phone:"CELLPHONE"}[r??"card"]??"CARD",d={PayMethod:C,GoodsName:n.order_name,Amt:String(n.amount),MID:g.mid,Moid:n.order_number,BuyerName:n.customer_name??"",BuyerEmail:n.customer_email??"",BuyerTel:n.customer_phone??"",ReturnURL:I,EdiDate:g.ediDate,SignData:g.signData,CharSet:"utf-8",GoodsCl:"1",DirectShowOpt:"",DirectEasyPay:"",NicepayReserved:"",EasyPayMethod:"",MallReserved:"",MallReserved1:""};C==="CELLPHONE"&&(d.GoodsCl=n.goods_cl??"1");const c=l.useEscrow&&!f;if(d.TransType=c?"1":"0",f)switch(d.DirectShowOpt="CARD",d.MallReserved=`nicepay_easy_pay_method=${encodeURIComponent(r)}`,d.MallReserved1=r,r){case"nicepay_naverpay":d.DirectEasyPay="E020",d.EasyPayMethod="E020=CARD";break;case"nicepay_kakaopay":d.NicepayReserved="DirectKakao=Y";break;case"nicepay_samsungpay":d.DirectEasyPay="E021";break;case"nicepay_applepay":d.DirectEasyPay="E025";break;case"nicepay_payco":d.NicepayReserved="DirectPayco=Y";break;case"nicepay_skpay":d.NicepayReserved="DirectPay11=Y";break;case"nicepay_ssgpay":d.DirectEasyPay="E007";break;case"nicepay_lpay":d.DirectEasyPay="E018";break}try{const w=(await i.api.get(`/modules/sirsoft-ecommerce/user/orders/${n.order_number}`))?.data;if(w){const T=Number(w.total_tax_amount??0),E=Number(w.total_vat_amount??0),$e=Number(w.total_tax_free_amount??0);(T>0||E>0||$e>0)&&(d.TaxAmt=String(T),d.VatAmt=String(E),d.TaxFreeAmt=String($e))}}catch{}const y=je(I,d);if(u){y.action=Fe,y.acceptCharset="euc-kr";const S=y.querySelector('input[name="CharSet"]');S&&(S.value="euc-kr"),y.submit();return}window.nicepaySubmit=()=>{y.submit()};let k=!1;const z=new Set(document.body.children),Re=(S,w)=>{if(k)return;k=!0;const T=[S,w].filter(Boolean).join(": ")||"nicepay-window-closed";Be({closeReportUrl:l.close_report_url,oid:n.order_number,price:Number(n.amount),buyer_email:n.customer_email??"",buyer_phone:n.customer_phone??"",payment_method:r},T),window.remLine truncated
|
||||
(function(){"use strict";function Oe(e){return/^https?:\/\//i.test(e)||e.startsWith("/api/")?e:e.startsWith("/plugins/")?`/api${e}`:e.startsWith("plugins/")?`/api/${e}`:e}function De(e){return e.trim().slice(0,160)}async function Be(e,t="nicepay-window-closed"){if(!e.closeReportUrl)return;const n={oid:e.oid,price:Number(e.price),buyer_email:e.buyer_email??"",buyer_phone:e.buyer_phone??"",payment_method:e.payment_method??"",reason:De(t)||"nicepay-window-closed"};try{const a=window.G7Core?.api;if(typeof a?.post=="function"){await a.post(e.closeReportUrl,n);return}await fetch(Oe(e.closeReportUrl),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n),keepalive:!0})}catch(a){console.warn("[sirsoft-pay_nicepayments] failed to report payment window close",a)}}function Ge(e){return new Promise((t,n)=>{if(document.querySelector(`script[src="${e}"]`)){t();return}const a=document.createElement("script");a.src=e,a.onload=()=>t(),a.onerror=()=>n(new Error(`Failed to load script: ${e}`)),document.head.appendChild(a)})}function je(e,t){const n=document.createElement("form");n.id="nicepayForm",n.method="post",n.action=e,n.acceptCharset="utf-8";for(const[a,o]of Object.entries(t)){const r=document.createElement("input");r.type="hidden",r.name=a,r.value=o,n.appendChild(r)}return document.body.appendChild(n),n}const qe="/plugins/sirsoft-pay_nicepayments/payment/callback",Fe="https://web.nicepay.co.kr/v3/v3Payment.jsp";function Ye(){if(typeof navigator>"u")return!1;const e=navigator;if(e.userAgentData?.mobile!==void 0)return e.userAgentData.mobile;const t=(navigator.userAgent||"").toLowerCase();return!!(/android|iphone|ipod|windows phone|iemobile|blackberry|opera mini|mobile safari/.test(t)||(navigator.maxTouchPoints??0)>1&&!t.includes("windows")&&!t.includes("macintosh"))}async function J(e,t){const{pgPaymentData:n,paymentMethod:a}=e.params??{},o=window.__templateApp?.globalState?._local,r=a??o?.paymentMethod??"card";if(!n){console.error("[sirsoft-pay_nicepayments] pgPaymentData is required");return}const i=window.G7Core;try{const s=await i.api.get("/modules/sirsoft-ecommerce/payments/client-config/nicepayments");if(!s.data){console.error("[sirsoft-pay_nicepayments] Failed to fetch client config",s);return}const l=s.data,u=Ye();if(!u&&(await Ge(l.sdk_url),typeof window.goPay!="function")){i?.toast?.error?.("나이스페이먼츠 SDK를 불러올 수 없습니다. 잠시 후 다시 시도해주세요."),i?.state?.setLocal?.({isSubmittingOrder:!1,paymentMethod:r});return}const b=window.location.origin+l.sign_data_url,m=typeof localStorage<"u"?localStorage.getItem("auth_token"):null,p={"Content-Type":"application/json","X-Requested-With":"XMLHttpRequest",Accept:"application/json"};m&&(p.Authorization=`Bearer ${m}`);const P=await fetch(b,{method:"POST",credentials:"include",headers:p,body:JSON.stringify({amt:n.amount,moid:n.order_number,buyer_email:n.customer_email??"",buyer_phone:n.customer_phone??""})});if(!P.ok)throw new Error("SignData 생성에 실패했습니다.");const g=await P.json(),I=window.location.origin+qe,f=typeof r=="string"&&r.startsWith("nicepay_"),C=f?"CARD":{card:"CARD",vbank:"VBANK",bank:"BANK",phone:"CELLPHONE"}[r??"card"]??"CARD",d={PayMethod:C,GoodsName:n.order_name,Amt:String(n.amount),MID:g.mid,Moid:n.order_number,BuyerName:n.customer_name??"",BuyerEmail:n.customer_email??"",BuyerTel:n.customer_phone??"",ReturnURL:I,EdiDate:g.ediDate,SignData:g.signData,CharSet:"utf-8",GoodsCl:"1",DirectShowOpt:"",DirectEasyPay:"",NicepayReserved:"",EasyPayMethod:"",MallReserved:"",MallReserved1:""};C==="CELLPHONE"&&(d.GoodsCl=n.goods_cl??"1");const c=l.useEscrow&&!f;if(d.TransType=c?"1":"0",f)switch(d.DirectShowOpt="CARD",d.MallReserved=`nicepay_easy_pay_method=${encodeURIComponent(r)}`,d.MallReserved1=r,r){case"nicepay_naverpay":d.DirectEasyPay="E020",d.EasyPayMethod="E020=CARD";break;case"nicepay_kakaopay":d.NicepayReserved="DirectKakao=Y";break;case"nicepay_samsungpay":d.DirectEasyPay="E021";break;case"nicepay_applepay":d.DirectEasyPay="E025";break;case"nicepay_payco":d.NicepayReserved="DirectPayco=Y";break;case"nicepay_skpay":d.NicepayReserved="DirectPay11=Y";break;case"nicepay_ssgpay":d.DirectEasyPay="E007";break;case"nicepay_lpay":d.DirectEasyPay="E018";break}try{const w=(await i.api.get(`/modules/sirsoft-ecommerce/user/orders/${n.order_number}`))?.data;if(w){const T=Number(w.total_tax_amount??0),E=Number(w.total_vat_amount??0),$e=Number(w.total_tax_free_amount??0);(T>0||E>0||$e>0)&&(d.TaxAmt=String(T),d.VatAmt=String(E),d.TaxFreeAmt=String($e))}}catch{}const y=je(I,d);if(u){y.action=Fe,y.acceptCharset="euc-kr";const S=y.querySelector('input[name="CharSet"]');S&&(S.value="euc-kr"),y.submit();return}window.nicepaySubmit=()=>{y.submit()};let k=!1;const z=new Set(document.body.children),Re=(S,w)=>{if(k)return;k=!0;const T=[S,w].filter(Boolean).join(": ")||"nicepay-window-closed";Be({closeReportUrl:l.close_report_url,oid:n.order_number,price:Number(n.amount),buyer_email:n.customer_email??"Line truncated
|
||||
//# sourceMappingURL=plugin.iife.js.map
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"plugin.iife.js","sources":["../../resources/js/paymentCloseReport.ts","../../resources/js/handlers/requestPayment.ts","../../resources/js/handlers/setPaymentMethod.ts","../../resources/js/handlers/index.ts","../../resources/js/orderResponseInterceptor.ts","../../resources/js/checkoutEasyPayInjector.ts","../../resources/js/orderCompleteReceiptInjector.ts","../../resources/js/mypageOrderShowInjector.ts","../../resources/js/adminPaymentMethodBrandInjector.ts","../../resources/js/adminOrderPaymentDisplayInjector.ts","../../resources/js/index.ts"],"sourcesContent":["export interface PaymentCloseReportContext {\n closeReportUrl?: string;\n oid: string;\n price: number;\n buyer_email?: string;\n buyer_phone?: string;\n payment_method?: string;\n}\n\nfunction resolveApiUrl(url: string): string {\n if (/^https?:\\/\\//i.test(url) || url.startsWith('/api/')) {\n return url;\n }\n\n if (url.startsWith('/plugins/')) {\n return `/api${url}`;\n }\n\n if (url.startsWith('plugins/')) {\n return `/api/${url}`;\n }\n\n return url;\n}\n\nfunction trimReason(reason: string): string {\n return reason.trim().slice(0, 160);\n}\n\nexport async function reportPaymentWindowClosed(\n context: PaymentCloseReportContext,\n reason = 'nicepay-window-closed',\n): Promise<void> {\n if (!context.closeReportUrl) {\n return;\n }\n\n const payload = {\n oid: context.oid,\n price: Number(context.price),\n buyer_email: context.buyer_email ?? '',\n buyer_phone: context.buyer_phone ?? '',\n payment_method: context.payment_method ?? '',\n reason: trimReason(reason) || 'nicepay-window-closed',\n };\n\n try {\n const apiClient = ((window as any).G7Core)?.api;\n if (typeof apiClient?.post === 'function') {\n await apiClient.post(context.closeReportUrl, payload);\n return;\n }\n\n await fetch(resolveApiUrl(context.closeReportUrl), {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(payload),\n keepalive: true,\n });\n } catch (error) {\n console.warn('[sirsoft-pay_nicepayments] failed to report payment window close', error);\n }\n}\n","import { reportPaymentWindowClosed } from '../paymentCloseReport';\n\ninterface G7ApiResponse {\n data?: unknown;\n}\n\ninterface G7CoreApi {\n get: (url: string) => Promise<G7ApiResponse>;\n post?: (url: string, data?: Record<string, unknown>) => Promise<G7ApiResponse>;\n}\n\ninterface G7CoreToast {\n error: (message: string) => void;\n}\n\ninterface G7CoreStateApi {\n setLocal: (state: Record<string, unknown>) => void;\n}\n\ninterface G7Core {\n api: G7CoreApi;\n toast?: G7CoreToast;\n state?: G7CoreStateApi;\n}\n\ninterface TemplateLocalState {\n paymentMethod?: string;\n}\n\ninterface TemplateApp {\n globalState?: {\n _local?: TemplateLocalState;\n };\n}\n\ninterface PgPaymentData {\n order_number: string;\n order_name: string;\n amount: number;\n currency?: string;\n customer_name?: string;\n customer_email?: string;\n customer_phone?: string;\n goods_cl?: string; // 휴대폰결제 상품 유형: '0'=디지털컨텐츠, '1'=실물\n}\n\ninterface RequestPaymentParams {\n pgPaymentData: PgPaymentData;\n paymentMethod?: string;\n}\n\ninterface ClientConfig {\n mid: string;\n sdk_url: string;\n sign_data_url: string;\n close_report_url?: string;\n useEscrow?: boolean;\n}\n\ninterface SignDataResponse {\n ediDate: string;\n signData: string;\n mid: string;\n}\n\ninterface PaymentAction {\n params?: RequestPaymentParams;\n}\n\ndeclare global {\n interface Window {\n nicepaySubmit: () => void;\n nicepayClose: (resultCode: string, resultMsg: string) => void;\n goPay: (form: HTMLFormElement) => void;\n G7Core: G7Core;\n __templateApp?: TemplateApp;\n }\n}\n\nfunction loadScript(src: string): Promise<void> {\n return new Promise((resolve, reject) => {\n if (document.querySelector(`script[src=\"${src}\"]`)) {\n resolve();\n return;\n }\n\n const script = document.createElement('script');\n script.src = src;\n script.onload = () => resolve();\n script.onerror = () => reject(new Error(`Failed to load script: ${src}`));\n document.head.appendChild(script);\n });\n}\n\nfunction createPaymentForm(action: string, fields: Record<string, string>): HTMLFormElement {\n const form = document.createElement('form');\n form.id = 'nicepayForm';\n form.method = 'post';\n form.action = action;\n // CharSet 필드와 일치 — 모바일은 form.acceptCharset 으로 charset 결정\n form.acceptCharset = 'utf-8';\n\n for (const [name, value] of Object.entries(fields)) {\n const input = document.creatLine truncated
|
||||
{"version":3,"file":"plugin.iife.js","sources":["../../resources/js/paymentCloseReport.ts","../../resources/js/handlers/requestPayment.ts","../../resources/js/handlers/setPaymentMethod.ts","../../resources/js/handlers/index.ts","../../resources/js/orderResponseInterceptor.ts","../../resources/js/checkoutEasyPayInjector.ts","../../resources/js/orderCompleteReceiptInjector.ts","../../resources/js/mypageOrderShowInjector.ts","../../resources/js/adminPaymentMethodBrandInjector.ts","../../resources/js/adminOrderPaymentDisplayInjector.ts","../../resources/js/index.ts"],"sourcesContent":["export interface PaymentCloseReportContext {\n closeReportUrl?: string;\n oid: string;\n price: number;\n buyer_email?: string;\n buyer_phone?: string;\n payment_method?: string;\n}\n\nfunction resolveApiUrl(url: string): string {\n if (/^https?:\\/\\//i.test(url) || url.startsWith('/api/')) {\n return url;\n }\n\n if (url.startsWith('/plugins/')) {\n return `/api${url}`;\n }\n\n if (url.startsWith('plugins/')) {\n return `/api/${url}`;\n }\n\n return url;\n}\n\nfunction trimReason(reason: string): string {\n return reason.trim().slice(0, 160);\n}\n\nexport async function reportPaymentWindowClosed(\n context: PaymentCloseReportContext,\n reason = 'nicepay-window-closed',\n): Promise<void> {\n if (!context.closeReportUrl) {\n return;\n }\n\n const payload = {\n oid: context.oid,\n price: Number(context.price),\n buyer_email: context.buyer_email ?? '',\n buyer_phone: context.buyer_phone ?? '',\n payment_method: context.payment_method ?? '',\n reason: trimReason(reason) || 'nicepay-window-closed',\n };\n\n try {\n const apiClient = ((window as any).G7Core)?.api;\n if (typeof apiClient?.post === 'function') {\n await apiClient.post(context.closeReportUrl, payload);\n return;\n }\n\n await fetch(resolveApiUrl(context.closeReportUrl), {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(payload),\n keepalive: true,\n });\n } catch (error) {\n console.warn('[sirsoft-pay_nicepayments] failed to report payment window close', error);\n }\n}\n","import { reportPaymentWindowClosed } from '../paymentCloseReport';\n\ninterface G7ApiResponse {\n data?: unknown;\n}\n\ninterface G7CoreApi {\n get: (url: string) => Promise<G7ApiResponse>;\n post?: (url: string, data?: Record<string, unknown>) => Promise<G7ApiResponse>;\n}\n\ninterface G7CoreToast {\n error: (message: string) => void;\n}\n\ninterface G7CoreStateApi {\n setLocal: (state: Record<string, unknown>) => void;\n}\n\ninterface G7Core {\n api: G7CoreApi;\n toast?: G7CoreToast;\n state?: G7CoreStateApi;\n}\n\ninterface TemplateLocalState {\n paymentMethod?: string;\n}\n\ninterface TemplateApp {\n globalState?: {\n _local?: TemplateLocalState;\n };\n}\n\ninterface PgPaymentData {\n order_number: string;\n order_name: string;\n amount: number;\n currency?: string;\n customer_name?: string;\n customer_email?: string;\n customer_phone?: string;\n goods_cl?: string; // 휴대폰결제 상품 유형: '0'=디지털컨텐츠, '1'=실물\n}\n\ninterface RequestPaymentParams {\n pgPaymentData: PgPaymentData;\n paymentMethod?: string;\n}\n\ninterface ClientConfig {\n mid: string;\n sdk_url: string;\n sign_data_url: string;\n close_report_url?: string;\n useEscrow?: boolean;\n}\n\ninterface SignDataResponse {\n ediDate: string;\n signData: string;\n mid: string;\n}\n\ninterface PaymentAction {\n params?: RequestPaymentParams;\n}\n\ndeclare global {\n interface Window {\n nicepaySubmit: () => void;\n nicepayClose: (resultCode: string, resultMsg: string) => void;\n goPay: (form: HTMLFormElement) => void;\n G7Core: G7Core;\n __templateApp?: TemplateApp;\n }\n}\n\nfunction loadScript(src: string): Promise<void> {\n return new Promise((resolve, reject) => {\n if (document.querySelector(`script[src=\"${src}\"]`)) {\n resolve();\n return;\n }\n\n const script = document.createElement('script');\n script.src = src;\n script.onload = () => resolve();\n script.onerror = () => reject(new Error(`Failed to load script: ${src}`));\n document.head.appendChild(script);\n });\n}\n\nfunction createPaymentForm(action: string, fields: Record<string, string>): HTMLFormElement {\n const form = document.createElement('form');\n form.id = 'nicepayForm';\n form.method = 'post';\n form.action = action;\n // CharSet 필드와 일치 — 모바일은 form.acceptCharset 으로 charset 결정\n form.acceptCharset = 'utf-8';\n\n for (const [name, value] of Object.entries(fields)) {\n const input = document.creatLine truncated
|
||||
+17
@@ -13,6 +13,8 @@ const PG_PAYMENT = {
|
||||
order_number: 'ORD-001',
|
||||
order_name: 'Test Order',
|
||||
amount: 10000,
|
||||
customer_email: 'buyer@example.com',
|
||||
customer_phone: '01012345678',
|
||||
};
|
||||
|
||||
describe('requestPaymentHandler', () => {
|
||||
@@ -228,6 +230,21 @@ describe('requestPaymentHandler', () => {
|
||||
expect(getSubmittedPayMethod()).toBe('CARD');
|
||||
});
|
||||
|
||||
it('SignData 요청은 주문자 이메일과 전화번호를 함께 전송', async () => {
|
||||
await requestPaymentHandler({
|
||||
params: { pgPaymentData: PG_PAYMENT, paymentMethod: 'card' },
|
||||
});
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const [, init] = fetchSpy.mock.calls[0];
|
||||
expect(JSON.parse(String(init?.body))).toEqual({
|
||||
amt: PG_PAYMENT.amount,
|
||||
moid: PG_PAYMENT.order_number,
|
||||
buyer_email: PG_PAYMENT.customer_email,
|
||||
buyer_phone: PG_PAYMENT.customer_phone,
|
||||
});
|
||||
});
|
||||
|
||||
it("paymentMethod 미명시 시 _local.paymentMethod 로 fallback", async () => {
|
||||
(window as unknown as Record<string, unknown>).__templateApp = {
|
||||
globalState: { _local: { paymentMethod: 'vbank' } },
|
||||
|
||||
@@ -208,10 +208,7 @@ export async function requestPaymentHandler(action: PaymentAction, _context?: un
|
||||
}
|
||||
|
||||
// 3. 서버에서 EdiDate + SignData 생성
|
||||
// sign-data 엔드포인트는 'auth' 미들웨어가 걸려있어 Sanctum Bearer 토큰
|
||||
// 또는 세션 쿠키 중 하나가 필요. SPA 모드에서 토큰만 있는 경우를 대비해
|
||||
// localStorage 의 auth_token 을 Authorization 헤더로 명시 전달하고,
|
||||
// credentials:include 로 세션 쿠키도 함께 전송 (둘 중 하나만 있어도 통과).
|
||||
// 공개 엔드포인트지만 서버에서 주문번호, 결제 전 상태, 금액, 구매자 컨텍스트를 검증한다.
|
||||
const signDataUrl = window.location.origin + config.sign_data_url;
|
||||
const authToken = (typeof localStorage !== 'undefined') ? localStorage.getItem('auth_token') : null;
|
||||
const signDataHeaders: Record<string, string> = {
|
||||
@@ -226,7 +223,12 @@ export async function requestPaymentHandler(action: PaymentAction, _context?: un
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: signDataHeaders,
|
||||
body: JSON.stringify({ amt: pgPaymentData.amount, moid: pgPaymentData.order_number }),
|
||||
body: JSON.stringify({
|
||||
amt: pgPaymentData.amount,
|
||||
moid: pgPaymentData.order_number,
|
||||
buyer_email: pgPaymentData.customer_email ?? '',
|
||||
buyer_phone: pgPaymentData.customer_phone ?? '',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!signDataRes.ok) {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayNicepayments\Concerns;
|
||||
|
||||
use Illuminate\Cookie\CookieJar;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
|
||||
trait IssuesReceiptCookie
|
||||
{
|
||||
public const RECEIPT_COOKIE_NAME = 'nicepayments_receipt_token';
|
||||
private const RECEIPT_COOKIE_TTL_MINUTES = 5;
|
||||
|
||||
protected function queueReceiptCookie(string $orderNumber): void
|
||||
{
|
||||
$expiresTs = time() + (self::RECEIPT_COOKIE_TTL_MINUTES * 60);
|
||||
$signature = $this->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', ''));
|
||||
}
|
||||
}
|
||||
+31
@@ -15,6 +15,7 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Exceptions\PaymentAmountMismatchException;
|
||||
@@ -23,6 +24,7 @@ use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||||
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
|
||||
use Plugins\Sirsoft\PayNicepayments\Concerns\PreventsReplayCallback;
|
||||
use Plugins\Sirsoft\PayNicepayments\Concerns\IssuesReceiptCookie;
|
||||
use Plugins\Sirsoft\PayNicepayments\Concerns\RecordsPaymentWindowClosure;
|
||||
use Plugins\Sirsoft\PayNicepayments\Concerns\ResolvesEasyPayDisplay;
|
||||
use Plugins\Sirsoft\PayNicepayments\Concerns\SanitizesPgResponse;
|
||||
@@ -41,6 +43,7 @@ use Plugins\Sirsoft\PayNicepayments\Support\UrlHelper;
|
||||
class PaymentCallbackController
|
||||
{
|
||||
use PreventsReplayCallback;
|
||||
use IssuesReceiptCookie;
|
||||
use RecordsPaymentWindowClosure;
|
||||
use ResolvesEasyPayDisplay;
|
||||
use SanitizesPgResponse;
|
||||
@@ -199,6 +202,8 @@ class PaymentCallbackController
|
||||
'ip' => $request->ip(),
|
||||
]);
|
||||
|
||||
$this->queueReceiptCookie($moid);
|
||||
|
||||
return redirect($this->resolveSuccessUrl($moid));
|
||||
}
|
||||
$this->cache->put($replayCacheKey, true, 60);
|
||||
@@ -282,6 +287,7 @@ class PaymentCallbackController
|
||||
$issueStatus = $this->recordVbankIssueWithLock($moid, $pgResponse, $resultCode, $payMethod, $isEscrow, $txTid);
|
||||
if ($issueStatus === 'already_waiting') {
|
||||
$this->logReplayDetected((string) ($pgResponse['TID'] ?? $txTid), $moid, 'authCallback (vbank issue)');
|
||||
$this->queueReceiptCookie($moid);
|
||||
|
||||
return redirect($this->resolveSuccessUrl($moid));
|
||||
}
|
||||
@@ -308,6 +314,7 @@ class PaymentCallbackController
|
||||
|
||||
if ($completionStatus === 'already_paid') {
|
||||
$this->logReplayDetected($effectiveTid, $moid, 'authCallback (card)');
|
||||
$this->queueReceiptCookie($moid);
|
||||
|
||||
return redirect($this->resolveSuccessUrl($moid));
|
||||
}
|
||||
@@ -319,6 +326,7 @@ class PaymentCallbackController
|
||||
]);
|
||||
|
||||
$this->apiService->sendNetCancel($netCancelUrl, $txTid, $authToken, $amt);
|
||||
$this->queueReceiptCookie($moid);
|
||||
|
||||
return redirect($this->resolveSuccessUrl($moid));
|
||||
}
|
||||
@@ -336,6 +344,8 @@ class PaymentCallbackController
|
||||
}
|
||||
}
|
||||
|
||||
$this->queueReceiptCookie($moid);
|
||||
|
||||
return redirect($this->resolveSuccessUrl($moid));
|
||||
|
||||
} catch (PaymentAmountMismatchException $e) {
|
||||
@@ -382,6 +392,7 @@ class PaymentCallbackController
|
||||
$approvedTid = $pgResponse['TID'] ?? $txTid;
|
||||
if ($this->wasAlreadyPaid((string) $approvedTid)) {
|
||||
$this->logReplayDetected((string) $approvedTid, $moid, 'authCallback exception');
|
||||
$this->queueReceiptCookie($moid);
|
||||
|
||||
return redirect($this->resolveSuccessUrl($moid));
|
||||
}
|
||||
@@ -584,6 +595,12 @@ class PaymentCallbackController
|
||||
return response()->json(['error' => __('sirsoft-pay_nicepayments::messages.errors.invalid_request')], 400);
|
||||
}
|
||||
|
||||
$rateLimitKey = $this->signDataRateLimitKey($request, $moid);
|
||||
if (RateLimiter::tooManyAttempts($rateLimitKey, 20)) {
|
||||
return response()->json(['error' => __('sirsoft-pay_nicepayments::messages.errors.invalid_request')], 429);
|
||||
}
|
||||
RateLimiter::hit($rateLimitKey, 60);
|
||||
|
||||
// 주문 금액 검증: 클라이언트가 임의 금액으로 SignData를 요청하는 조작 방지
|
||||
$order = $this->orderService->findByOrderNumber($moid);
|
||||
|
||||
@@ -596,6 +613,15 @@ class PaymentCallbackController
|
||||
return response()->json(['error' => __('sirsoft-pay_nicepayments::messages.errors.order_not_found')], 422);
|
||||
}
|
||||
|
||||
if (! $this->requestMatchesOrderBuyer($request, $order)) {
|
||||
Log::warning('NicePayments: SignData buyer verification failed', [
|
||||
'moid' => $moid,
|
||||
'ip' => $request->ip(),
|
||||
]);
|
||||
|
||||
return response()->json(['error' => __('sirsoft-pay_nicepayments::messages.errors.invalid_request')], 403);
|
||||
}
|
||||
|
||||
$payment = $order->payment()->first();
|
||||
|
||||
if (! $payment || $payment->pg_provider !== 'nicepayments') {
|
||||
@@ -667,6 +693,11 @@ class PaymentCallbackController
|
||||
]);
|
||||
}
|
||||
|
||||
private function signDataRateLimitKey(Request $request, string $moid): string
|
||||
{
|
||||
return 'sirsoft-pay_nicepayments:sign-data:'.sha1($request->ip().'|'.$moid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 가상계좌 입금 통보 처리
|
||||
*
|
||||
|
||||
+29
-4
@@ -7,14 +7,21 @@ namespace Plugins\Sirsoft\PayNicepayments\Controllers;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Sirsoft\Ecommerce\Services\GuestOrderAuthService;
|
||||
use Plugins\Sirsoft\PayNicepayments\Concerns\IssuesReceiptCookie;
|
||||
use Plugins\Sirsoft\PayNicepayments\Concerns\ResolvesEasyPayDisplay;
|
||||
|
||||
class UserReceiptController
|
||||
{
|
||||
use IssuesReceiptCookie;
|
||||
use ResolvesEasyPayDisplay;
|
||||
|
||||
private const RECEIPT_BASE_URL = 'https://npg.nicepay.co.kr/issue/IssueLoader.do';
|
||||
|
||||
public function __construct(
|
||||
private readonly GuestOrderAuthService $guestOrderAuthService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 사용자 마이페이지 영수증 정보 조회
|
||||
*
|
||||
@@ -22,18 +29,36 @@ class UserReceiptController
|
||||
* receipt_url 이 비어있으면 transaction_id 로 NicePay IssueLoader URL 을 동적 생성.
|
||||
* 테스트 모드 결제는 is_test_mode=true 플래그로 표시되어 UI 에서 안내 가능.
|
||||
*
|
||||
* @param Request $request 인증된 사용자 요청
|
||||
* @param Request $request 회원 또는 비회원 영수증 요청
|
||||
* @param string $orderNumber 주문번호
|
||||
* @return JsonResponse receipt_url / cash_receipt_url / is_test_mode 또는 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', 'nicepayments');
|
||||
|
||||
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.receipt_url',
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayNicepayments\Providers;
|
||||
|
||||
use App\Extension\BasePluginServiceProvider;
|
||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||
|
||||
class PayNicepaymentsServiceProvider extends BasePluginServiceProvider
|
||||
{
|
||||
protected string $pluginIdentifier = 'sirsoft-pay_nicepayments';
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
// PG callback 이 발급하는 영수증 쿠키는 HMAC 으로 자체 무결성 보장하므로
|
||||
// EncryptCookies 대상에서 제외한다. 암호화 대상이면 callback 응답에서
|
||||
// 직접 발급한 평문 쿠키가 다음 API 요청에서 복호화 실패로 폐기될 수 있다.
|
||||
// 값 동기화: IssuesReceiptCookie::RECEIPT_COOKIE_NAME 과 동일해야 함.
|
||||
EncryptCookies::except(['nicepayments_receipt_token']);
|
||||
}
|
||||
}
|
||||
@@ -23,10 +23,9 @@ use Plugins\Sirsoft\PayNicepayments\Controllers\UserReceiptController;
|
||||
Route::post('/payment/close-report', [PaymentCloseReportController::class, 'store'])
|
||||
->name('payment.close-report');
|
||||
|
||||
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::prefix('admin')->name('admin.')->middleware(['auth:sanctum', 'admin'])->group(function () {
|
||||
// 가상계좌 입금통보 URL 조회 (관리자 설정 페이지 표시용)
|
||||
|
||||
+43
@@ -4,9 +4,11 @@ namespace Plugins\Sirsoft\PayNicepayments\Tests\Feature\Controllers;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Mockery;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderAddressFactory;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderOptionFactory;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderPaymentFactory;
|
||||
@@ -205,6 +207,47 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
->assertJsonPath('error', __('sirsoft-pay_nicepayments::messages.errors.invalid_amount'));
|
||||
}
|
||||
|
||||
public function test_sign_data_rejects_buyer_mismatch(): void
|
||||
{
|
||||
$order = $this->createTestOrder(50000);
|
||||
OrderAddressFactory::new()->forOrder($order)->shipping()->create([
|
||||
'orderer_email' => 'buyer@example.com',
|
||||
'orderer_phone' => '010-1234-5678',
|
||||
]);
|
||||
$this->mockPluginSettings();
|
||||
|
||||
$response = $this->postJson('/plugins/sirsoft-pay_nicepayments/payment/sign-data', [
|
||||
'amt' => 50000,
|
||||
'moid' => $order->order_number,
|
||||
'buyer_email' => 'attacker@example.com',
|
||||
'buyer_phone' => '01099999999',
|
||||
]);
|
||||
|
||||
$response->assertForbidden()
|
||||
->assertJsonPath('error', __('sirsoft-pay_nicepayments::messages.errors.invalid_request'));
|
||||
}
|
||||
|
||||
public function test_sign_data_is_rate_limited_by_order_and_ip(): void
|
||||
{
|
||||
$order = $this->createTestOrder(50000);
|
||||
$this->mockPluginSettings();
|
||||
|
||||
RateLimiter::clear('sirsoft-pay_nicepayments:sign-data:'.sha1('127.0.0.1|'.$order->order_number));
|
||||
|
||||
for ($i = 0; $i < 20; $i++) {
|
||||
$this->postJson('/plugins/sirsoft-pay_nicepayments/payment/sign-data', [
|
||||
'amt' => 50000,
|
||||
'moid' => $order->order_number,
|
||||
])->assertOk();
|
||||
}
|
||||
|
||||
$this->postJson('/plugins/sirsoft-pay_nicepayments/payment/sign-data', [
|
||||
'amt' => 50000,
|
||||
'moid' => $order->order_number,
|
||||
])->assertStatus(429)
|
||||
->assertJsonPath('error', __('sirsoft-pay_nicepayments::messages.errors.invalid_request'));
|
||||
}
|
||||
|
||||
public function test_sign_data_rejects_unchargeable_payment_currency(): void
|
||||
{
|
||||
$order = $this->createTestOrder(50000);
|
||||
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayNicepayments\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\PayNicepayments\Tests\PluginTestCase;
|
||||
|
||||
class UserReceiptControllerTest extends PluginTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// 운영에서는 PayNicepaymentsServiceProvider::boot 가 동일하게 등록한다.
|
||||
EncryptCookies::except(['nicepayments_receipt_token']);
|
||||
}
|
||||
|
||||
public function test_guest_receipt_requires_valid_token(): void
|
||||
{
|
||||
$order = $this->createGuestOrder();
|
||||
$this->createNicepaymentsPayment($order, 'NICE_TID_GUEST_001');
|
||||
|
||||
$this->getJson("/api/plugins/sirsoft-pay_nicepayments/user/orders/{$order->order_number}/receipt")
|
||||
->assertNotFound();
|
||||
|
||||
$this->withHeader('X-Guest-Order-Token', (time() + 1800).'|'.str_repeat('0', 64))
|
||||
->getJson("/api/plugins/sirsoft-pay_nicepayments/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->createNicepaymentsPayment($order, 'NICE_TID_GUEST_002');
|
||||
|
||||
$response = $this->withHeader('X-Guest-Order-Token', $this->issueGuestToken($order))
|
||||
->getJson("/api/plugins/sirsoft-pay_nicepayments/user/orders/{$order->order_number}/receipt")
|
||||
->assertOk()
|
||||
->assertJsonPath('receipt_url', 'https://npg.nicepay.co.kr/issue/IssueLoader.do?type=0&TID=NICE_TID_GUEST_002');
|
||||
|
||||
$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->createNicepaymentsPayment($order, 'NICE_TID_GUEST_003');
|
||||
|
||||
$this->withUnencryptedCookie('nicepayments_receipt_token', $this->issueReceiptCookie($order->order_number))
|
||||
->withCredentials()
|
||||
->getJson("/api/plugins/sirsoft-pay_nicepayments/user/orders/{$order->order_number}/receipt")
|
||||
->assertOk()
|
||||
->assertJsonPath('receipt_url', 'https://npg.nicepay.co.kr/issue/IssueLoader.do?type=0&TID=NICE_TID_GUEST_003');
|
||||
}
|
||||
|
||||
public function test_guest_token_for_other_order_cannot_access_receipt(): void
|
||||
{
|
||||
$order1 = $this->createGuestOrder();
|
||||
$order2 = $this->createGuestOrder();
|
||||
$this->createNicepaymentsPayment($order1, 'NICE_TID_GUEST_004');
|
||||
$this->createNicepaymentsPayment($order2, 'NICE_TID_GUEST_005');
|
||||
|
||||
$this->withHeader('X-Guest-Order-Token', $this->issueGuestToken($order1))
|
||||
->getJson("/api/plugins/sirsoft-pay_nicepayments/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->createNicepaymentsPayment($order, 'NICE_TID_GUEST_006');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withHeader('X-Guest-Order-Token', $this->issueGuestToken($order))
|
||||
->getJson("/api/plugins/sirsoft-pay_nicepayments/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-NICE-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 createNicepaymentsPayment($order, string $transactionId): void
|
||||
{
|
||||
OrderPaymentFactory::new()->create([
|
||||
'order_id' => $order->id,
|
||||
'payment_status' => PaymentStatusEnum::PAID,
|
||||
'payment_method' => PaymentMethodEnum::CARD,
|
||||
'pg_provider' => 'nicepayments',
|
||||
'transaction_id' => $transactionId,
|
||||
'receipt_url' => null,
|
||||
'paid_amount_local' => 1000,
|
||||
'paid_at' => now(),
|
||||
'payment_meta' => ['is_test_mode' => true],
|
||||
]);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayNicepayments\Tests\Unit\Concerns;
|
||||
|
||||
use Plugins\Sirsoft\PayNicepayments\Concerns\IssuesReceiptCookie;
|
||||
use Plugins\Sirsoft\PayNicepayments\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* 영수증 cookie HMAC 검증 로직 단위 테스트.
|
||||
*
|
||||
* HTTP 통합(callback → Set-Cookie → 후속 호출에서 cookie 자동 첨부) 검증은
|
||||
* Playwright 시나리오로 다룬다 — Laravel 의 EncryptCookies 미들웨어와 PHPUnit
|
||||
* HTTP 테스트 환경의 cookie 처리 흐름이 production 과 일부 다르기 때문.
|
||||
*/
|
||||
class IssuesReceiptCookieTest extends PluginTestCase
|
||||
{
|
||||
private object $subject;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// IssuesReceiptCookie 는 trait — 익명 클래스로 인스턴스화
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user