diff --git a/README.md b/README.md index 64840ef2d..b1cce59f0 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ cd gnuboard5 ## 문서 및 지원 -- [5.6.38 보안 업데이트 및 필수 DB업그레이드 안내](docs/release-5.6.38.md) +- [5.6.39 보안 업데이트 및 필수 DB업그레이드 안내](docs/release-5.6.39.md) - [5.6.37 SIRK 전용 카카오페이 연동 종료 및 기존 거래 지원](docs/sirk-kakaopay-retirement.md) - [그누보드5 공식 페이지](https://sir.kr/main/g5/) diff --git a/common.php b/common.php index 831dd8188..5e1339bf5 100644 --- a/common.php +++ b/common.php @@ -354,7 +354,11 @@ if( !function_exists('shop_check_is_pay_page') ){ // PG 결제시에 세션이 없으면 내 호출페이지를 다시 호출하여 쿠키 PHPSESSID를 살려내어 세션값을 정상적으로 불러오게 합니다. // 위와 같이 코드를 전부 한페이지에 넣은 이유는 이전 버전 사용자들이 패치시 어려울수 있으므로 한페이지에 코드를 다 넣었습니다. if(XenoPostToForm::check()) { - if ( shop_check_is_pay_page() ){ // PG 결제 리턴페이지에서만 사용 + // 토큰 복귀는 실제 엔드포인트에서 DB 해시를 검증한다. 여기서는 쿠키 재전송만 생략한다. + $g5_order_token_return = isset($_REQUEST['g5_order_state']) && is_string($_REQUEST['g5_order_state']) && + preg_match('/\A[0-9]{1,20}\.[a-f0-9]{64}\z/D', $_REQUEST['g5_order_state']) && + preg_match('~/mobile/shop/kcp/order_approval_form\.php$~', str_replace('\\', '/', $_SERVER['SCRIPT_NAME'])); + if ( shop_check_is_pay_page() && !$g5_order_token_return ){ // PG 결제 리턴페이지에서만 사용 XenoPostToForm::submit($_POST); // session_start(); 하기 전에 } } diff --git a/docs/database-migrations.md b/docs/database-migrations.md index 94f48dcea..edcc718ec 100644 --- a/docs/database-migrations.md +++ b/docs/database-migrations.md @@ -2,6 +2,14 @@ 데이터베이스 스키마 변경은 설정·목록·상세 화면이나 공통 라이브러리의 일반 실행 경로에서 수행하지 않는다. 신규 설치의 최종 스키마는 `install/gnuboard5.sql`에 반영하고, 기존 설치 변경은 `migrations` 디렉터리의 버전형 SQL 파일로 관리한다. +## 신규 설치 이력 + +기존 테이블이 없는 신규 설치는 최신 스키마와 기본 데이터를 생성한 뒤, `data/dbconfig.php`를 만들기 전에 배포본의 모든 마이그레이션 ID·설명·체크섬을 성공 이력으로 등록한다. 적용 시각은 설치 시각이고 실행시간은 0이다. 과거 마이그레이션 SQL은 실행하지 않으며, 쇼핑몰 설치 선택 여부와 관계없이 해당 배포본을 기준선으로 기록한다. 설치 직후 DB 업그레이드에서 별도의 기존 상태 확인 이력 등록이 필요하지 않다. + +마이그레이션 디렉터리가 비어 있거나 파일 읽기 실패 또는 이력 저장 실패 시 설치 완료를 중단한다. 해당 접두어의 기존 테이블이 있거나 기존 DB를 유지·재설치하는 경로는 일괄 완료로 기록하지 않는다. 남아 있는 쇼핑몰·SMS 등의 구버전 테이블을 완료로 오인하지 않도록 DB 업그레이드에서 실제 상태를 확인한다. + +`php tests/migration_install_history.php /path/to/isolated/mysql.sock`은 root/빈 비밀번호인 격리 MySQL에 임시 DB를 생성하여 사용자 지정 접두어, 쇼핑몰 선택 여부, 설치 이력과 체크섬, 재실행 방지, 이력 저장 실패 및 잘못된 이력 테이블의 1054 오류와 실행 차단을 검증한다. 검사 종료 시 임시 DB를 삭제한다. `python3 tests/migration_install_flow.py /path/to/isolated/mysql.sock`은 임시 설치 디렉터리에서 실제 설치 코드를 실행하여 일반·쇼핑몰 신규 설치, 기존 테이블 보존과 마이그레이션 파일이 없을 때 설정 파일 생성 차단을 검증한다. + ## 파일 규칙 - 파일명은 `YYYYMMDD_NNN_설명.sql` 형식을 사용한다. @@ -49,6 +57,8 @@ ## 권한과 배포 +`migrations/`는 업그레이드 완료 후에도 유지한다. Apache 2.4용 `migrations/.htaccess`를 함께 배포하며, Nginx는 별도의 서버 설정이 필요하다. 공유호스팅 적용 조건, 하위 경로 설치 예시와 개별 SQL 파일의 차단 확인 방법은 [마이그레이션 폴더 접근 차단](migration-directory-security.md)을 참고한다. HTTP 접근 차단 후에도 PHP의 서버 내부 파일 읽기는 허용해야 한다. + 관리자 DB업그레이드 실행 시에는 DB 계정에 필요한 DDL 권한이 있어야 한다. 평상시 DDL 권한을 제거한 운영 환경에서는 업그레이드 시점에 필요한 권한을 부여하고, 완료 후 운영 권한으로 복원한다. 배포 전에는 백업과 테이블 크기, 예상 잠금 시간을 확인한다. MySQL 계열 DB의 DDL은 트랜잭션으로 완전히 되돌릴 수 없으므로 실패한 마이그레이션의 오류를 확인하고 수동 복구 후 재실행한다. @@ -56,3 +66,14 @@ ## 기존 업그레이드 코드 과거 설치본을 화면 접근 시점에 보정하던 누적 DDL은 일반 실행 파일에서 제거했다. `orderupgrade.php`와 `adm/dbupgrade.php`에 누적됐던 배포 후 변경은 실제 도입 시점별 마이그레이션으로 이전했다. 조사 근거와 대응 목록은 `database-migration-history.md`에 기록한다. + +## 이력 테이블 구조 오류 복구 + +`Unknown column 'migration_id' in 'field list'`는 실행기가 사용하는 이력 테이블에 `migration_id` 컬럼이 없을 때 발생한다. 현재 설치 SQL에는 이 컬럼이 포함되어 있다. 기존의 `CREATE TABLE IF NOT EXISTS`는 이미 존재하는 테이블의 구조를 고치지 않으며, 과거 실행기는 이력 조회 실패를 빈 이력으로 취급하여 첫 이력 저장에서 오류가 드러날 수 있었다. 실제 발생 환경에서 구조가 달라진 경위는 테이블 정의와 설치 파일을 확인해야 한다. + +조회와 실행 시 필수 컬럼 및 `migration_id` 단일 기본키를 확인한다. 구조 또는 이력 조회에 실패하면 오류를 표시하고 마이그레이션 실행과 이력 등록을 중단한다. 화면 조회에서는 테이블을 변경하지 않는다. + +1. DB 백업 후 `SHOW CREATE TABLE g5_migrations`와 `SELECT COUNT(*) FROM g5_migrations`로 구조와 기존 이력을 확인한다. 사용자 지정 접두어를 사용했다면 실제 테이블명으로 바꾼다. +2. **잘못된 구조이며 이력이 없는 테이블**은 다른 용도로 사용되는 테이블이 아닌지 확인하고, 사용하지 않는 이름으로 보존한다. 예: `RENAME TABLE g5_migrations TO g5_migrations_backup_20260914`. 이름이 중복되지 않는지 먼저 확인한다. +3. 최신 파일을 배포한 뒤 DB 업그레이드의 전체 실행 또는 기존 상태 확인 이력 등록을 다시 수행한다. 정상 이력 테이블을 생성하고 실제 DB 상태에 따라 처리한다. 기존 상태 등록은 선행 실행 필요 항목에서 중단하므로 남은 작업은 전체 실행으로 처리한다. +4. **기존 이력이 있는 테이블**은 컬럼 매핑과 체크섬을 검토하여 현재 정의로 복구한다. 정상 성공 이력을 잃으면 조건 없는 데이터 보정이 재실행될 수 있으므로, 이력을 임의 삭제하거나 빈 테이블로 대체하지 않는다. `install/gnuboard5.sql` 전체를 기존 DB에 실행하면 테이블이 삭제되므로 복구용으로 실행하지 않는다. diff --git a/docs/migration-directory-security.md b/docs/migration-directory-security.md new file mode 100644 index 000000000..0a218fc91 --- /dev/null +++ b/docs/migration-directory-security.md @@ -0,0 +1,69 @@ +# 마이그레이션 폴더 접근 차단 + +`migrations/`는 DB 업그레이드에 필요한 SQL 파일을 보관한다. 업그레이드 완료 후에도 적용 상태와 체크섬 확인, 후속 업데이트를 위해 폴더와 파일을 유지한다. HTTP 접근만 차단하고, 서버 내부에서 PHP가 파일을 읽을 수 있도록 한다. + +## Apache 2.4 및 공유호스팅 + +배포본의 `migrations/.htaccess`에는 다음 설정이 포함된다. + +```apache +Require all denied +``` + +FTP·파일 관리자로 업로드할 때 숨김 파일인 `.htaccess`도 포함해야 한다. 기존에 해당 파일을 수정했다면 설정을 병합한다. 이 규칙은 폴더와 개별 SQL 파일의 HTTP 접근을 차단하며 관리자 DB 업그레이드의 파일 읽기에는 영향을 주지 않는다. + +호스팅에서 `.htaccess`와 인증·접근 제어 지시문을 허용해야 적용된다. `AllowOverride None`이면 무시되며, 지시문 사용이 제한된 환경에서는 HTTP 500이 발생할 수 있다. 차단 여부를 아래 방법으로 확인하고, 적용되지 않으면 호스팅 업체에 해당 폴더와 하위 파일의 HTTP 접근 차단을 요청한다. 웹서버 관리자는 `AllowOverride AuthConfig` 또는 필요한 지시문을 허용하는 설정을 검토할 수 있다. 자세한 조건은 [Apache 공식 안내](https://httpd.apache.org/docs/2.4/howto/htaccess.html)를 참고한다. + +## Nginx + +Nginx는 `.htaccess`를 적용하지 않는다. 사이트를 서비스하는 `server` 블록에 다음 설정을 추가한다. 도메인 루트에 그누보드를 설치한 경우의 예시다. + +```nginx +location = /migrations { + return 404; +} + +location ^~ /migrations/ { + return 404; +} +``` + +하위 경로에 설치했다면 실제 공개 URL에 맞춰 두 경로를 모두 바꾼다. 예를 들어 `/gnuboard5-dev/`에 설치한 경우 다음과 같다. + +```nginx +location = /gnuboard5-dev/migrations { + return 404; +} + +location ^~ /gnuboard5-dev/migrations/ { + return 404; +} +``` + +`^~`는 해당 접두어가 선택되었을 때 일반 정규식 location보다 우선하도록 한다. 기존의 더 구체적인 location, alias, 별도 호스트나 프록시 경로로 같은 파일을 제공한다면 해당 경로도 함께 차단한다. [Nginx location 공식 문서](https://nginx.org/en/docs/http/ngx_http_core_module.html#location)를 참고한다. + +서버 관리자는 설정 파일을 저장한 뒤 문법 검사를 통과한 경우에만 반영한다. + +```bash +sudo nginx -t +sudo systemctl reload nginx +``` + +공유호스팅에서 Nginx 설정 권한이 없다면 위 설치 경로와 설정 예시를 호스팅 업체에 전달한다. Apache 앞에서 Nginx가 정적 파일을 직접 제공하는 구성도 Nginx 측 차단이 필요하다. 이 작업을 위해 SQL 확장자나 파일명을 임의로 변경하지 않는다. + +## 적용 확인 + +사이트 주소와 설치 경로를 바꿔 확인한다. 마지막 URL은 실제 존재하는 SQL 파일이어야 한다. + +```bash +curl -i 'https://example.com/migrations' +curl -i 'https://example.com/migrations/' +curl -i 'https://example.com/migrations/20260910_001_kcp_notification.sql' +``` + +- 폴더와 개별 파일 요청 모두 HTTP 403 또는 404가 반환되고 SQL 본문이 노출되지 않아야 한다. 폴더 목록만 숨기는 `index.html`이나 디렉터리 목록 비활성화만으로는 개별 파일 다운로드를 막을 수 없다. +- 사이트 전체 인증으로 HTTP 401이 반환되는 것만으로 폴더 전용 차단을 확인할 수는 없다. 사이트 인증을 통과한 요청에서도 SQL 파일이 차단되는지 확인한다. +- HTTP 500은 정상 차단으로 간주하지 않는다. 호스팅의 허용 지시문과 오류 로그를 확인한다. +- 관리자 **환경설정 → DB업그레이드**에서 기존 마이그레이션 목록과 상태가 정상적으로 조회되는지 확인한다. 검증 목적으로 마이그레이션을 재실행할 필요는 없다. + +PHP 실행 계정에는 가능한 한 읽기 권한만 부여하고 파일 변경은 배포 계정으로 관리한다. 두 계정이 같은 공유호스팅에서는 계정별 권한 분리가 어려울 수 있으므로 `chmod` 값만으로 변조 방지가 보장된다고 안내하지 않는다. 폴더를 쓰기 가능하게 만들거나 `777`로 설정하지 않는다. DB 백업·실제 비밀번호·개인정보가 포함된 파일은 이 폴더에 보관하지 않는다. diff --git a/docs/release-5.6.39.md b/docs/release-5.6.39.md new file mode 100644 index 000000000..cfaf1ed25 --- /dev/null +++ b/docs/release-5.6.39.md @@ -0,0 +1,35 @@ +# 그누보드(영카트) 5.6.39 배포 안내 + +배포일: 2026-09-14. 변경 기준은 5.6.38입니다. + +이번 버전은 임시 주문 정보 접근과 결제 복귀 보호를 강화하고, 마이그레이션 경로 보호와 설치·DB업그레이드를 보완한 보안 업데이트입니다. **파일 반영 후 최고관리자 DB업그레이드가 필요합니다.** + +## 업데이트 절차 + +1. 신규 결제 접수를 잠시 중지하고 소스·설정·DB를 백업합니다. 진행 중인 결제와 PG에서 승인됐으나 주문이 완료되지 않은 거래를 먼저 대조합니다. +2. 5.6.38 설치본에 전체 소스 또는 `gnuboard5.6.39.patch.zip`을 반영합니다. 이번 패치의 삭제 대상 파일은 없습니다. 사용자 수정 파일은 비교·병합하고 `migrations/.htaccess`도 업로드합니다. 더 오래된 버전은 [5.6.38 안내](release-5.6.38.md)와 선행 버전의 설정·삭제 파일·DB 변경을 함께 확인하십시오. +3. 최고관리자로 **환경설정 → DB업그레이드**에서 선행 마이그레이션과 `20260914_001_order_access_state`를 실행합니다. 새 주문 상태 테이블은 InnoDB를 사용합니다. 주문 잠금과 장바구니 잠금은 별도 DB 연결을 사용하므로 일반 결제 요청당 최대 2개의 추가 연결을 고려하십시오. [DB업그레이드 안내](database-migrations.md)를 확인하십시오. +4. 사용자 주문 스킨·테마에 주문별 체크아웃 필드와 토큰 헤더 수신·결제 복귀 전달 변경을 반영하고 PHP·JavaScript 파일 및 캐시를 함께 갱신합니다. 공통 임시 저장에서 허용하지 않은 사용자 추가 필드는 저장되지 않으므로 [주문 보호 문서](security-kve-2026-2140.md)의 저장 허용 목록을 확인하십시오. +5. Apache는 `.htaccess` 적용 여부를 확인하고, Nginx는 별도의 접근 차단 설정을 반영합니다. `migrations/`와 실제 SQL 파일 URL 모두 403 또는 404가 반환되어야 합니다. 업그레이드 후에도 마이그레이션 파일을 유지하십시오. [서버별 설정 안내](migration-directory-security.md)를 확인하십시오. +6. 결제 복귀 URL은 HTTPS를 사용하고 웹 서버·프록시·APM에서 주문 상태 토큰을 기록하지 않도록 마스킹합니다. 과거 임시 주문은 PG 원장과 대조한 후 제공된 CLI 도구로 정리·복구합니다. 승인 여부가 불명확한 거래를 임의로 삭제하거나 다시 승인하지 마십시오. +7. 사용 중인 PG의 테스트 환경에서 PC·모바일, 회원·비회원, 결제 앱 복귀, 승인·취소·재시도, 비회원 주문 조회를 확인합니다. 주문·PG 금액과 상태가 일치하고 미완료 거래 처리 방침이 정해진 뒤 접수를 재개하십시오. + +## 보안 변경 + +- **KVE-2026-2140:** PC·모바일 Toss와 모바일 KCP의 임시 주문 복원·승인·주문 저장에 주문별 복귀 토큰, 소유자·장바구니·PG·유효기간 검증을 적용합니다. 쿠키 없는 복귀와 다중 탭을 주문별로 처리하며, 토큰으로 일반 로그인 세션을 발급하지 않습니다. +- 승인 상태와 결과를 기록하여 중복 승인과 응답 유실 후의 잘못된 재처리를 방지합니다. Toss는 거래 조회·멱등 처리로 복구하며, KCP의 불명확 거래와 주문 일부 저장은 운영자의 원장 대조가 필요합니다. +- 공통 임시 주문 저장 항목을 허용 목록으로 제한하고 비회원 비밀번호·토큰을 복원용 데이터와 주문 POST 로그에서 제외합니다. 비밀번호는 서버 해시로 보호하며 완료된 임시 정보와 만료 데이터를 정리하는 CLI 도구를 제공합니다. 기존 데이터는 파일 교체만으로 모두 정리되지 않습니다. +- `migrations/.htaccess`와 Nginx 설정 안내를 제공하여 마이그레이션 SQL 파일의 HTTP 접근을 차단할 수 있도록 합니다. + +## 설치·호환성 보완 + +- 신규 설치 완료 전에 해당 배포본의 마이그레이션 이력을 등록합니다. 이력 등록에 실패하면 설치 완료로 진행하지 않습니다. +- 기존 마이그레이션 이력 테이블의 구조 불일치와 조회 실패를 감지해 잘못된 업그레이드 실행을 차단합니다. +- PHP 5.2.17 구문 호환을 유지하도록 주문 보호 코드와 Toss 클래스 등을 보완합니다. 안전한 난수원·SHA-256·JSON이 필요하며, PG 연결에는 해당 PG가 요구하는 cURL/TLS 환경이 별도로 필요합니다. +- MySQL 5.0의 연결당 이름 잠금 제약을 고려하여 업무·주문·장바구니 잠금 연결을 분리합니다. 연결 실패 또는 잠금 유실 시 승인 상태 처리를 중단합니다. + +## 검증 범위와 운영 확인 + +주문 접근·저장 10건, 토큰 전달 8건, PHP 5.2.17·7.4.33·8.4.22의 호환 함수 각 35건과 운영 PHP 33개 파일의 문법 검사, 안전한 난수원 부재 시 거부 검사를 통과했습니다. MySQL 8.0.45의 실제 연결 잠금 9건, 마이그레이션 이력·줄바꿈 13건, 신규 설치 4개 시나리오와 설치 스키마 대조를 확인했습니다. 격리된 PHP 8.4·MySQL·HTTP 환경에서 결제 상태 50건과 주문 확정 37건을 통과했으며, PG 통신은 대역을 사용했습니다. + +실제 외부 PG 승인·취소·앱 복귀, MySQL 5.0 서버 자체 및 모든 사용자 스킨·운영 환경의 연동을 검증한 결과는 아닙니다. 다른 PG도 공통 저장 변경의 영향을 받으므로 정상 주문 확인이 필요합니다. 지원 버전의 구문 호환과 구형 TLS 환경에서의 실제 PG 연결 가능성은 구분해야 합니다. 상세 복구·정리 절차와 검증 한계는 [KVE-2026-2140 문서](security-kve-2026-2140.md)를 참고하십시오. diff --git a/docs/security-kve-2026-2140.md b/docs/security-kve-2026-2140.md new file mode 100644 index 000000000..87bb0f2c7 --- /dev/null +++ b/docs/security-kve-2026-2140.md @@ -0,0 +1,110 @@ +# KVE-2026-2140 주문 상태·복귀·복구 + +## 구현 범위 + +1차 세션 검사에 주문별 복귀 토큰, 영속 승인 기록, 데이터 정리와 운영 대조 도구를 추가했다. PC·모바일 Toss와 모바일 KCP의 반환·승인·주문 저장을 대상으로 한다. 공통 임시 저장의 필드 제한과 비밀번호 보호는 다른 PG에도 적용된다. 다른 PG의 쿠키 없는 복귀·승인 재시도를 이 구현이 대신 처리하지는 않는다. + +- 임시 저장은 256비트 난수 토큰을 발급한다. DB에는 SHA-256 해시만 저장하고, 토큰 원문은 `X-G5-Order-State` 응답 헤더로 현재 탭에 전달한다. +- 토큰은 주문번호·PG·회원/비회원·장바구니·일반/개인결제·저장 본문 해시·만료 시각에 연결된다. 원문 임시 데이터와 비밀번호 해시를 읽기 전에 토큰과 최소 메타데이터를 검증한다. +- 새 테이블 `order_access`의 주문별 잠금과 최종 처리 시 장바구니별 잠금을 사용한다. 서로 다른 세션/프로세스에서도 승인부터 주문 저장까지 중복 처리를 막는다. 같은 장바구니의 다른 미완료 승인도 차단한다. +- 폼을 표시할 때 주문별 체크아웃 nonce와 장바구니를 세션에 등록한다. 다른 탭이 현재 주문 세션을 바꾸더라도 원래 탭은 자기 주문을 저장·복원한다. 같은 상품 장바구니를 두 번 결제하는 것은 허용하지 않는다. +- 쿠키 없는 복귀는 토큰으로 서버 상태를 복원한다. 회원 주문의 경우 해당 결제 요청 안에서만 원래 회원 컨텍스트를 적용하고 로그인 세션은 발급하지 않는다. 다른 회원으로 로그인한 브라우저의 토큰 사용은 거부한다. 결제 토큰은 해당 주문에 대한 bearer 권한이므로 로그·URL 보관에 주의한다. +- 최종 주문의 이름·주소·금액·포인트·쿠폰·배송비 등 업무 필드는 서버가 저장한 데이터로 복원한다. 결제 키·PG 암호문 등 승인 응답 필드는 별도로 검증한다. 정상 결제 금액은 기존 서버 장바구니 계산값 및 개인결제 금액과 대조한다. +- 공통 임시 저장은 `lib/shop_order_fields.lib.php`의 기본 주문서/PG별 허용 목록만 저장한다. 비밀번호와 토큰은 복원용 데이터에 보관하지 않는다. 사용자 스킨의 추가 업무 필드는 서버 허용 목록에 명시적으로 추가해야 한다. +- 비회원 비밀번호는 서버 해시로 분리 보관하며, 최종 주문에서 재해싱하지 않는다. 완료 후 임시 행·토큰·해시·응답 상태를 정리한다. 주문 POST 로그에서 비밀번호와 상태/체크아웃 토큰을 제외한다. + +## 승인 상태와 복구 + +| 상태 | 처리 | +|---|---| +| `pending` | 승인 미시작. 기본 복귀 수명 2시간. `G5_ORDER_DATA_ACCESS_TTL`로 조정 가능 | +| `approving` | PG 요청 직전에 금액·요청 식별자를 영속 기록. 응답 유실 가능성이 있으므로 무조건 재승인하지 않음 | +| `approved` | 검증된 PG 결과를 저장. 후속 주문 저장을 계속할 수 있음 | +| `finalizing` | 주문 DB 반영을 시작함. 일부만 저장되었을 가능성이 있음 | +| `completed` | 주문 처리가 끝남. 토큰·비밀번호 해시·PG 응답은 지워짐 | +| `cancel_pending` | 취소 요청 시작. 결과 대조 전에는 승인 결과를 재사용하지 않음 | +| `cancelled`, `failed` | 확인된 종료 상태. 유예 후 정리 가능 | +| `legacy`, `unknown` | 운영자 대조 필요. 자동 삭제·재승인·토큰 발급 금지 | +| `purged` | 임시 개인정보 및 인증 상태 제거. 주문번호 재사용 방지용 기록만 유지 | + +Toss는 안정적인 `Idempotency-Key`로 승인한다. 응답을 놓친 요청과 승인 후 중단된 요청은 PG 조회를 먼저 수행하고 주문번호·결제 키·금액·상태를 대조한다. 취소·실패한 거래의 저장된 성공 응답을 재사용하지 않는다. PG가 같은 거래의 `IN_PROGRESS` 상태를 확인한 경우에만 동일 키로 승인 재시도하며, 멱등 키 유효기간보다 짧은 14일로 제한한다. 조회 자체가 실패하면 재승인하지 않는다. 취소에도 동일 거래의 고정 멱등 키와 거래번호 대조를 적용한다. + +KCP는 검증된 승인 결과를 별도 기록해 같은 요청 재처리 시 결과를 복원한다. 통신 도중 중단되어 승인 결과가 없는 경우에는 KCP 거래 원장을 확인한 운영자가 복구 자료를 제공해야 한다. 자동 조회 API를 임의로 가정하지 않았다. + +일반 주문이 `finalizing`에서 중단됐으나 주문 행이 없고 원래 선택 장바구니가 남아 있으면 승인된 거래를 계속 처리한다. 주문 행이 이미 생겼거나 개인결제가 일부 반영된 경우에는 자동 덮어쓰기/재승인하지 않는다. 포인트·쿠폰·주문·장바구니를 운영자가 대조한 뒤 완료 처리한다. 기존 주문/장바구니 테이블이 MyISAM인 환경에서 전체 업무 트랜잭션을 원자적으로 되돌릴 수 있다고 가정하지 않는다. + +## 설치·배포 + +신규 설치 SQL과 `migrations/20260914_001_order_access_state.sql`을 함께 제공한다. 기존 설치는 최고관리자 DB업그레이드에서 선행 마이그레이션을 처리한 후 이 마이그레이션을 적용한다. 일반 페이지에서는 DDL을 실행하지 않는다. 새 테이블은 InnoDB를 사용한다. MySQL 5.0의 연결당 이름 잠금 하나 제약에 맞춰 주문 잠금과 장바구니 잠금은 각각 별도 비영속 DB 연결을 사용한다. 기존 업무·포인트 DB 연결의 잠금과 분리되며, 연결 종료 시 서버가 잠금을 해제한다. 이 처리에는 일반 결제 요청당 최대 2개의 추가 DB 연결이 필요하다. 연결·잠금 획득 실패나 잠금 연결 유실이 확인되면 승인 상태 처리를 중단한다. 정리 도구는 항목별로 잠금을 해제하여 처리 건수만큼 연결이 누적되지 않는다. + +배포 순서: + +1. 신규 결제 접수를 일시 중단하고 DB 및 웹 공개 경로 밖의 미완료 거래 백업을 확보한다. +2. 미완료 주문·PG 승인 내역을 확인한다. 승인된 거래를 정리 대상으로 표시하지 않는다. +3. 마이그레이션과 PHP/JS 변경을 함께 적용한다. 사용자 스킨에도 체크아웃 필드, 응답 헤더 수신 및 복귀 토큰 전달 변경을 반영한다. 새 PHP에 예전 JS가 캐시되지 않도록 배포 캐시를 갱신한다. +4. 반환 URL의 HTTPS와 토큰 전달을 확인한다. Toss 성공/실패 URL과 KCP Ret_URL에 `g5_order_state`가 전달된다. KCP의 쿠키 재전송 우회는 토큰 형태를 인식하는 것뿐이며, 실제 인증은 엔드포인트의 DB 해시 검증으로 수행한다. +5. 웹서버·프록시·APM에서 결제 복귀 URL의 쿼리와 Referer를 로그에 기록하지 않거나 `g5_order_state`를 마스킹한다. 예를 들어 Nginx 결제 반환 로그는 `$request_uri`/`$request` 대신 쿼리 없는 `$uri`를 사용한다. 응답은 `no-store` 및 `Referrer-Policy: no-referrer`를 설정한다. 응답 헤더와 POST 본문을 별도 수집하는 서비스에도 같은 마스킹을 적용한다. +6. 계약된 PG 테스트 환경에서 외부 앱 복귀·성공·실패·취소·재통보를 확인한 후 접수를 재개한다. 코드 push와 운영 배포/PG 검증은 구분한다. + +## 기존 요청과 정리 도구 + +`php tools/shop-order-maintenance.php`는 CLI 전용이며 기본값은 읽기 전용 목록이다. 웹에서는 실행되지 않는다. 목록과 오류에 토큰·비밀번호·전체 개인정보·PG 응답을 출력하지 않는다. + +```sh +php tools/shop-order-maintenance.php --action=list +php tools/shop-order-maintenance.php --action=cleanup +php tools/shop-order-maintenance.php --action=cleanup --apply +php tools/shop-order-maintenance.php --action=reconcile-toss --order=주문번호 --apply +php tools/shop-order-maintenance.php --action=quarantine-legacy --order=주문번호 --apply +``` + +`cleanup`은 기본 24시간의 유예 후 완료·확인된 실패·취소 및 만료된 미승인 Toss/KCP 요청만 처리한다. 1회 최대 500건이다. `--grace=초`는 최소 1시간이다. `approving/approved/finalizing/unknown/cancel_pending`과 검토 전 과거 데이터는 자동 정리하지 않는다. 운영 환경에서 명시적으로 예약 실행할 수 있다. 일반 조회 요청에 정리 작업을 붙이지 않는다. + +1차 패치의 주문·세션·본문 해시 검증을 통과하는 과거 요청은 새 상태로 전환할 수 있다. 그보다 오래된 요청은 주문번호 또는 PAYREQ_MAP만으로 신뢰하거나 토큰을 재발급하지 않는다. `quarantine-legacy`는 지정 주문의 평문 비밀번호를 제거하고 해시를 별도 보관하되 접근 불가능한 `legacy` 상태로 격리한다. 승인된 미완료 거래를 먼저 PG/주문 자료와 대조해야 한다. + +KCP의 불명확한 승인 결과는 원장을 확인한 뒤 다음 명령으로 반영한다. JSON에는 `orderId`, `amount`, `tno`, `res_cd`와 해당 결제수단의 `app_time`, `app_no`, 은행/가상계좌 등의 실제 승인 결과를 넣는다. 자료는 웹 공개 경로 밖에 보관한다. 이 명령은 운영자의 원장 확인을 명시적으로 신뢰하며, 파일 자체를 PG 서명으로 간주하지 않는다. + +```sh +php tools/shop-order-maintenance.php --action=reconcile-kcp --order=주문번호 --receipt=/private/verified-kcp.json --verified --apply +php tools/shop-order-maintenance.php --action=complete-reviewed --order=주문번호 --verified --apply +php tools/shop-order-maintenance.php --action=purge-reviewed-legacy --order=주문번호 --receipt=/private/legacy-review.json --verified --apply +``` + +`complete-reviewed`는 `finalizing` 거래의 주문·PG·장바구니·포인트·쿠폰 대조를 마친 경우에만 사용한다. 저장된 거래번호도 승인 기록과 비교한다. 기존 데이터 삭제 자료는 `orderId`, `resolution`(`unpaid/cancelled/completed`), `reference`(원장 대조 기록)를 포함한다. `completed`를 선택하면 실제 저장된 주문/개인결제의 거래번호가 있어야 한다. 이 도구는 불명확한 거래를 자동으로 미승인으로 판정하지 않는다. + +## 검증 방법과 한계 + +- `php tests/shop_order_access_test.php`: 1차 세션 상태의 안전한 전환 경계와 저장 허용 목록. +- `node tests/shop_order_state_browser_test.js`: 토큰 헤더·폼 전달·콜백 URL 인코딩. DOM 대역이며 실제 PG SDK 실행은 아니다. +- `tests/shop_order_state_test.py`: 실제 PHP/DB/HTTP, 쿠키 없는 Toss/KCP 복귀, 위조 토큰, 주문/금액/키 변조, 다중 탭, 병렬 프로세스 승인, 응답 유실·부분 저장·취소 상태, 정리와 과거 요청, 신규/기존/쇼핑몰 미설치 마이그레이션. +- `tests/shop_order_finalize_test.py`: 별도 테스트 설치본에서 PG 전송 클래스만 대체하고 실제 PC·모바일 주문 확정, 개인결제, 비회원 주문 조회, 완료 정리, 중복 POST를 실행한다. 테스트가 끝나면 원래 전송 클래스를 복원한다. + +Python 검증에는 `G5_ORDER_TEST_CONFIG`로 별도 설치본의 JSON(`root`, `php`, `ini`, `port`)을 지정한다. 해당 설치본은 loopback DB 주소와 `issue48_` 접두어의 데이터베이스를 사용해야 하며 `data/.order-state-test`에 `KVE-2026-2140-local-test`를 적어 명시적으로 표시한다. fixture PHP는 운영 DB에서 실행을 거부한다. 테스트 파일은 Git에는 보관하지만 일반 배포 아카이브에서 제외한다. + +실제 Toss/KCP 네트워크 승인·취소·재통보, PG 앱/브라우저별 쿠키 정책, 다른 PHP/DB 버전, 모든 사용자 스킨의 호환성은 이 로컬 대역 검증으로 확정하지 않는다. KCP 불명확 거래와 부분 주문 저장은 위 운영자 대조 경로로 처리하며 무조건 자동 복구한다고 안내하지 않는다. + +Toss API 근거: [결제 API](https://docs.tosspayments.com/reference), [멱등성과 결제 후처리](https://docs.tosspayments.com/guides/v2/get-started/llms-quick-reference). + +## 최소 버전 호환 처리 + +PHP 최소 버전은 기존 PHP 5.2.17을 유지한다. 공통 주문 라이브러리의 익명 함수와 `__DIR__`, PHP 7 전용 난수·역직렬화 옵션 의존을 제거했다. PC·모바일 주문서의 익명 콜백과 PHP 5.4 이후 JSON 출력 옵션, Toss 클래스의 스칼라·반환·프로퍼티 타입 선언도 구버전에서 읽을 수 있게 변경했다. 운영 도구는 구버전 HTTP 상태 헤더·JSON 출력·예외 처리를 사용한다. + +`lib/shop_order_compat.lib.php`에서 다음을 공통 처리한다. + +- 난수는 `random_bytes`, 강한 OpenSSL 난수, `/dev/urandom`, `MCRYPT_DEV_URANDOM` 순으로 사용 가능한 안전한 소스를 찾는다. 약한 난수로 대체하지 않으며 안전한 소스가 없으면 결제를 진행하지 않는다. +- 토큰·해시는 길이와 자료형을 확인하고 같은 길이의 문자열은 끝까지 비교한다. +- 기존 Base64/serialize 임시 데이터는 배열·문자열·정수·유한 실수·불리언·null만 읽는 전용 파서로 복원한다. 객체·참조·잘못된 길이·과도한 크기/깊이는 거부하며 PHP 객체 생성이나 `unserialize()`를 호출하지 않는다. 원문 길이와 바이너리 문자열을 유지하므로 기존 주문 필드와 KCP 응답을 읽을 수 있다. + +인증 토큰 생성과 주문 상태 처리에는 hash(SHA-256)·JSON 및 안전한 난수원이 필요하다. PG 통신에는 해당 PG가 요구하는 cURL/TLS 환경이 별도로 필요하며, PHP 구문 호환이 구형 TLS 라이브러리로 실제 PG 연결까지 가능함을 보장하지 않는다. 사용자 주문 스킨의 토큰 전달 반영 요건은 그대로 유지한다. + +검증 명령: + +```sh +php tests/shop_order_compat_test.php +php -d disable_functions=random_bytes,openssl_random_pseudo_bytes,mcrypt_create_iv tests/shop_order_compat_test.php --no-rng +php tests/shop_order_lock_test.php 127.0.0.1:포트 issue48_테스트DB +``` + +설계 근거: [MySQL의 GET_LOCK 버전별 동작](https://dev.mysql.com/blog-archive/making-get_lock-behavior-more-predictable-cross-version-with-query-rewrite/)과 [mysql_connect의 new_link 옵션](https://www.php.net/manual/en/function.mysql-connect.php)을 기준으로 잠금 연결을 분리했다. PHP의 [random_bytes 지원 범위](https://www.php.net/manual/en/function.random-bytes.php)와 [PHP 7의 역직렬화 필터 도입](https://www.php.net/manual/en/migration70.new-features.php)을 확인하고 구버전용 처리를 추가했다. DB 파일 잠금은 여러 웹서버 간 보호를 보장하지 않으므로 사용하지 않는다. + +호환 함수와 구형 mysql 드라이버의 단일 잠금 동작 대역은 PHP 5.2.17·7.4·8.4에서 검사한다. 실제 잠금 검사는 root/빈 비밀번호인 격리 MySQL에서 업무·주문·장바구니 연결 분리, 다른 프로세스의 경합, 종료·연결 단절 시 해제와 유실 감지를 확인한다. MySQL 5.0 서버 자체의 실행 검증과 실제 PG 통신은 이 검사에 포함하지 않는다. 기존 주문 상태·최종 주문 회귀 검증도 별도 설치본에서 실행한다. diff --git a/install/gnuboard5shop.sql b/install/gnuboard5shop.sql index cecaf5579..d3486326f 100644 --- a/install/gnuboard5shop.sql +++ b/install/gnuboard5shop.sql @@ -1055,3 +1055,19 @@ CREATE TABLE `g5_shop_kcp_noti` ( KEY `kn_trade` (`kn_trade`), KEY `od_id` (`od_id`) ) ENGINE=MyISAM DEFAULT CHARSET=utf8; + +CREATE TABLE `g5_shop_order_access` ( + od_id bigint(20) unsigned NOT NULL, + token_hash char(64) NOT NULL DEFAULT '', + pg varchar(20) NOT NULL DEFAULT '', + cart_id bigint(20) unsigned NOT NULL DEFAULT '0', + status varchar(20) NOT NULL DEFAULT 'pending', + expires bigint(20) NOT NULL DEFAULT '0', + updated_at bigint(20) NOT NULL DEFAULT '0', + state_json mediumtext NOT NULL, + payment_key varchar(200) NOT NULL DEFAULT '', + response_json mediumtext NOT NULL, + PRIMARY KEY (od_id), + KEY cart_status (cart_id,status), + KEY state_expiry (status,expires) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; diff --git a/install/install.function.php b/install/install.function.php index c66ff8bde..0d121e927 100644 --- a/install/install.function.php +++ b/install/install.function.php @@ -451,3 +451,26 @@ if( ! function_exists('install_file_write') ){ return true; } } + +// 최신 설치 SQL을 새로 생성한 경우에만 호출한다. 과거 SQL은 실행하지 않는다. +function install_record_migrations($table_prefix) +{ + $files = glob(g5_migration_path() . '/*.sql'); + if (!$files) return '마이그레이션 파일을 찾을 수 없습니다. 배포본의 migrations 디렉터리를 확인해 주십시오.'; + sort($files, SORT_STRING); + $migrations = array(); + foreach ($files as $file) { + $migration = g5_migration_read_file($file); + if (isset($migration['error'])) return $migration['error']; + $migrations[] = $migration; + } + $table = $table_prefix . 'migrations'; + $error = g5_migration_validate_table($table); + if ($error !== '') return $error; + foreach ($migrations as $migration) { + if (!g5_migration_save_record($migration, 'success', '', 0, $table)) { + return $migration['id'] . ' 설치 이력을 저장하지 못했습니다: ' . sql_error_info(); + } + } + return ''; +} diff --git a/install/install_db.php b/install/install_db.php index 5a2ce5058..63d0f6f82 100644 --- a/install/install_db.php +++ b/install/install_db.php @@ -108,6 +108,18 @@ if ($table_check_error) { install_fail_page('기존 테이블 존재 여부를 확인하지 못했습니다. DB 계정 권한을 확인해 주십시오.', $dblink, $install_table_prefixes); } +// 기존 테이블을 보존하거나 재사용하는 설치는 DB 업그레이드에서 실제 상태를 검사한다. +$install_record_baseline = $is_install === false; +$existing_tables = install_query_or_fail('SHOW TABLES', $dblink, '신규 설치 여부를 확인하지 못했습니다.', $install_table_prefixes); +while ($existing_table = sql_fetch_array($existing_tables)) { + $existing_table_name = reset($existing_table); + foreach (array($table_prefix, $g5_shop_prefix, $table_prefix . 'shop_') as $existing_prefix) { + if (strpos($existing_table_name, $existing_prefix) === 0) { + $install_record_baseline = false; + } + } +} + // 그누보드5 재설치에 체크하였거나 그누보드5가 설치되어 있지 않다면 if ($g5_install || $is_install === false) { // 테이블 생성 ------------------------------------ @@ -580,6 +592,16 @@ if($g5_shop_install) { $shop_prefix . 'coupon_zone', 'g5_shop_inicis_log_table' => $shop_prefix . 'inicis_log', 'g5_shop_order_data_table' => $shop_prefix . 'order_data', + 'g5_shop_order_access_table' => $shop_prefix . 'order_access', 'g5_shop_post_log_table' => $shop_prefix . 'order_post_log', 'g5_shop_order_cancel_log_table' => $shop_prefix . 'order_cancel_log', 'g5_shop_inicis_pay_table' => $shop_prefix . 'inicis_pay', @@ -85,7 +86,7 @@ function g5_migration_parse_condition($directive, $file) return $condition; } -function g5_migration_parse_file($file) +function g5_migration_read_file($file) { $contents = @file_get_contents($file); if ($contents === false) { @@ -110,6 +111,17 @@ function g5_migration_parse_file($file) $migration['description'] = trim($matches[1]); } + $migration['contents'] = $contents; + return $migration; +} + +function g5_migration_parse_file($file) +{ + $migration = g5_migration_read_file($file); + if (isset($migration['error'])) return $migration; + $contents = $migration['contents']; + unset($migration['contents']); + if (preg_match('/^--[\t ]*@skip-if-column[\t ]+(\S+)[\t ]+(\S+)[\t ]*\r?$/mi', $contents, $matches)) { $migration['skip_table'] = g5_migration_replace_placeholders(trim($matches[1], '`')); $migration['skip_column'] = trim($matches[2], '`'); @@ -303,17 +315,31 @@ function g5_migration_execute_statement($statement) return array('error' => '', 'executed' => $executed); } -function g5_migration_get_records() +function g5_migration_get_records(&$error = null) { $table = g5_migration_table_name(); $records = array(); + $error = ''; - if (!g5_migration_table_exists($table)) { + $tables = sql_query("SHOW TABLES LIKE '" . sql_real_escape_string($table) . "'", false); + if (!$tables) { + $error = '마이그레이션 이력 테이블을 조회하지 못했습니다: ' . sql_error_info(); + return $records; + } + $exists = false; + while ($row = sql_fetch_array($tables)) { + if (reset($row) === $table) $exists = true; + } + if (!$exists) { return $records; } + $error = g5_migration_validate_table(); + if ($error !== '') return $records; + $result = sql_query("SELECT migration_id, checksum, status, error_message, applied_at FROM `{$table}` ORDER BY migration_id", false); if (!$result) { + $error = '마이그레이션 이력을 읽지 못했습니다: ' . sql_error_info(); return $records; } @@ -399,7 +425,8 @@ function g5_migration_needs_no_execution($migration, &$cache) function g5_migration_status() { $migrations = g5_migration_discover(); - $records = g5_migration_get_records(); + $records = g5_migration_get_records($history_error); + if ($history_error !== '') return array(array('error' => $history_error)); $status = array(); $inspection_cache = array(); @@ -470,6 +497,25 @@ function g5_migration_validate_target_dependencies($migrations, $records, $targe return ''; } +// 조회에서는 구조만 검사하고, 복구를 위한 DDL은 자동 실행하지 않는다. +function g5_migration_validate_table($table = null) +{ + if ($table === null) $table = g5_migration_table_name(); + $columns = 'migration_id, description, checksum, status, error_message, execution_ms, applied_at'; + if (!sql_query("SELECT {$columns} FROM `{$table}` LIMIT 0", false)) { + return "마이그레이션 이력 테이블 `{$table}`의 구조를 확인하지 못했습니다: " . sql_error_info() + . ' (docs/database-migrations.md의 이력 테이블 복구 절차를 확인해 주십시오.)'; + } + $indexes = sql_query("SHOW INDEX FROM `{$table}` WHERE Key_name = 'PRIMARY'", false); + if (!$indexes) return '마이그레이션 이력 기본키를 확인하지 못했습니다: ' . sql_error_info(); + $primary = array(); + while ($row = sql_fetch_array($indexes)) $primary[] = $row['Column_name']; + if ($primary !== array('migration_id')) { + return "마이그레이션 이력 테이블 `{$table}`의 기본키가 migration_id 단일 컬럼이 아닙니다. docs/database-migrations.md의 이력 테이블 복구 절차를 확인해 주십시오."; + } + return ''; +} + function g5_migration_ensure_table() { $table = g5_migration_table_name(); @@ -487,9 +533,9 @@ function g5_migration_ensure_table() return (bool) sql_query($sql, false); } -function g5_migration_save_record($migration, $status, $error_message, $execution_ms) +function g5_migration_save_record($migration, $status, $error_message, $execution_ms, $table = null) { - $table = g5_migration_table_name(); + if ($table === null) $table = g5_migration_table_name(); $id = sql_real_escape_string($migration['id']); $description = sql_real_escape_string($migration['description']); $checksum = sql_real_escape_string($migration['checksum']); @@ -525,7 +571,12 @@ function g5_migration_run($target_id = '', $record_existing = false) } $migrations = g5_migration_discover(); - $records = g5_migration_get_records(); + $records = g5_migration_get_records($history_error); + if ($history_error !== '') { + $result['errors'][] = $history_error; + sql_query("SELECT RELEASE_LOCK('{$lock_name}')", false); + return $result; + } $target_found = $target_id === ''; if ($record_existing) { diff --git a/lib/shop.lib.php b/lib/shop.lib.php index a48162752..038af1282 100644 --- a/lib/shop.lib.php +++ b/lib/shop.lib.php @@ -2829,7 +2829,9 @@ function add_order_post_log($msg='', $code='error'){ if( empty($_POST) ) return; - $post_data = base64_encode(serialize($_POST)); + $log_data = $_POST; + unset($log_data['od_pwd'], $log_data['g5_order_state'], $log_data['g5_checkout_nonce']); + $post_data = base64_encode(serialize($log_data)); $od_id = get_session('ss_order_id'); if( $code === 'delete' ){ diff --git a/lib/shop_order_access.lib.php b/lib/shop_order_access.lib.php new file mode 100644 index 000000000..091dc3c4e --- /dev/null +++ b/lib/shop_order_access.lib.php @@ -0,0 +1,107 @@ +$payment_key)); + set_session('ss_order_data_access', $states); + $data['paymentKey'] = $payment_key; + $data['orderId'] = $order_id; + return $data; +} + +// 클라이언트가 보낸 해시를 신뢰하지 않고 저장 당시 서버 상태만 사용한다. +function shop_order_access_password($order_id) +{ + $states = get_session('ss_order_data_access'); + if (!is_array($states) || !isset($states[$order_id])) return null; + $state = $states[$order_id]; + shop_order_access_load($order_id, $state['pg']); + if ($state['personal'] || empty($state['password_hash'])) shop_order_access_fail(); + return $state['password_hash']; +} + +function shop_order_access_forget($order_id) +{ + shop_order_state_complete($order_id); + $states = get_session('ss_order_data_access'); + if (is_array($states)) { + unset($states[$order_id]); + set_session('ss_order_data_access', $states); + } +} diff --git a/lib/shop_order_compat.lib.php b/lib/shop_order_compat.lib.php new file mode 100644 index 000000000..0f47882e4 --- /dev/null +++ b/lib/shop_order_compat.lib.php @@ -0,0 +1,105 @@ + 1398104) return false; + $serialized = base64_decode($encoded, true); + if ($serialized === false || strlen($serialized) > 1048576) return false; + $offset = 0; + $nodes = 0; + $valid = true; + $value = shop_order_decode_value($serialized, $offset, $nodes, $valid, 0); + return $valid && $offset === strlen($serialized) && is_array($value) ? $value : false; +} + +function shop_order_decode_value($data, &$offset, &$nodes, &$valid, $depth) +{ + if (!$valid || $depth > 32 || ++$nodes > 20000 || $offset >= strlen($data)) { + $valid = false; + return null; + } + $type = $data[$offset++]; + if ($type === 'N' && substr($data, $offset++, 1) === ';') return null; + if (substr($data, $offset++, 1) !== ':') { $valid = false; return null; } + if ($type === 's' || $type === 'a') { + $end = strpos($data, ':', $offset); + if ($end === false) { $valid = false; return null; } + $number = substr($data, $offset, $end - $offset); + if (!preg_match('/\A(?:0|[1-9][0-9]{0,6})\z/D', $number)) { $valid = false; return null; } + $count = (int)$number; + $offset = $end + 1; + if ($type === 's') { + if (substr($data, $offset++, 1) !== '"' || $count > strlen($data) - $offset - 2) { $valid = false; return null; } + $value = substr($data, $offset, $count); + $offset += $count; + if (substr($data, $offset, 2) !== '";') $valid = false; + $offset += 2; + return $value; + } + if ($count > 10000 || substr($data, $offset++, 1) !== '{') { $valid = false; return null; } + $value = array(); + for ($i = 0; $i < $count && $valid; $i++) { + $key = shop_order_decode_value($data, $offset, $nodes, $valid, $depth + 1); + if (!is_int($key) && !is_string($key)) { $valid = false; break; } + $item = shop_order_decode_value($data, $offset, $nodes, $valid, $depth + 1); + if ($valid) $value[$key] = $item; + } + if (substr($data, $offset++, 1) !== '}') $valid = false; + return $value; + } + if ($type === 'b' || $type === 'i' || $type === 'd') { + $end = strpos($data, ';', $offset); + if ($end === false) { $valid = false; return null; } + $number = substr($data, $offset, $end - $offset); + $offset = $end + 1; + if ($type === 'b' && ($number === '0' || $number === '1')) return $number === '1'; + if ($type === 'i' && preg_match('/\A-?(?:0|[1-9][0-9]*)\z/D', $number) && (string)(int)$number === $number) return (int)$number; + if ($type === 'd' && preg_match('/\A-?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[Ee][+-]?[0-9]+)?\z/D', $number) && is_finite((float)$number)) return (float)$number; + } + $valid = false; + return null; +} diff --git a/lib/shop_order_fields.lib.php b/lib/shop_order_fields.lib.php new file mode 100644 index 000000000..60765e4b3 --- /dev/null +++ b/lib/shop_order_fields.lib.php @@ -0,0 +1,42 @@ + 'amountCurrency buyeremail buyertel cardUseAppCardOnly cardUseCardPoint cardUseEscrow cardeasyPay cardflowMode comm_free_mny comm_tax_mny comm_vat_mny customerEmail customerMobilePhone customerName escrowProducts good_mny id_info method od_id orderId orderName submitChecked taxFreeAmount tr_code tx windowTarget', + 'kcp' => 'ActionResult KCP_PAY_MODULE Ret_URL amt_sup amt_svc amt_tax amt_tot applepay_direct approval_key bank_issu bank_name bask_cntx buyr_mail buyr_name buyr_tel1 buyr_tel2 cash_authno cash_id_info cash_tr_code cash_tsdtime cash_yn comm_free_mny comm_tax_mny comm_vat_mny complex_pnt_yn corp_type currency def_site_cd deli_term disp_tax_yn enc_data enc_info eng_flag epnt_issu escrow_foot escw_used fix_inst good_cd good_expr good_info good_mny good_name id_info ipgm_date kakaopay_direct kcp_noint kcp_noint_quota module_type naverpay_direct nhnkcp_pay_case not_used_card ordr_idxx param_opt_1 param_opt_2 param_opt_3 pay_method pay_mod payco_direct pt_memcorp_cd quotaopt rcvr_add1 rcvr_add2 rcvr_mail rcvr_name rcvr_tel1 rcvr_tel2 rcvr_zipx req_tx res_cd res_msg ret_pay_method save_ocb settle_method shop_name shop_user_id site_cd site_logo site_name skin_indx submitChecked tablet_size tar_opener tax_flag tk_shop_id tno tr_code trace_no trad_time tran_cd tx use_pay_method used_card used_card_CCXX used_card_YN vcnt_expire_term vcnt_expire_term_time wish_vbank_list', + 'inicis' => 'gopaymethod DEF_RESERVED P_AMT P_APPL_NUM P_AUTH_DT P_AUTH_NO P_CARD_ISSUER P_EMAIL P_GOODS P_HASH P_HPP_CORP P_HPP_METHOD P_MID P_MOBILE P_NEXT_URL P_NOTI P_NOTI_URL P_OID P_QUOTABASE P_RESERVED P_RETURN_URL P_SKIP_TERMS P_TAX P_TAXFREE P_TYPE P_UNAME P_VACT_BANK P_VACT_NAME P_VACT_NUM acceptmethod buyeremail buyername buyertel charset closeUrl comm_free_mny comm_tax_mny comm_vat_mny currency good_mny goodname id_info ini_logoimage_url ini_menuarea_url mKey mid nointerest od_id oid parentemail payViewType popupUrl price quotabase recvaddr recvname recvpostnum recvtel res_cd returnUrl signature submitChecked tax taxfree timestamp tr_code tx version', + 'lg' => 'CST_MID CST_PLATFORM LGD_AMOUNT LGD_BUYER LGD_BUYERADDRESS LGD_BUYEREMAIL LGD_BUYERID LGD_BUYERIP LGD_BUYERPHONE LGD_CASHRECEIPTYN LGD_CASNOTEURL LGD_CUSTOM_FIRSTPAY LGD_CUSTOM_PROCESSTYPE LGD_CUSTOM_SKIN LGD_CUSTOM_USABLEPAY LGD_EASYPAY_ONLY LGD_ENCODING LGD_ENCODING_RETURNURL LGD_ESCROW_ADDRESS1 LGD_ESCROW_ADDRESS2 LGD_ESCROW_BUYERPHONE LGD_ESCROW_ZIPCODE LGD_HASHDATA LGD_MID LGD_OID LGD_PAYKEY LGD_PRODUCTINFO LGD_RECEIVER LGD_RECEIVERPHONE LGD_RETURNURL LGD_TAXFREEAMOUNT LGD_TIMESTAMP LGD_VERSION LGD_WINDOW_VER comm_free_mny comm_tax_mny comm_vat_mny good_mny id_info od_id res_cd submitChecked tr_code tx', + 'nicepay' => 'Amt BuyerEmail BuyerName BuyerTel CharSet DirectEasyPay DirectShowOpt EasyPayCardCode EasyPayMethod EasyPayQuota EdiDate GoodsCl GoodsName GoodsVat MID Moid MultiEasyPayQuota NicepayReserved NpLang PayMethod ReqReserved ReturnURL SelectCardCode SelectQuota ServiceAmt SignData SupplyAmt TaxFreeAmt TransType VbankExpDate buyeremail buyertel comm_free_mny comm_tax_mny comm_vat_mny good_mny id_info od_id submitChecked tr_code tx', + 'samsungpay' => 'DEF_RESERVED P_AMT P_APPL_NUM P_AUTH_DT P_AUTH_NO P_CARD_ISSUER P_EMAIL P_GOODS P_HASH P_HPP_CORP P_HPP_METHOD P_MID P_MOBILE P_NEXT_URL P_NOTI P_NOTI_URL P_OID P_QUOTABASE P_RESERVED P_RETURN_URL P_SKIP_TERMS P_TAX P_TAXFREE P_TYPE P_UNAME P_VACT_BANK P_VACT_NAME P_VACT_NUM good_mny res_cd samsungpay_form', + ); + return array_unique(array_merge($common, isset($pg_fields[$pg]) ? explode(' ', $pg_fields[$pg]) : array())); +} + +function shop_order_filter_data($input, $pg) +{ + $out = array(); + foreach (shop_order_allowed_fields($pg) as $key) { + if (!array_key_exists($key, $input)) continue; + $value = $input[$key]; + if (is_array($value)) { + if (count($value) > 1000) shop_order_access_fail(); + foreach ($value as $k=>$v) { + if (!is_scalar($v) || strlen((string)$v) > 65536 || !preg_match('/\A[0-9A-Za-z_-]{1,100}\z/D', (string)$k)) shop_order_access_fail(); + } + } elseif (!is_scalar($value) || strlen((string)$value) > 65536) shop_order_access_fail(); + $out[$key] = $value; + } + if (strlen(serialize($out)) > 1048576) shop_order_access_fail(); + return $out; +} + +function shop_order_toss_providers() +{ + $providers = array(); + foreach (shop_easypay_catalog('toss') as $provider) $providers[] = $provider[1]; + return $providers; +} diff --git a/lib/shop_order_maintenance.lib.php b/lib/shop_order_maintenance.lib.php new file mode 100644 index 000000000..504500f4e --- /dev/null +++ b/lib/shop_order_maintenance.lib.php @@ -0,0 +1,75 @@ +$id, 'status'=>$row['status'], 'action'=>$apply ? 'purged' : 'would_purge'); + if ($apply) { + $pg = sql_escape_string($row['pg']); + if (!sql_query("delete from {$g5['g5_shop_order_data_table']} where od_id='$id' and dt_pg='$pg'", false)) shop_order_access_fail(); + shop_order_state_write($id, array('status'=>'purged','state_json'=>'','response_json'=>'','token_hash'=>'','payment_key'=>'')); + } + shop_order_state_unlock('order', $id); + } + return $result; +} + +function shop_order_quarantine_legacy($id) +{ + global $g5; + shop_order_state_lock('order', $id); + if (shop_order_state_row($id)) throw new RuntimeException('이미 상태 기록이 있는 주문입니다.'); + $res = sql_query("select * from {$g5['g5_shop_order_data_table']} where od_id='$id'"); + if (sql_num_rows($res) !== 1) throw new RuntimeException('임시 주문을 하나로 확정할 수 없습니다.'); + $row = sql_fetch_array($res); + $data = shop_order_decode_data($row['dt_data']); + if (!is_array($data)) throw new RuntimeException('임시 데이터 형식을 확인해 주십시오.'); + $password_hash = isset($data['od_pwd']) && is_string($data['od_pwd']) ? get_encrypt_string($data['od_pwd']) : ''; + unset($data['od_pwd']); + $data = shop_order_filter_data($data, $row['dt_pg']); + $encoded = base64_encode(serialize($data)); + // 먼저 접근 불가능한 상태를 기록한다. 세션이 남아 있어도 자동 전환하지 않는다. + $state = sql_escape_string(json_encode(array('password_hash'=>$password_hash, 'legacy'=>true))); + $pg = sql_escape_string($row['dt_pg']); + if (!sql_query("insert into ".shop_order_state_table()." set od_id='$id',pg='$pg',status='legacy',state_json='$state',response_json='',updated_at=".G5_SERVER_TIME, false)) shop_order_access_fail(); + if (!sql_query("update {$g5['g5_shop_order_data_table']} set dt_data='$encoded' where od_id='$id'", false)) shop_order_access_fail(); + return array('order'=>$id, 'status'=>'legacy', 'plaintext_removed'=>true, 'token_issued'=>false); +} + +function shop_order_reconcile_toss($id, $toss) +{ + global $g5; + shop_order_state_lock('order', $id); + $row = shop_order_state_row($id); + $state = $row ? json_decode($row['state_json'], true) : null; + if (!$row || $row['pg'] !== 'toss' || empty($state['expected_amount']) || $row['payment_key'] === '' || + !in_array($row['status'], array('approving','approved','finalizing','unknown','cancel_pending'), true)) throw new RuntimeException('승인 대조가 필요한 Toss 주문이 아닙니다.'); + if (!$toss->getPaymentByOrderId($id)) throw new RuntimeException('PG 조회 실패: 상태를 변경하지 않았습니다.'); + $p = $toss->responseData; + if (!isset($p['orderId'],$p['paymentKey'],$p['totalAmount'],$p['status']) || $p['orderId'] !== $id || + $p['paymentKey'] !== $row['payment_key'] || (int)$p['totalAmount'] !== (int)$state['expected_amount']) throw new RuntimeException('PG 주문번호·키·금액이 일치하지 않습니다.'); + if ($p['status'] === 'CANCELED') { + shop_order_state_write($id, array('status'=>'cancelled')); + return array('order'=>$id, 'status'=>'cancelled'); + } + if (!($p['status'] === 'DONE' || ($p['status'] === 'WAITING_FOR_DEPOSIT' && isset($p['method']) && $p['method'] === '가상계좌'))) throw new RuntimeException('PG 거래를 재승인하지 말고 별도 확인해 주십시오.'); + $saved = $state['personal'] ? sql_fetch("select pp_tno as tno from {$g5['g5_shop_personalpay_table']} where pp_id='$id'") : sql_fetch("select od_tno as tno from {$g5['g5_shop_order_table']} where od_id='$id'"); + if (($state['personal'] && !empty($saved['tno'])) || (!$state['personal'] && $saved)) { + shop_order_state_write($id, array('status'=>'finalizing')); + return array('order'=>$id, 'status'=>'finalizing', 'action'=>'주문·장바구니·포인트·쿠폰의 부분 저장 대조 필요'); + } + shop_order_state_approved($id, $p); + return array('order'=>$id, 'status'=>'approved', 'action'=>'원래 복귀 토큰으로 재시도 가능, 신규 승인 호출 없음'); +} diff --git a/lib/shop_order_state.lib.php b/lib/shop_order_state.lib.php new file mode 100644 index 000000000..e23d734b0 --- /dev/null +++ b/lib/shop_order_state.lib.php @@ -0,0 +1,398 @@ +array(), 'rows'=>array(), 'active'=>''); + return $runtime; +} + +function shop_order_state_table() +{ + global $g5; + return isset($g5['g5_shop_order_access_table']) ? $g5['g5_shop_order_access_table'] : G5_SHOP_TABLE_PREFIX.'order_access'; +} + +function shop_order_lock_name($kind, $id) +{ + return substr('g5oa_'.hash('sha256', G5_MYSQL_DB.G5_SHOP_TABLE_PREFIX.$kind.$id), 0, 64); +} + +function shop_order_state_lock($kind, $id) +{ + $r =& shop_order_runtime(); + $key = shop_order_lock_name($kind, $id); + shop_order_state_check_locks(); + if (isset($r['locks'][$key])) return; + // MySQL 5.0은 연결당 이름 잠금 하나만 유지한다. 업무/포인트 연결과도 분리한다. + $mysqli = function_exists('mysqli_connect') && G5_MYSQLI_USE; + $host = G5_MYSQL_HOST; + if (substr($host, 0, 2) === 'p:') $host = substr($host, 2); + if ($mysqli) { + try { $link = @mysqli_connect($host, G5_MYSQL_USER, G5_MYSQL_PASSWORD, G5_MYSQL_DB); } + catch (Exception $e) { $link = false; } + } else { + $link = @mysql_connect($host, G5_MYSQL_USER, G5_MYSQL_PASSWORD, true); + } + if (!$link) shop_order_access_fail(); + $lock = array('link'=>$link, 'mysqli'=>$mysqli); + // 실패나 예외에서도 이 연결만 닫아 획득 중인 잠금까지 해제한다. + if (empty($r['shutdown_registered'])) { + register_shutdown_function('shop_order_state_unlock_all'); + $r['shutdown_registered'] = true; + } + $r['locks'][$key] = $lock; + if ($mysqli) { + try { $result = @mysqli_query($link, "SELECT GET_LOCK('$key', 3) AS acquired"); } + catch (Exception $e) { $result = false; } + $row = $result ? mysqli_fetch_assoc($result) : false; + } else { + $result = @mysql_query("SELECT GET_LOCK('$key', 3) AS acquired", $link); + $row = $result ? mysql_fetch_assoc($result) : false; + } + if (!$row || (int)$row['acquired'] !== 1) { + shop_order_state_unlock($kind, $id); + shop_order_access_fail(); + } +} + +// 잠금 연결이 끊겼거나 자동 재접속되면 기존 잠금을 보유한 것으로 취급하지 않는다. +function shop_order_state_check_locks() +{ + $r =& shop_order_runtime(); + foreach ($r['locks'] as $key=>$lock) { + $sql = "SELECT IS_USED_LOCK('$key') = CONNECTION_ID() AS owned"; + if ($lock['mysqli']) { + try { $result = @mysqli_query($lock['link'], $sql); } + catch (Exception $e) { $result = false; } + $row = $result ? mysqli_fetch_assoc($result) : false; + } else { + $result = @mysql_query($sql, $lock['link']); + $row = $result ? mysql_fetch_assoc($result) : false; + } + if (!$row || (int)$row['owned'] !== 1) shop_order_access_fail(); + } +} + +function shop_order_state_unlock($kind, $id) +{ + $r =& shop_order_runtime(); + $key = shop_order_lock_name($kind, $id); + if (!isset($r['locks'][$key])) return; + $lock = $r['locks'][$key]; + unset($r['locks'][$key]); + // 비영속 연결 종료 자체가 해당 연결의 잠금을 해제한다. + if ($lock['mysqli']) mysqli_close($lock['link']); + else mysql_close($lock['link']); +} + +function shop_order_state_unlock_all() +{ + $r =& shop_order_runtime(); + foreach ($r['locks'] as $lock) { + if ($lock['mysqli']) mysqli_close($lock['link']); + else mysql_close($lock['link']); + } + $r['locks'] = array(); +} + +function shop_order_state_row($id) +{ + if (!shop_order_access_id((string)$id)) shop_order_access_fail(); + return sql_fetch("select * from ".shop_order_state_table()." where od_id='$id'", false); +} + +function shop_order_state_meta($id) +{ + if (!shop_order_access_id((string)$id)) shop_order_access_fail(); + return sql_fetch("select od_id,token_hash,pg,status,expires from ".shop_order_state_table()." where od_id='$id'", false); +} + +function shop_order_state_write($id, $fields) +{ + shop_order_state_check_locks(); + $allowed = array('status','payment_key','response_json','state_json','token_hash','expires','updated_at'); + $set = array(); + foreach ($fields as $key=>$value) { + if (!in_array($key, $allowed, true)) shop_order_access_fail(); + $set[] = "$key='".sql_escape_string((string)$value)."'"; + } + $set[] = 'updated_at='.G5_SERVER_TIME; + if (!sql_query("update ".shop_order_state_table()." set ".implode(',', $set)." where od_id='$id'", false)) shop_order_access_fail(); +} + +function shop_order_state_token($id) +{ + $token = isset($_REQUEST['g5_order_state']) ? $_REQUEST['g5_order_state'] : ''; + if ($token === '') { + $states = get_session('ss_order_data_access'); + $token = isset($states[$id]['token']) ? $states[$id]['token'] : ''; + } + if (!is_string($token) || !preg_match('/\A'.preg_quote((string)$id, '/').'\.[a-f0-9]{64}\z/D', $token)) return ''; + return $token; +} + +function shop_order_checkout_fields($id, $personal = false) +{ + if (!shop_order_access_id((string)$id)) return ''; + $map = get_session('ss_order_checkouts'); + if (!is_array($map)) $map = array(); + foreach ($map as $key=>$value) if ($value['expires'] <= G5_SERVER_TIME) unset($map[$key]); + if (count($map) >= 30) array_shift($map); + $nonce = shop_order_random_hex(24); + $map[(string)$id] = array('nonce'=>$nonce, 'expires'=>G5_SERVER_TIME+7200, + 'cart'=>(string)get_session(get_session('ss_direct') ? 'ss_cart_direct' : 'ss_cart_id'), + 'direct'=>(bool)get_session('ss_direct'), 'personal'=>$personal, + 'personal_hash'=>get_session('ss_personalpay_hash')); + set_session('ss_order_checkouts', $map); + return ''. + ''; +} + +function shop_order_checkout_restore() +{ + if (!isset($_POST['g5_checkout_id'])) return; // 기존 사용자 스킨은 현재 세션 바인딩을 사용한다. + $id = $_POST['g5_checkout_id']; $nonce = isset($_POST['g5_checkout_nonce']) ? $_POST['g5_checkout_nonce'] : ''; + $map = get_session('ss_order_checkouts'); + if (!shop_order_access_id($id) || !is_string($nonce) || !isset($map[$id]) || + !shop_order_equals($map[$id]['nonce'], $nonce) || $map[$id]['expires'] <= G5_SERVER_TIME) shop_order_access_fail(); + $c = $map[$id]; + if ($c['personal'] !== !empty($_POST['pp_id']) || ($c['personal'] && (string)$_POST['pp_id'] !== $id)) shop_order_access_fail(); + set_session('ss_order_id', $id); set_session('ss_direct', $c['direct']); + set_session($c['direct'] ? 'ss_cart_direct' : 'ss_cart_id', $c['cart']); + if ($c['personal']) { + set_session('ss_personalpay_id', $id); set_session('ss_personalpay_hash', $c['personal_hash']); + } +} + +function shop_order_state_save($id, $state) +{ + shop_order_state_lock('order', $id); + $previous = shop_order_state_meta($id); + if ($previous) { + $token = shop_order_state_token($id); + if (!$token || !shop_order_equals($previous['token_hash'], hash('sha256', $token)) || + $previous['status'] !== 'pending') shop_order_access_fail(); + } + $token = $id.'.'.shop_order_random_hex(32); + $state['version'] = 2; + unset($state['token'], $state['payment_key']); + $json = json_encode($state); + if ($json === false) shop_order_access_fail(); + $table = shop_order_state_table(); + $fields = "token_hash='".hash('sha256', $token)."', pg='".sql_escape_string($state['pg'])."', ". + "cart_id='".sql_escape_string($state['cart'])."', status='pending', expires=".(int)$state['expires']. + ", state_json='".sql_escape_string($json)."', payment_key='', response_json='', updated_at=".G5_SERVER_TIME; + $query = $previous ? "update $table set $fields where od_id='$id'" : "insert into $table set od_id='$id', $fields"; + if (!sql_query($query, false)) shop_order_access_fail(); + $state['token'] = $token; + $states = get_session('ss_order_data_access'); if (!is_array($states)) $states = array(); + $states[$id] = $state; set_session('ss_order_data_access', $states); + header('Cache-Control: no-store, private'); header('Referrer-Policy: no-referrer'); + header('X-G5-Order-State: '.$token); + return $state; +} + +function shop_order_state_can_save($id) +{ + shop_order_state_lock('order', $id); + $old = shop_order_state_meta($id); + if (!$old) return; + $token = shop_order_state_token($id); + if ($old['status'] !== 'pending' || !$token || !shop_order_equals($old['token_hash'], hash('sha256', $token))) shop_order_access_fail(); +} + +function shop_order_state_load($id, $pg) +{ + global $g5, $member, $is_member, $is_guest; + if (!shop_order_access_id($id)) shop_order_access_fail(); + shop_order_state_lock('order', $id); + $row = shop_order_state_meta($id); + if (!$row) { + // 1차 패치가 만든 세션/본문 해시가 검증되는 요청만 전환한다. + // PAYREQ_MAP 또는 주문번호만 있는 과거 요청은 legacy 검증에서도 거부된다. + $data = shop_order_access_load_legacy($id, $pg); + $states = get_session('ss_order_data_access'); + shop_order_state_save($id, $states[$id]); + $row = shop_order_state_meta($id); + } + $token = shop_order_state_token($id); + if (!$token || $row['pg'] !== $pg || !shop_order_equals($row['token_hash'], hash('sha256', $token))) shop_order_access_fail(); + if (!in_array($row['status'], array('pending','approving','approved','finalizing'), true)) shop_order_access_fail(); + if ((int)$row['expires'] <= G5_SERVER_TIME && $row['status'] === 'pending') shop_order_access_fail(); + $row = shop_order_state_row($id); + $state = json_decode($row['state_json'], true); + if (!is_array($state) || !isset($state['version']) || $state['version'] !== 2) shop_order_access_fail(); + $current = isset($member['mb_id']) ? (string)$member['mb_id'] : ''; + if ($current !== '' && $current !== $state['member']) shop_order_access_fail(); + // 결제 토큰은 해당 주문 처리에만 회원 컨텍스트를 제공한다. 로그인 세션을 발급하지 않는다. + if ($current === '' && $state['member'] !== '') { + $owner = get_member($state['member']); + if (empty($owner['mb_id']) || !empty($owner['mb_leave_date']) || !empty($owner['mb_intercept_date'])) shop_order_access_fail(); + $member = $owner; $is_member = true; $is_guest = false; + } + if ($state['personal']) { + $done = sql_fetch("select pp_tno, pp_use, pp_price, pp_time from {$g5['g5_shop_personalpay_table']} where pp_id='$id'"); + if (!$done || !$done['pp_use'] || $done['pp_tno'] || + !shop_order_equals($state['personal_hash'], md5($id.$done['pp_price'].$done['pp_time']))) shop_order_access_fail(); + } else { + $done = sql_fetch("select od_id from {$g5['g5_shop_order_table']} where od_id='$id'"); + if (!empty($done['od_id'])) shop_order_access_fail(); // 부분 저장도 자동 재승인하지 않는다. + } + if ($row['status'] === 'finalizing') { + if ($state['personal']) shop_order_access_fail(); + $cart_id = sql_escape_string($state['cart']); + $remaining = sql_fetch("select count(*) as cnt from {$g5['g5_shop_cart_table']} where od_id='$cart_id' and ct_select='1' and ct_status='쇼핑'"); + if (empty($remaining['cnt']) || $row['response_json'] === '') shop_order_access_fail(); + shop_order_state_write($id, array('status'=>'approved')); + $row['status'] = 'approved'; + } + $rows = sql_query("select cart_id,mb_id,dt_pg,dt_time from {$g5['g5_shop_order_data_table']} where od_id='$id'"); + if (sql_num_rows($rows) !== 1) shop_order_access_fail(); + $meta = sql_fetch_array($rows); + if ((string)$meta['cart_id'] !== $state['cart'] || $meta['mb_id'] !== $state['member'] || + $meta['dt_pg'] !== $pg || $meta['dt_time'] !== $state['time']) shop_order_access_fail(); + $temp = sql_fetch("select dt_data from {$g5['g5_shop_order_data_table']} where od_id='$id'"); + if (empty($temp['dt_data']) || !shop_order_equals($state['digest'], hash('sha256', $temp['dt_data']))) shop_order_access_fail(); + $data = shop_order_decode_data($temp['dt_data']); + if (!is_array($data) || !empty($data['pp_id']) !== $state['personal'] || + ($state['personal'] && (string)$data['pp_id'] !== $id)) shop_order_access_fail(); + unset($data['od_pwd']); + $state['token'] = $token; + if ($row['payment_key'] !== '') $state['payment_key'] = $row['payment_key']; + $states = get_session('ss_order_data_access'); if (!is_array($states)) $states = array(); + $states[$id] = $state; set_session('ss_order_data_access', $states); + set_session('ss_order_id', $id); set_session('ss_direct', $state['direct']); + set_session($state['direct'] ? 'ss_cart_direct' : 'ss_cart_id', $state['cart']); + if ($state['personal']) { set_session('ss_personalpay_id', $id); set_session('ss_personalpay_hash', $state['personal_hash']); } + $r =& shop_order_runtime(); $r['rows'][$id] = $row; $r['active'] = $id; + $data['g5_order_state'] = $token; + return $data; +} + +function shop_order_state_prepare($personal) +{ + global $default; + $token = isset($_REQUEST['g5_order_state']) ? $_REQUEST['g5_order_state'] : ''; + $id = (string)get_session($personal ? 'ss_personalpay_id' : 'ss_order_id'); + if ($token !== '') { + if (!is_string($token) || !preg_match('/\A([0-9]{1,20})\.[a-f0-9]{64}\z/D', $token, $m)) shop_order_access_fail(); + $id = $m[1]; + } + $states = get_session('ss_order_data_access'); + if ($token === '' && empty($states[$id])) return; // 임시 저장 없는 기존 PG/무통장 흐름 + $row = shop_order_state_meta($id); + $pg = $row ? $row['pg'] : (isset($states[$id]['pg']) ? $states[$id]['pg'] : ''); + $data = shop_order_access_load($id, $pg); + if (!empty($data['pp_id']) !== $personal) shop_order_access_fail(); + $default['de_pg_service'] = $pg; + // 결제 복귀의 업무 필드는 저장 당시 서버 데이터만 사용한다. + $fields = array_merge(shop_order_data_fields($personal ? 1 : 0), array('pp_id','sw_direct','od_temp_point','od_coupon','od_send_coupon','cp_id','cp_price','it_id','od_cp_id','sc_cp_id','od_hope_date','ad_default','ad_subject','good_mny','amountValue','comm_tax_mny','comm_vat_mny','comm_free_mny')); + foreach ($fields as $key) { + $value = isset($data[$key]) ? $data[$key] : ''; + $_POST[$key] = $value; $GLOBALS[$key] = $value; + } + if (!$personal) { + $s = get_session('ss_order_data_access'); + shop_order_state_lock('cart', $s[$id]['cart']); + $cart = sql_escape_string($s[$id]['cart']); + $busy = sql_fetch("select od_id from ".shop_order_state_table()." where cart_id='$cart' and od_id<>'$id' and status in ('approving','approved','finalizing','unknown','cancel_pending') limit 1"); + if ($busy) shop_order_access_fail(); + } +} + +function shop_order_state_begin($id, $key, $expected) +{ + shop_order_state_lock('order', $id); + $row = shop_order_state_row($id); + if (!$row || !in_array($row['status'], array('pending','approving','approved'), true)) shop_order_access_fail(); + if ($row['payment_key'] !== '' && !shop_order_equals($row['payment_key'], $key)) shop_order_access_fail(); + $state = json_decode($row['state_json'], true); + if (!is_array($state) || (int)$expected <= 0 || (isset($state['expected_amount']) && (int)$state['expected_amount'] !== (int)$expected)) shop_order_access_fail(); + if ($row['status'] === 'pending') { + $state['expected_amount'] = (int)$expected; + $state['approval_started'] = G5_SERVER_TIME; + shop_order_state_write($id, array('status'=>'approving','payment_key'=>$key,'state_json'=>json_encode($state))); + } + return $row; +} + +function shop_order_state_approved($id, $response) +{ + $json = json_encode($response); + if ($json === false) shop_order_access_fail(); + shop_order_state_write($id, array('status'=>'approved','response_json'=>$json)); +} + +function shop_order_state_finalizing() +{ + $r =& shop_order_runtime(); + if ($r['active'] === '') return; + $row = shop_order_state_row($r['active']); + if ($row && $row['status'] === 'approved') shop_order_state_write($r['active'], array('status'=>'finalizing')); +} + +function shop_order_state_complete($id) +{ + $row = shop_order_state_row($id); + if (!$row) return; + shop_order_state_write($id, array('status'=>'completed','state_json'=>'','response_json'=>'','token_hash'=>'','payment_key'=>'')); +} + +function shop_order_state_cancel($status) +{ + $r =& shop_order_runtime(); + if ($r['active'] !== '') shop_order_state_write($r['active'], array('status'=>$status)); +} + +function shop_order_toss_approve($toss, $id, $key, $expected) +{ + $row = shop_order_state_begin($id, $key, $expected); + $toss->headers[] = 'Idempotency-Key: g5-confirm-'.hash('sha256', (defined('G5_MYSQL_DB') ? G5_MYSQL_DB : '').G5_SHOP_TABLE_PREFIX.$id.$key); + // 승인 응답을 놓쳤거나 저장 후 중단된 경우에도 먼저 PG에 현재 상태를 조회한다. + if ($row['status'] !== 'pending') { + if (!$toss->getPaymentByOrderId($id)) shop_order_access_fail(); + $response = $toss->responseData; + if (!isset($response['orderId'],$response['paymentKey'],$response['totalAmount'],$response['status']) || + $response['orderId'] !== $id || $response['paymentKey'] !== $key || (int)$response['totalAmount'] !== (int)$expected) shop_order_access_fail(); + if (in_array($response['status'], array('CANCELED','ABORTED','EXPIRED'), true)) { + shop_order_state_write($id, array('status'=>$response['status'] === 'CANCELED' ? 'cancelled' : 'failed')); + shop_order_access_fail(); + } + if ($response['status'] === 'IN_PROGRESS' && $row['status'] === 'approving') { + $state = json_decode($row['state_json'], true); + // PG가 미승인을 확인한 경우에만 동일 본문/멱등 키로 재시도한다(15일보다 짧게 제한). + if (empty($state['approval_started']) || G5_SERVER_TIME - $state['approval_started'] > 14*86400 || !$toss->approvePayment()) shop_order_access_fail(); + $response = $toss->responseData; + } + } else { + if (!$toss->approvePayment()) { + // 네트워크 오류를 결제 실패로 단정하지 않는다. 다음 시도는 조회부터 시작한다. + shop_order_access_fail(); + } + $response = $toss->responseData; + } + if (!isset($response['orderId'],$response['paymentKey'],$response['totalAmount'],$response['status'],$response['method']) || + $response['orderId'] !== $id || $response['paymentKey'] !== $key || (int)$response['totalAmount'] !== (int)$expected || + !($response['status'] === 'DONE' || ($response['status'] === 'WAITING_FOR_DEPOSIT' && $response['method'] === '가상계좌'))) shop_order_access_fail(); + shop_order_state_approved($id, $response); + return true; +} + +function shop_order_kcp_result_fields() +{ + return explode(' ', 'tno amount pnt_issue card_cd card_name app_time app_no noinf quota partcanc_yn bankname bank_name bank_code depositor account pt_idno pnt_amount pnt_app_time pnt_app_no add_pnt use_pnt rsv_pnt commid mobile_no tk_van_code tk_app_no cash_authno cash_tr_code escw_yn res_cd res_msg app_kakaomny_time kakaomny_mny kcp_pay_method od_other_pay_type'); +} + +function shop_order_state_abort_pending() +{ + $token = isset($_REQUEST['g5_order_state']) ? $_REQUEST['g5_order_state'] : ''; + if (!is_string($token) || !preg_match('/\A([0-9]{1,20})\.[a-f0-9]{64}\z/D', $token, $m)) shop_order_access_fail(); + shop_order_state_lock('order', $m[1]); + $row = shop_order_state_meta($m[1]); + if (!$row || !shop_order_equals($row['token_hash'], hash('sha256', $token))) shop_order_access_fail(); + // PG 승인 여부가 불명확한 상태에는 실패 복귀가 와도 데이터를 지우지 않는다. + if ($row['status'] === 'pending') shop_order_state_write($m[1], array('status'=>'failed')); + shop_order_access_fail(); +} diff --git a/migrations/.htaccess b/migrations/.htaccess new file mode 100644 index 000000000..130f0a14b --- /dev/null +++ b/migrations/.htaccess @@ -0,0 +1,3 @@ +# Apache 2.4: 마이그레이션 파일의 HTTP 접근을 차단한다. +# 서버 내부에서 PHP가 SQL 파일을 읽는 동작에는 영향을 주지 않는다. +Require all denied diff --git a/migrations/20260914_001_order_access_state.sql b/migrations/20260914_001_order_access_state.sql new file mode 100644 index 000000000..71829e2d3 --- /dev/null +++ b/migrations/20260914_001_order_access_state.sql @@ -0,0 +1,18 @@ +-- @description KVE-2026-2140 주문별 복귀 인증과 영속 승인 상태 +-- @if-table-exists {{g5_shop_order_data_table}} +-- @if-table-missing {{g5_shop_order_access_table}} +CREATE TABLE {{g5_shop_order_access_table}} ( + od_id bigint(20) unsigned NOT NULL, + token_hash char(64) NOT NULL DEFAULT '', + pg varchar(20) NOT NULL DEFAULT '', + cart_id bigint(20) unsigned NOT NULL DEFAULT '0', + status varchar(20) NOT NULL DEFAULT 'pending', + expires bigint(20) NOT NULL DEFAULT '0', + updated_at bigint(20) NOT NULL DEFAULT '0', + state_json mediumtext NOT NULL, + payment_key varchar(200) NOT NULL DEFAULT '', + response_json mediumtext NOT NULL, + PRIMARY KEY (od_id), + KEY cart_status (cart_id,status), + KEY state_expiry (status,expires) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; diff --git a/mobile/shop/kcp/approval_key.js b/mobile/shop/kcp/approval_key.js index c9a99fb10..622deb4a2 100644 --- a/mobile/shop/kcp/approval_key.js +++ b/mobile/shop/kcp/approval_key.js @@ -63,7 +63,7 @@ + "&pay_method=" + form.pay_method.value + "&escw_used=" + form.escw_used.value + "&good_name=" + form.good_name.value - + "&Ret_URL=" + form.Ret_URL.value; + + "&Ret_URL=" + encodeURIComponent(form.Ret_URL.value); sendRequest( url + params ); } diff --git a/mobile/shop/kcp/order_approval_form.php b/mobile/shop/kcp/order_approval_form.php index 9da35e7ce..2285afc16 100644 --- a/mobile/shop/kcp/order_approval_form.php +++ b/mobile/shop/kcp/order_approval_form.php @@ -1,5 +1,8 @@ $value) { + if (!in_array($key, $exclude, true) || !is_string($value)) continue; echo ''.PHP_EOL; } @@ -309,7 +308,7 @@ if($enc_data != '' && $enc_info != '' && $tran_cd != '') { - + diff --git a/mobile/shop/kcp/pp_ax_hub.php b/mobile/shop/kcp/pp_ax_hub.php index f5e1e186b..ae5bcf1f9 100644 --- a/mobile/shop/kcp/pp_ax_hub.php +++ b/mobile/shop/kcp/pp_ax_hub.php @@ -1,5 +1,32 @@ base64_encode(serialize($kcp_snapshot)))); diff --git a/mobile/shop/orderform.sub.php b/mobile/shop/orderform.sub.php index 45525509d..0eb61e746 100644 --- a/mobile/shop/orderform.sub.php +++ b/mobile/shop/orderform.sub.php @@ -1,5 +1,6 @@
+
+ @@ -1333,14 +1336,14 @@ function pay_approval() f.cardUseEscrow.value = 'true'; - f.escrowProducts.value = JSON.stringify(); + f.escrowProducts.value = JSON.stringify(); f.cardflowMode.value = 'DEFAULT'; f.cardeasyPay.value = ''; if(settle_method == "간편결제") { var provider = $("input[name=od_settle_case]:checked").attr("data-pay"); - var providers = ; + var providers = ; if (providers.indexOf(provider) === -1) { alert('간편결제 수단을 다시 선택해 주세요.'); return false; @@ -1500,16 +1503,22 @@ function pay_approval() // 주문 정보 임시저장 - var order_data = $(pf).serialize(); - var save_result = ""; + + // 복귀 페이지에서 요청값으로 덮어쓰지 않도록 PG 필드를 임시 저장 전에 동기화한다. + $(f).serializeArray().forEach(function(field) { + if (pf.elements[field.name]) pf.elements[field.name].value = field.value; + }); + + var order_data = $(pf).serialize(); + var save_result = "결제 요청을 저장하지 못했습니다."; $.ajax({ type: "POST", data: order_data, url: g5_url+"/shop/ajax.orderdatasave.php", cache: false, async: false, - success: function(data) { - save_result = data; + success: function(data, textStatus, xhr) { + save_result = data || g5_order_state_accept(xhr); } }); diff --git a/mobile/shop/orderformupdate.php b/mobile/shop/orderformupdate.php index 8e77c1488..0b34e2f35 100644 --- a/mobile/shop/orderformupdate.php +++ b/mobile/shop/orderformupdate.php @@ -1,5 +1,10 @@
+ +

개인결제정보

@@ -311,16 +314,22 @@ function pay_approval() //f.target = "tar_opener"; // 주문 정보 임시저장 - var order_data = $(pf).serialize(); - var save_result = ""; + + // 복귀 페이지에서 요청값으로 덮어쓰지 않도록 PG 필드를 임시 저장 전에 동기화한다. + $(f).serializeArray().forEach(function(field) { + if (pf.elements[field.name]) pf.elements[field.name].value = field.value; + }); + + var order_data = $(pf).serialize(); + var save_result = "결제 요청을 저장하지 못했습니다."; $.ajax({ type: "POST", data: order_data, url: g5_url+"/shop/ajax.orderdatasave.php", cache: false, async: false, - success: function(data) { - save_result = data; + success: function(data, textStatus, xhr) { + save_result = data || g5_order_state_accept(xhr); } }); diff --git a/mobile/shop/personalpayformupdate.php b/mobile/shop/personalpayformupdate.php index 908dad56e..2c8d4e0c4 100644 --- a/mobile/shop/personalpayformupdate.php +++ b/mobile/shop/personalpayformupdate.php @@ -1,5 +1,7 @@ $value) { - if (isset($_REQUEST[$key]) && $_REQUEST[$key]) { - $value = $_REQUEST[$key]; - } - if (is_array($value)) { - $value = implode(',', $value); - } - if ($key === 'escrowProducts') { - $value = str_replace("\\", "", $value); - echo ''.PHP_EOL; - } else { - echo ''.PHP_EOL; - } +if (isset($payReqMap['escrowProducts']) && is_string($payReqMap['escrowProducts'])) { + $payReqMap['escrowProducts'] = stripslashes($payReqMap['escrowProducts']); } +echo make_order_field($payReqMap, array()); ?> +
+
@@ -1622,14 +1625,14 @@ function forderform_check(f) f.cardUseEscrow.value = 'true'; - f.escrowProducts.value = JSON.stringify(); + f.escrowProducts.value = JSON.stringify(); f.cardflowMode.value = 'DEFAULT'; f.cardeasyPay.value = ''; if(settle_method == "간편결제") { var provider = $("input[name=od_settle_case]:checked").attr("data-pay"); - var providers = ; + var providers = ; if (providers.indexOf(provider) === -1) { alert('간편결제 수단을 다시 선택해 주세요.'); return false; @@ -1648,15 +1651,15 @@ function forderform_check(f) if(f.method.value != "무통장") { // 주문정보 임시저장 var order_data = $(f).serialize(); - var save_result = ""; + var save_result = "결제 요청을 저장하지 못했습니다."; $.ajax({ type: "POST", data: order_data, url: g5_url+"/shop/ajax.orderdatasave.php", cache: false, async: false, - success: function(data) { - save_result = data; + success: function(data, textStatus, xhr) { + save_result = data || g5_order_state_accept(xhr); } }); @@ -1687,15 +1690,15 @@ function forderform_check(f) if(f.gopaymethod.value != "무통장") { // 주문정보 임시저장 var order_data = $(f).serialize(); - var save_result = ""; + var save_result = "결제 요청을 저장하지 못했습니다."; $.ajax({ type: "POST", data: order_data, url: g5_url+"/shop/ajax.orderdatasave.php", cache: false, async: false, - success: function(data) { - save_result = data; + success: function(data, textStatus, xhr) { + save_result = data || g5_order_state_accept(xhr); } }); @@ -1730,15 +1733,15 @@ function forderform_check(f) if(f.PayMethod.value != "무통장") { // 주문정보 임시저장 var order_data = $(f).serialize(); - var save_result = ""; + var save_result = "결제 요청을 저장하지 못했습니다."; $.ajax({ type: "POST", data: order_data, url: g5_url+"/shop/ajax.orderdatasave.php", cache: false, async: false, - success: function(data) { - save_result = data; + success: function(data, textStatus, xhr) { + save_result = data || g5_order_state_accept(xhr); } }); diff --git a/shop/orderformupdate.php b/shop/orderformupdate.php index 51c2b8dd1..1d0ca0a33 100644 --- a/shop/orderformupdate.php +++ b/shop/orderformupdate.php @@ -1,5 +1,10 @@ + + 0 && $pp['pp_id'] && $pp['od_id']) { } } +// 완료한 개인결제의 임시 데이터 삭제 +$sql = " delete from {$g5['g5_shop_order_data_table']} where od_id = '{$pp['pp_id']}' and dt_pg = '$pp_pg' "; +sql_query($sql); + // 개인결제번호제거 if (!empty($_POST['inicis_pro']) && function_exists('inicis_pro_audit_order_saved')) inicis_pro_audit_order_saved($pp['pp_id'], $pp_tno, 'personal', 'web'); +include_once(G5_LIB_PATH.'/shop_order_access.lib.php'); +shop_order_access_forget((string)$pp['pp_id']); set_session('ss_personalpay_id', ''); set_session('ss_personalpay_hash', ''); diff --git a/shop/toss/orderform.1.php b/shop/toss/orderform.1.php index e33cc31dd..afca8168f 100644 --- a/shop/toss/orderform.1.php +++ b/shop/toss/orderform.1.php @@ -45,8 +45,8 @@ async function launchCrossPlatform(frm) { taxFreeAmount: parseInt(frm.taxFreeAmount.value), orderId: frm.orderId.value, // 고유 주문번호 orderName: frm.orderName.value, - successUrl: "/toss/returnurl.php", // 결제 요청이 성공하면 리다이렉트되는 URL - failUrl: "/toss/returnurl.php?mode=fail", // 결제 요청이 실패하면 리다이렉트되는 URL + successUrl: g5_order_state_url("/toss/returnurl.php", frm), // 결제 요청이 성공하면 리다이렉트되는 URL + failUrl: g5_order_state_url("/toss/returnurl.php?mode=fail", frm), // 결제 요청이 실패하면 리다이렉트되는 URL customerEmail: frm.customerEmail.value, customerName: frm.customerName.value, customerMobilePhone: frm.customerMobilePhone.value, diff --git a/shop/toss/returnurl.php b/shop/toss/returnurl.php index 434cf9ad5..674656409 100644 --- a/shop/toss/returnurl.php +++ b/shop/toss/returnurl.php @@ -1,41 +1,20 @@ '한국산업은행', '03' => 'IBK기업은행', @@ -75,7 +75,7 @@ class TossPayments { 'ST' => '토스증권' ); - public array $cardCode = array( + public $cardCode = array( '3K' => '기업 BC', '46' => '광주은행', '71' => '롯데카드', @@ -105,7 +105,7 @@ class TossPayments { ); // 간편결제 제공업체 코드 - public array $easyPayCode = array( + public $easyPayCode = array( 'TOSSPAY' => '토스페이', 'NAVERPAY' => '네이버페이', 'SAMSUNGPAY' => '삼성페이', @@ -117,7 +117,7 @@ class TossPayments { 'SSG' => 'SSG페이' ); - public function __construct(string $clientKey, string $secretKey, string $mId) { + public function __construct($clientKey, $secretKey, $mId) { $this->clientKey = $clientKey; $this->secretKey = $secretKey; $this->mId = $mId; @@ -127,7 +127,7 @@ class TossPayments { * 헤더 시크릿 키 설정 * @return void */ - private function setHeaderSecretKey(): void + private function setHeaderSecretKey() { $this->headerSecretKey = base64_encode($this->secretKey . ':'); } @@ -136,7 +136,7 @@ class TossPayments { * 헤더 설정 * @return void */ - public function setPaymentHeader(): void + public function setPaymentHeader() { $this->setHeaderSecretKey(); @@ -152,7 +152,7 @@ class TossPayments { * @param array $request * @return void */ - public function setPaymentData(array $request): void + public function setPaymentData(array $request) { $this->paymentData = array( 'amount' => $request['amount'], @@ -164,10 +164,10 @@ class TossPayments { /** * 주문번호로 결제정보 조회 * - * @param string $orderId + * @param $orderId * @return bool */ - public function getPaymentByOrderId(string $orderId): bool + public function getPaymentByOrderId($orderId) { if (empty($orderId)) { return false; @@ -183,7 +183,7 @@ class TossPayments { $response = curl_exec($curl); $return_status = curl_getinfo($curl, CURLINFO_HTTP_CODE); - $this->responseData = json_decode($response, true); + $this->responseData = (array) json_decode((string)$response, true); curl_close($curl); @@ -200,7 +200,7 @@ class TossPayments { * * @return bool */ - public function approvePayment(): bool { + public function approvePayment() { $curl = curl_init(); curl_setopt($curl, CURLOPT_URL, $this->acceptUrl); curl_setopt($curl, CURLOPT_HTTPHEADER, $this->headers); @@ -213,12 +213,12 @@ class TossPayments { $response = curl_exec($curl); $return_status = curl_getinfo($curl, CURLINFO_HTTP_CODE); - $this->responseData = json_decode($response, true); + $this->responseData = (array) json_decode((string)$response, true); curl_close($curl); // 결제 실패 상황인 경우 - if ($return_status != 200 || ($this->responseData['status'] != 'DONE' && $this->responseData['status'] != 'WAITING_FOR_DEPOSIT')) { + if ($return_status != 200 || !isset($this->responseData['status']) || ($this->responseData['status'] != 'DONE' && $this->responseData['status'] != 'WAITING_FOR_DEPOSIT')) { return false; } @@ -231,7 +231,7 @@ class TossPayments { * @param array $request * @return void */ - public function setCancelData(array $request): void + public function setCancelData(array $request) { $this->cancelData = array( 'paymentKey' => $request['paymentKey'], @@ -263,7 +263,7 @@ class TossPayments { * * @return bool */ - public function cancelPayment(): bool + public function cancelPayment() { // 취소에 필요한 결제 키가 있는지 여부 if (empty($this->cancelData['paymentKey'])) { @@ -282,7 +282,7 @@ class TossPayments { $response = curl_exec($curl); $return_status = curl_getinfo($curl, CURLINFO_HTTP_CODE); - $this->responseData = json_decode($response, true); + $this->responseData = (array) json_decode((string)$response, true); curl_close($curl); @@ -297,7 +297,7 @@ class TossPayments { /** * 현금영수증 발급 데이터 설정 */ - public function setCashReceiptsData(array $request): void + public function setCashReceiptsData(array $request) { $this->cashReceiptsData = array( 'amount' => $request['amount'], @@ -311,7 +311,7 @@ class TossPayments { /** * 현금영수증 발급 */ - public function issueCashReceipt(): bool + public function issueCashReceipt() { $curl = curl_init(); curl_setopt($curl, CURLOPT_URL, $this->cashReceiptsUrl); @@ -328,7 +328,7 @@ class TossPayments { $response = curl_exec($curl); $return_status = curl_getinfo($curl, CURLINFO_HTTP_CODE); - $this->responseData = json_decode($response, true); + $this->responseData = (array) json_decode((string)$response, true); curl_close($curl); @@ -343,7 +343,7 @@ class TossPayments { /** * 현금영수증 발급 취소 */ - public function cancelCashReceipt($receiptKey): bool + public function cancelCashReceipt($receiptKey) { $curl = curl_init(); curl_setopt($curl, CURLOPT_URL, $this->cashReceiptsUrl."/".$receiptKey."/cancel"); @@ -359,7 +359,7 @@ class TossPayments { $response = curl_exec($curl); $return_status = curl_getinfo($curl, CURLINFO_HTTP_CODE); - $this->responseData = json_decode($response, true); + $this->responseData = (array) json_decode((string)$response, true); curl_close($curl); diff --git a/shop/toss/toss_approval.php b/shop/toss/toss_approval.php index 3c389e175..1a9f74ddc 100644 --- a/shop/toss/toss_approval.php +++ b/shop/toss/toss_approval.php @@ -1,79 +1,8 @@ setPaymentData(array( - 'orderId' => $orderId, - 'amount' => $amount, - 'paymentKey' => $paymentKey, -)); - -// 장바구니 ID 설정 (바로구매 여부 확인) -$ss_cart_id = get_session('ss_direct') ? get_session('ss_cart_direct') : get_session('ss_cart_id'); - -// 임시데이터에 결제 데이터 저장 -$addQuery = ""; -if (isset($orderId)) { - $addQuery .= " AND od_id = '$orderId'"; -} -if (isset($ss_cart_id)) { - $addQuery .= " AND cart_id = '$ss_cart_id'"; -} -if (isset($member['mb_id'])) { - $addQuery .= " AND mb_id = '{$member['mb_id']}'"; -} - -if (empty($orderId) && empty($ss_cart_id)) { - alert('주문정보가 올바르지 않습니다.'); - exit; -} - -// 기존 dt_data 가져오기 -$sql = " - SELECT * FROM {$g5['g5_shop_order_data_table']} - WHERE 1=1 - {$addQuery} - LIMIT 1 -"; -$res = sql_fetch($sql); -$dt_data = array(); -if (isset($res['dt_data'])) { - $dt_data = unserialize(base64_decode($res['dt_data'])); -} - -// dt_data 에 결제 키 추가 -if (isset($paymentKey)) { - $dt_data['paymentKey'] = $paymentKey; - $dt_data_new = base64_encode(serialize($dt_data)); - - // 업데이트 - $sql = " - UPDATE {$g5['g5_shop_order_data_table']} SET - dt_data = '".$dt_data_new."' - WHERE od_id = '$orderId' - {$addQuery} - "; - sql_query($sql); -} - -if(isset($payReqMap['pp_id']) && $payReqMap['pp_id']) { - $page_return_url = G5_SHOP_URL.'/personalpayform.php?pp_id='.$payReqMap['pp_id']; -} else { - $page_return_url = G5_SHOP_URL.'/orderform.php'; - if ($_SESSION['ss_direct']) { - $page_return_url .= '?sw_direct=1'; - } -} -?> +$amount = isset($_REQUEST['amount']) ? $_REQUEST['amount'] : ''; +shop_order_access_payment($orderId, $paymentKey, $amount); diff --git a/shop/toss/toss_cancel.php b/shop/toss/toss_cancel.php index 8738a0597..9c1cfaba2 100644 --- a/shop/toss/toss_cancel.php +++ b/shop/toss/toss_cancel.php @@ -1,6 +1,8 @@ setPaymentHeader(); -$od_id = isset($od['od_id']) ? $od['od_id'] : (isset($pp['pp_id']) ? $pp['pp_id'] : ''); +$cancel_runtime = function_exists('shop_order_runtime') ? shop_order_runtime() : array('active'=>''); +$cancel_order_id = $cancel_runtime['active'] !== '' ? $cancel_runtime['active'] : + (isset($od['od_id']) ? (string)$od['od_id'] : (isset($pp['pp_id']) ? (string)$pp['pp_id'] : (string)get_session('ss_order_id'))); -if (!$toss->getPaymentByOrderId($od_id)) { +if (!$toss->getPaymentByOrderId($cancel_order_id)) { alert('결제정보를 가져올 수 없습니다.'); } +if ($cancel_runtime['active'] !== '') { + $cancel_state = shop_order_state_row($cancel_order_id); + if (!isset($toss->responseData['orderId'],$toss->responseData['paymentKey']) || + $toss->responseData['orderId'] !== $cancel_order_id || $toss->responseData['paymentKey'] !== $cancel_state['payment_key']) shop_order_access_fail(); +} +if (isset($toss->responseData['status']) && $toss->responseData['status'] === 'CANCELED') { + if (function_exists('shop_order_state_cancel')) shop_order_state_cancel('cancelled'); + return; +} +$toss->headers[] = 'Idempotency-Key: g5-cancel-'.hash('sha256', $cancel_order_id.$toss->responseData['paymentKey']); $toss->setCancelData(array( 'paymentKey' => $toss->responseData['paymentKey'], @@ -35,4 +49,5 @@ if (!$toss->cancelPayment()) { $msg .= '코드 : ' . $toss->responseData['code']; } alert($msg); -} \ No newline at end of file +} +if (function_exists('shop_order_state_cancel')) shop_order_state_cancel('cancelled'); diff --git a/shop/toss/toss_result.php b/shop/toss/toss_result.php index 26e191b62..7e9aa0ddb 100644 --- a/shop/toss/toss_result.php +++ b/shop/toss/toss_result.php @@ -7,20 +7,14 @@ require_once(G5_SHOP_PATH.'/toss/toss.inc.php'); $orderId = isset($_REQUEST['orderId']) ? $_REQUEST['orderId'] : ''; $paymentKey = isset($_POST['paymentKey']) ? $_POST['paymentKey'] : ''; -if (empty($orderId) || empty($paymentKey)) { - alert('주문정보가 올바르지 않습니다.', G5_SHOP_URL); -} - -$sql = " select * from {$g5['g5_shop_order_data_table']} where od_id = '$orderId' limit 1 "; -$row = sql_fetch($sql); - -$data = isset($row['dt_data']) ? unserialize(base64_decode($row['dt_data'])) : array(); - +include_once(G5_LIB_PATH.'/shop_order_access.lib.php'); +$data = shop_order_access_payment($orderId, $paymentKey); +$is_personal = !empty($data['pp_id']); +if ($is_personal !== !empty($_POST['pp_id']) || + ($is_personal && (string)$_POST['pp_id'] !== $orderId)) shop_order_access_fail(); $amount = isset($data['amountValue']) ? (int)$data['amountValue'] : 0; - -if ($amount <= 0 || $amount !== (int)$order_price) { - alert('결제금액이 올바르지 않습니다.', G5_SHOP_URL); -} +$expected_amount = $is_personal ? (int)$pp['pp_price'] : (int)$order_price; +if ($amount <= 0 || $amount !== $expected_amount) shop_order_access_fail(); $toss = new TossPayments( $config['cf_toss_client_key'], @@ -37,9 +31,12 @@ $toss->setPaymentData(array( $toss->setPaymentHeader(); // 결제승인 요청 -$result = $toss->approvePayment(); +$result = shop_order_toss_approve($toss, $orderId, $paymentKey, $expected_amount); if ($result) { + if (!isset($toss->responseData['orderId'], $toss->responseData['paymentKey'], $toss->responseData['totalAmount']) || + $toss->responseData['orderId'] !== $orderId || $toss->responseData['paymentKey'] !== $paymentKey || + (int)$toss->responseData['totalAmount'] !== $expected_amount) shop_order_access_fail(); // 결제승인 성공시 처리 $status = isset($toss->responseData['status']) ? $toss->responseData['status'] : ''; $method = isset($toss->responseData['method']) ? $toss->responseData['method'] : ''; diff --git a/tools/shop-order-maintenance.php b/tools/shop-order-maintenance.php new file mode 100644 index 000000000..db3bacff0 --- /dev/null +++ b/tools/shop-order-maintenance.php @@ -0,0 +1,86 @@ +$id,'action'=>'--apply로 평문 제거·해시 별도 보관·복귀 차단을 실행합니다. 백업과 PG 대조를 먼저 수행하십시오.'); + else $out = shop_order_quarantine_legacy($id); + } elseif ($action === 'reconcile-toss') { + if (!$apply) throw new RuntimeException('PG 조회와 상태 갱신을 실행하려면 --apply를 지정해 주십시오.'); + require_once G5_SHOP_PATH.'/toss/toss.inc.php'; + $toss = new TossPayments($config['cf_toss_client_key'],$config['cf_toss_secret_key'],$config['cf_lg_mid']); + $toss->setPaymentHeader(); $out = shop_order_reconcile_toss($id, $toss); + } elseif ($action === 'reconcile-kcp') { + if (!$apply || !isset($options['verified'],$options['receipt'])) throw new RuntimeException('KCP 거래 원장 대조 후 --apply --verified --receipt=파일을 지정해 주십시오.'); + shop_order_state_lock('order', $id); $row = shop_order_state_row($id); + $state = $row ? json_decode($row['state_json'],true) : null; + $receipt = json_decode(file_get_contents($options['receipt']),true); + if (!$row || $row['pg'] !== 'kcp' || !in_array($row['status'],array('approving','unknown'),true) || + !is_array($receipt) || !isset($receipt['orderId'],$receipt['amount'],$receipt['tno'],$receipt['res_cd']) || + (string)$receipt['orderId'] !== $id || empty($state['expected_amount']) || (int)$receipt['amount'] !== (int)$state['expected_amount'] || + !is_string($receipt['tno']) || !preg_match('/\A[A-Za-z0-9_-]{6,100}\z/D',$receipt['tno']) || + !in_array($receipt['res_cd'],array('0000','V000'),true)) throw new RuntimeException('KCP 대조 자료가 주문·금액·승인 상태와 일치하지 않습니다.'); + $payload = array_intersect_key($receipt,array_flip(shop_order_kcp_result_fields())); + foreach ($payload as $value) if (!is_scalar($value)) throw new RuntimeException('승인 필드는 단일 값이어야 합니다.'); + shop_order_state_approved($id,array('payload'=>base64_encode(serialize($payload)))); + $out = array('order'=>$id,'status'=>'approved','action'=>'운영자 확인 자료 반영. 원래 토큰으로 재시도하십시오.'); + } elseif ($action === 'purge-reviewed-legacy') { + if (!$apply || !isset($options['verified'],$options['receipt'])) throw new RuntimeException('기존 거래 원장 대조 후 --apply --verified --receipt=파일을 지정하십시오.'); + shop_order_state_lock('order',$id);$row=shop_order_state_row($id); + $receipt=json_decode(file_get_contents($options['receipt']),true); + if (!$row || $row['status'] !== 'legacy' || !is_array($receipt) || !isset($receipt['orderId'],$receipt['resolution'],$receipt['reference']) || + (string)$receipt['orderId'] !== $id || !in_array($receipt['resolution'],array('unpaid','cancelled','completed'),true) || + !is_string($receipt['reference']) || trim($receipt['reference'])==='') throw new RuntimeException('격리된 과거 주문과 대조 자료를 확인하십시오.'); + if ($receipt['resolution']==='completed') { + $order=sql_fetch("select od_tno from {$g5['g5_shop_order_table']} where od_id='$id'"); + $personal=sql_fetch("select pp_tno from {$g5['g5_shop_personalpay_table']} where pp_id='$id'"); + if (empty($order['od_tno']) && empty($personal['pp_tno'])) throw new RuntimeException('완료 거래의 주문 저장을 먼저 복구하십시오.'); + } + $pg=sql_escape_string($row['pg']); + if (!sql_query("delete from {$g5['g5_shop_order_data_table']} where od_id='$id' and dt_pg='$pg'",false)) shop_order_access_fail(); + shop_order_state_write($id,array('status'=>'purged','state_json'=>json_encode(array('review_sha256'=>hash_file('sha256',$options['receipt']))),'response_json'=>'','token_hash'=>'','payment_key'=>'')); + $out=array('order'=>$id,'status'=>'purged'); + } elseif ($action === 'complete-reviewed') { + if (!$apply || !isset($options['verified'])) throw new RuntimeException('주문·PG·장바구니·포인트·쿠폰 대조를 마친 후 --apply --verified를 지정하십시오.'); + shop_order_state_lock('order',$id);$row=shop_order_state_row($id); + if (!$row || $row['status'] !== 'finalizing') throw new RuntimeException('부분 저장 대조 대상이 아닙니다.'); + $state=json_decode($row['state_json'],true); + $saved=$state['personal'] ? sql_fetch("select pp_tno as tno from {$g5['g5_shop_personalpay_table']} where pp_id='$id'") : sql_fetch("select od_tno as tno from {$g5['g5_shop_order_table']} where od_id='$id'"); + if (empty($saved['tno'])) throw new RuntimeException('저장된 주문의 거래번호가 없습니다.'); + if ($row['pg']==='toss' && $saved['tno']!==$row['payment_key']) throw new RuntimeException('저장 거래번호와 승인 기록이 다릅니다.'); + if ($row['pg']==='kcp') { + $response=json_decode($row['response_json'],true); + $response=isset($response['payload'])?shop_order_decode_data($response['payload']):false; + if (!is_array($response) || empty($response['tno']) || $saved['tno']!==$response['tno']) throw new RuntimeException('KCP 저장 거래번호와 승인 기록이 다릅니다.'); + } + shop_order_state_complete($id);$out=array('order'=>$id,'status'=>'completed'); + } else throw new RuntimeException('지원하지 않는 action입니다.'); + echo json_encode($out).PHP_EOL; +} catch (Exception $e) { + fwrite(STDERR,$e->getMessage().PHP_EOL);exit(1); +} diff --git a/version.php b/version.php index a765648f0..95f6f19a9 100644 --- a/version.php +++ b/version.php @@ -2,7 +2,7 @@ if (!defined('_GNUBOARD_')) exit; // 개별 페이지 접근 불가 define('G5_VERSION', '그누보드5'); -define('G5_GNUBOARD_VER', '5.6.38'); +define('G5_GNUBOARD_VER', '5.6.39'); // 그누보드5.4.5.5 버전과 영카트5.4.5.5.1 버전을 합쳐서 그누보드5.4.6 버전에서 시작함 (kagla-210617) // G5_YOUNGCART_VER 이 상수를 사용하는 곳이 있으므로 주석 처리 해제함 // 그누보드5.4.6 이상 버전 부터는 영카트를 그누보드에 포함하여 배포하므로 영카트5의 버전은 의미가 없습니다.