From a288b3a5f0ba3f3eacc3e1bdaddb7666b93a3abf Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Tue, 6 Oct 2026 00:15:23 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20GS25=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=9E=AC=ED=98=84=EA=B3=BC=20Windows=20=EC=95=88=EB=82=B4=20?= =?UTF-8?q?=EB=B3=B4=EC=B6=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/convenience-relay.md | 2 + docs/gs25-auth-login-guide.md | 197 +++++++++++++++++++++++++++ docs/gs25-auth-recovery-reference.md | 33 +++-- docs/gs25-auth-recovery-runbook.md | 61 +++++---- 4 files changed, 259 insertions(+), 34 deletions(-) create mode 100644 docs/gs25-auth-login-guide.md diff --git a/docs/convenience-relay.md b/docs/convenience-relay.md index bb16f322..43a0fc2f 100644 --- a/docs/convenience-relay.md +++ b/docs/convenience-relay.md @@ -1,5 +1,7 @@ # 편의점 Mac 중계 +개인 사이트에서 GS 토큰 발급을 처음 재현한다면 [단계별 로그인 가이드](gs25-auth-login-guide.md)를 읽으세요. 네이버 로그인 후 ‘닫기’ 화면·현재 번들 탐색·Windows와 도구 버전의 검증 범위를 설명합니다. + GS25 세션 만료·인증 장애를 복구할 때는 [정상 로그인 복구 운영 절차](gs25-auth-recovery-runbook.md)부터 읽으세요. 티켓·네이버 로그인·GS 토큰 저장·재기동·가상기기 종료 후 검증과 재현용 참조 코드를 포함합니다. CU PocketCU, 세븐일레븐 공식 앱 API와 GS25 상품·재고 API를 현재 Mac의 브라우저 없는 HTTP 중계로 호출합니다. 정상 API 응답 계약은 유지합니다. Worker에 설정이 없으면 기존 직접 호출을 사용하며, URL·토큰 또는 Access 인증의 부분 설정은 오류를 반환합니다. diff --git a/docs/gs25-auth-login-guide.md b/docs/gs25-auth-login-guide.md new file mode 100644 index 00000000..991add08 --- /dev/null +++ b/docs/gs25-auth-login-guide.md @@ -0,0 +1,197 @@ +# GS25 로그인 가이드: 네이버 로그인에서 GS 토큰 저장까지 + +개인 계정으로 GS25 재고 조회를 구성하는 개발자를 위한 안내입니다. 네이버 로그인 후 **‘닫기’ 화면에 멈춘 경우에는 4단계**를 먼저 확인하세요. 이 가이드는 GS 공식 웹의 정상 로그인 흐름을 사용합니다. + +## 확인된 범위 + +| 항목 | 확인 내용 | +| -------------------------- | ------------------------------------------------------------------------------------- | +| 실제 전체 로그인·재고 조회 | 2026-10-05, macOS + headed Chrome + agent-browser | +| GS 앱 헤더 기준 | 우리동네GS 5.3.61 / build 2132, Android 14 | +| 공개 웹 코드 재확인 | 2026-10-06, `login.BDscyujx.js`의 `u` export·`loginNaver`·v2 코드 교환 경로 존재 확인 | +| agent-browser 버전 | 성공 당시 버전은 기록하지 않아 확정할 수 없음. 2026-10-06 로컬 설치 버전은 0.27.0 | +| Windows | 로그인·저장·중계 전체 실행은 미검증. 아래 PowerShell 명령은 이식 시 참고할 절차 | + +확인한 공식 공개 코드: [로그인 모듈](https://ex-hubpage.grm.gsretail.com/_nuxt/login.BDscyujx.js), [GS 허브 페이지](https://ex-hubpage.grm.gsretail.com/bizmgt/signin/bridge/naver). + +10월 6일 확인은 공개 번들의 정적 확인입니다. 새 티켓으로 실제 계정 로그인을 다시 완료한 결과는 아닙니다. 아래 모듈 경로와 export 이름도 GS 배포에 따라 바뀔 수 있습니다. + +## 먼저 알아둘 세 가지 + +1. 네이버 로그인 결과의 `code`/`state`는 일회성 승인 정보입니다. 재고 API에 쓸 GS 토큰이 아닙니다. +2. GS 웹이 승인 정보를 교환하고 **GS `authReturn`으로 이동해야** `token`/`refresh`를 얻습니다. +3. 처음 GS bridge를 연 브라우저 프로필과 GS 탭을 계속 사용하세요. 주소만 다른 브라우저에 복사하거나 `/bridge/naver`로 새로고침하면 초기화된 채널·로그인 상태를 잃을 수 있습니다. + +```text +새 GS 티켓 → GS bridge 초기화 → 네이버 로그인 + → GS 허브 callback(code/state, ‘닫기’) + → 공식 loginNaver로 코드 교환 + → GS authReturn(token/refresh) → 로컬 세션 저장 +``` + +## 1. 비공개 작업 폴더를 만들고 시작 코드를 준비합니다 + +[참조 코드](gs25-auth-recovery-reference.md)의 `start.py`와 `capture.py`를 **저장소 밖의 새 작업 폴더**에 저장하세요. Python 3.9 이상을 기준으로 합니다. 브라우저는 화면이 보이는 Chrome을 사용하며 계정 입력·추가 인증은 직접 완료합니다. + +macOS에서는 폴더를 0700으로 만듭니다. Windows의 `os.open(..., 0o600)`은 NTFS 접근 권한을 설정하는 대체물이 아닙니다. PowerShell에서는 현재 사용자 전용 ACL을 먼저 설정합니다. 사용자 이름과 폴더 경로는 자신의 환경에서 확인하세요. + +```powershell +$work = Join-Path $env:LOCALAPPDATA ('Gs25Auth-' + [guid]::NewGuid()) +New-Item -ItemType Directory -Path $work | Out-Null +$me = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name +icacls $work /inheritance:r /grant:r "$($me):(OI)(CI)F" +if ($LASTEXITCODE -ne 0) { throw '작업 폴더 권한 설정 실패' } +Set-Location $work +# 이 폴더에 start.py와 capture.py를 저장한 다음 실행합니다. +python start.py +``` + +`start.py`는 새 티켓을 발급하고 `http://127.0.0.1:PORT/start`를 출력합니다. 서버를 실행한 터미널은 열어두세요. 같은 작업 폴더로 두 번 발급하려고 하면 실패합니다. 만료된 티켓은 새 작업 폴더에서 다시 시작합니다. + +## 2. GS 초기 bridge를 열고 같은 탭에서 이동합니다 + +처음부터 `/bizmgt/signin/bridge/naver` 주소를 직접 열지 마세요. 시작 코드가 출력한 **루프백 주소**를 먼저 열어 GS bridge가 티켓과 우리동네GS 채널을 초기화하도록 합니다. + +agent-browser를 사용한다면 첫 실행은 다음 형태입니다. 프로필 경로에는 새 작업 폴더 아래의 절대 경로를 사용하세요. 이후 명령도 같은 `--session gs25-native-auth`를 지정합니다. 출력에는 티켓 URL이 포함될 수 있어 비공개 파일로만 저장합니다. + +```powershell +$profile = Join-Path $work 'chrome-profile' +# PORT를 start.py가 출력한 실제 포트로 바꿉니다. +agent-browser --session gs25-native-auth --profile $profile --headed open http://127.0.0.1:PORT/start *> (Join-Path $work 'browser.private.log') +``` + +agent-browser가 대기 문제로 막히면 **처음부터 별도 Chrome 프로필의 일반 브라우저로 진행해도 됩니다**. 이 경우 루프백 주소부터 시작하고, 5단계에서 수동 저장 방식을 사용합니다. 진행 중인 티켓의 주소를 새 프로필로 옮기지는 마세요. + +GS 페이지에서 Chrome 개발자 도구(F12)를 열고 Console에 아래 코드를 실행합니다. 네이버 페이지나 다른 프레임이 아니라 **GS 허브의 메인 페이지 컨텍스트**를 선택합니다. 이 방식은 URL 전체를 다시 여는 대신 현재 앱의 라우터로 이동합니다. + +```javascript +(async () => { + if (location.origin !== 'https://ex-hubpage.grm.gsretail.com') { + console.log('GS 허브 페이지에서 실행하세요.'); + return; + } + const router = document.querySelector('#__nuxt')?.__vue_app__?.config.globalProperties.$router; + if (!router) { + console.log('GS 앱 초기화가 완료됐는지 확인하세요.'); + return; + } + await router.push('/bizmgt/signin/bridge/naver'); +})(); +``` + +## 3. 네이버 로그인을 완료합니다 + +최초 GS bridge에서 ‘네이버 통신 장애’ 팝업은 일반 브라우저에 앱용 네이버 SDK가 없는 흐름에서 관찰됐습니다. 팝업 자체만으로 GS 서버 장애나 토큰 발급 실패를 확정하지 마세요. 팝업을 닫은 뒤 2단계의 이동을 진행하고, 네이버 공식 화면에 **‘우리동네GS 로그인 중’**이 표시되는지 확인합니다. + +GS SHOP이 표시되면 다른 채널입니다. 새 티켓으로 초기 bridge부터 다시 시작하세요. 우리동네GS 네이버 로그인·추가 인증을 마치고 GS 허브의 `/bizmgt/signin/bridge/naver`로 돌아오면 다음 단계로 진행합니다. + +## 4. ‘닫기’ 화면에서 GS 코드 교환을 실행합니다 + +이 화면의 `code`/`state`를 읽어 **GS 공식 로그인 모듈**에 전달해야 합니다. 과거 참조의 `start.py`와 `capture.py`만 실행하면 이 중간 단계가 수행되지 않습니다. agent-browser 명령이 끝나지 않았다는 것만으로 코드 교환 요청이 전송됐다고 판단해서도 안 됩니다. + +다음은 코드 교환을 포함한 최소 브라우저 스크립트입니다. **로그인 직후의 같은 GS 허브 탭**에서 Console로 한 번만 실행하세요. 코드·토큰은 출력하지 않습니다. 현재 확인된 `u` export는 GS 로그인 기능을 반환하는 함수이며, 다른 export를 차례대로 실행해 찾지는 마세요. + +```javascript +(async () => { + if ( + location.origin !== 'https://ex-hubpage.grm.gsretail.com' || + location.pathname !== '/bizmgt/signin/bridge/naver' + ) { + console.log('네이버 로그인 후 돌아온 GS 허브 탭에서 실행하세요.'); + return; + } + const params = new URLSearchParams(location.search); + const codes = params.getAll('code'); + const states = params.getAll('state'); + if (codes.length !== 1 || states.length !== 1 || !codes[0] || !states[0]) { + console.log('승인 정보가 없습니다. 새 티켓부터 로그인하세요.'); + return; + } + const nuxt = document.querySelector('#__nuxt')?.__vue_app__?.config.globalProperties.$nuxt; + if (typeof nuxt?.runWithContext !== 'function') { + console.log('GS 앱 컨텍스트를 찾지 못했습니다. 원래 GS 탭인지 확인하세요.'); + return; + } + // 2026-10-06 공개 번들 확인값. 변경됐으면 아래 탐색 절차를 따릅니다. + const modulePath = '/_nuxt/login.BDscyujx.js'; + const exportName = 'u'; + try { + const mod = await import(modulePath); + if (typeof mod[exportName] !== 'function') { + console.log('로그인 export가 변경됐습니다. 현재 번들을 확인하세요.'); + return; + } + const api = nuxt.runWithContext(() => mod[exportName]()); + if (typeof api?.loginNaver !== 'function') { + console.log('공식 loginNaver 함수를 찾지 못했습니다.'); + return; + } + const result = await nuxt.runWithContext(() => + api.loginNaver({ + code: codes[0], + state: states[0], + }), + ); + console.log( + result === 'ERROR' + ? '교환 실패. Network에서 상태 코드만 확인하세요.' + : '교환 호출 완료. GS 반환 주소 또는 추가 가입 안내를 확인하세요.', + ); + } catch { + console.log('교환을 완료하지 못했습니다. 현재 번들·앱 컨텍스트를 확인하세요.'); + } +})(); +``` + +`runWithContext`는 현재 Nuxt 앱의 로그인 기능을 사용하는 데 필요합니다. 그 기능이 초기 bridge의 GS 채널 정보와 반환 정보를 함께 사용하므로 `code/state`만 Python의 새 HTTP 세션에 보내는 것과 같지 않습니다. 원본 코드가 사용하는 쿠키·앱 상태를 유지해야 합니다. 성공 당시에도 같은 프로필·GS 앱 탭·Nuxt 컨텍스트를 유지했습니다. 어떤 개별 쿠키가 필수인지는 별도로 분리 검증하지 않았습니다. + +### 요청이 실제 실행됐는지 확인하는 방법 + +개발자 도구의 **Network** 탭을 열고 `login/naver`로 필터링합니다. 로그인 반환에 따른 이동을 확인하려면 Preserve log를 켜되, 확인 뒤 민감한 기록을 삭제하고 HAR를 공유하지 마세요. + +- 기대 요청: `POST /api/hub/bridge/v2/login/naver`. +- 요청이 없다면 스크립트 실행·모듈 import·Nuxt 컨텍스트·잘못된 탭을 확인합니다. +- 요청이 있어도 HTTP 200만으로 성공은 아닙니다. GS 반환 주소 또는 추가 가입/동의 흐름을 확인합니다. +- 최종 성공 주소: `https://b2c-bff.woodongs.com/api/bff/v4/grmHub/authReturn`. `result=Y`, `resultCode=0000`, 비어 있지 않은 `token`·`refresh`가 있어야 합니다. +- 승인 정보가 만료됐거나 이미 사용됐다면 같은 코드로 반복 호출하지 말고 새 티켓부터 시작합니다. + +GS 공식 모듈 자체가 실패 내용을 Console에 기록할 수 있습니다. 콘솔·Network의 본문·전체 URL을 외부에 보내지 마세요. 위 스크립트의 ‘호출 완료’ 메시지도 토큰 발급 성공 판정을 대신하지 않습니다. + +### 모듈 경로나 export가 바뀌었을 때 + +1. **현재 GS 허브 탭**의 DevTools → Sources에서 전체 검색(Ctrl+Shift+F, Mac은 Cmd+Option+F)을 엽니다. +2. `loginNaver` 또는 `/api/hub/bridge/v2/login/naver`를 검색합니다. 비슷한 `/api/hub/bridge/login/naver`는 다른 경로이므로 혼동하지 마세요. +3. 검색된 파일의 실제 `/_nuxt/…js` URL을 확인합니다. 압축되어 있으면 `{}` 버튼으로 보기 좋게 정렬합니다. +4. `loginNaver`를 반환하는 함수와 파일 끝 export를 연결합니다. 10월 6일에는 함수 `Z`가 `export { … Z as u }`로 노출됐습니다. 글자 `Z`·`u`는 고정 계약이 아닙니다. +5. 필요하면 Network의 JS 필터에서 해당 파일 또는 이를 import하는 모듈을 찾아 검색합니다. Console에서 **검증한 단일 모듈만** import한 뒤 `Object.keys(mod)`로 export 이름을 볼 수 있지만, 이름 목록만으로 어느 함수인지 판정할 수는 없습니다. +6. 확인한 값만 위 스크립트의 `modulePath`·`exportName`에 반영합니다. 네이버 client ID·redirect·scope·GS 채널을 임의 변경하지 않습니다. + +Sources에 lazy-loaded 모듈이 없다면 Network에서 현재 페이지의 `modulepreload`·import 관계를 확인해 공개 JS를 읽으세요. 10월 6일 공개 페이지는 `/_nuxt/login.BDscyujx.js`를 참조했습니다. 무작위 export 실행이나 임의 API 본문 조합으로 교환을 추측하지 마세요. + +## 5. 반환 주소를 출력하지 않고 세션을 저장합니다 + +[참조 코드의 capture.py](gs25-auth-recovery-reference.md#3-gs-authreturn만-비공개-파일로-저장)를 실행합니다. + +- agent-browser를 썼다면 같은 session에서 `python capture.py`를 실행합니다. +- 일반 Chrome으로 진행했다면 `python capture.py --manual`을 실행하고, **본인 터미널의 숨김 입력**에 최종 GS 반환 주소를 붙여 넣습니다. 주소를 셸 명령 인자나 채팅에 넣지 마세요. 입력 문자가 숨겨지지 않는다는 경고가 나오면 중단합니다. 사용 후 클립보드를 비웁니다. + +생성 파일은 `session.private.json`이며 `accessToken`, `refreshToken`, `deviceId` 세 필드만 담습니다. `capture.py`는 주소·성공 플래그·JWT 형태·만료를 검사하지만 서명을 검증하지는 않습니다. 실제 재고·갱신 성공은 다음 단계에서 확인합니다. + +Windows에서 agent-browser 호출이 멈추면 `Get-Command agent-browser`, `agent-browser --version`, `agent-browser --help`부터 확인하세요. 별도 Python 도구가 npm `.cmd` shim을 실행하는 문제인지, 브라우저 시작 문제인지, 다른 session 문제인지 구분합니다. capture 예제의 subprocess는 15초에 중단하며 시간 초과를 로그인 성공으로 취급하지 않습니다. cmd/shim 실행을 억지로 바꾸기 전에 일반 Chrome + 수동 저장 경로로 로그인 단계와 도구 문제를 분리할 수 있습니다. + +## 6. 서버에서 사용합니다 + +GS 토큰을 개인 사이트의 브라우저 JavaScript·공개 저장소에 넣지 마세요. 재고 API는 서버에서 호출합니다. GS `deviceId`는 티켓 발급 때 사용한 값을 유지하고, access 만료·401/403 때 정상 refresh 흐름을 사용합니다. + +이 저장소 중계를 사용하는 경우 [운영 절차 4절](gs25-auth-recovery-runbook.md#4-재고갱신을-검증한-뒤-운영-파일을-교체한다)로 이어가세요. 현재 구현은 파일 소유자·0600 권한을 검사하는 POSIX/Mac 운영 기준입니다. Windows에서는 이 검사를 NTFS ACL에 맞춰 구현·검증해야 하므로, 로그인 예제를 실행했다고 중계 전체가 Windows에서 지원된다고 볼 수 없습니다. `launchctl` 명령도 Mac 전용입니다. + +## 질문별 답변 + +| 질문 | 답변 | +| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 지금도 같은 loginNaver를 쓰나요? | 실제 성공은 10월 5일입니다. 10월 6일 공개 번들에서도 같은 모듈·u export·v2 경로를 확인했습니다. 매번 필요한 로그인 단계이며 재고 조회마다 실행하지 않습니다. 새 계정 로그인 전체 재검증 결과는 아닙니다. | +| 최소 재현 스크립트가 있나요? | 참조의 start.py → 이 가이드 2·4단계 브라우저 스크립트 → capture.py 순서입니다. 초기화와 사용자 로그인 사이의 수동 단계가 있으므로 두 Python 파일만으로 자동 완료되지 않습니다. | +| agent-browser 버전·컨텍스트는? | 당시 버전 미기록, 현재 0.27.0입니다. 같은 프로필·GS 탭·채널 상태·Nuxt 컨텍스트를 유지했습니다. 일반 Chrome 개발자 도구로도 같은 공식 모듈을 호출할 수 있도록 안내했습니다. | +| Windows에서도 검증했나요? | 아니요. PowerShell/수동 Chrome 경로는 안내이며 Windows 전체 성공을 보장하는 검증 기록은 없습니다. NTFS ACL·실행 파일/shim·중계 권한 검사를 따로 확인해야 합니다. | + +추가 질문을 보내실 때는 OS·Python/agent-browser 버전, 마지막 완료 단계, 모듈 경로/export 이름, 교환 요청 발생 여부·HTTP 상태만 알려주세요. 인증 코드·토큰·쿠키·전체 callback URL·HAR는 보내지 않아도 됩니다. diff --git a/docs/gs25-auth-recovery-reference.md b/docs/gs25-auth-recovery-reference.md index 0530661d..c1b8c6f8 100644 --- a/docs/gs25-auth-recovery-reference.md +++ b/docs/gs25-auth-recovery-reference.md @@ -1,6 +1,6 @@ # GS25 정상 로그인 참조 코드 -[운영 절차](gs25-auth-recovery-runbook.md)의 3절과 함께 사용한다. 아래 코드는 2026-10-05에 성공한 티켓 시작·callback 저장 절차를 자기 완결된 예제로 정리한 것이다. 임시 scratch의 다른 Python 모듈이 필요하지 않다. **문서 예제의 구문 검증과 당시 정상 흐름의 실증은 서로 다르다.** 다음 실행에서는 현재 공식 계약을 확인하고 결과를 새로 검증한다. 이 예제는 운영 재고 서버나 공개 로그인 endpoint로 배포하지 않는다. +[단계별 로그인 가이드](gs25-auth-login-guide.md)와 [운영 절차](gs25-auth-recovery-runbook.md)의 3절과 함께 사용한다. 아래 코드는 2026-10-05에 성공한 티켓 시작·callback 저장 절차를 자기 완결된 예제로 정리한 것이다. 임시 scratch의 다른 Python 모듈이 필요하지 않다. **문서 예제의 구문 검증과 당시 정상 흐름의 실증은 서로 다르다.** 다음 실행에서는 현재 공식 계약을 확인하고 결과를 새로 검증한다. 이 예제는 운영 재고 서버나 공개 로그인 endpoint로 배포하지 않는다. 빈 작업 디렉터리와 브라우저 profile을 현재 사용자만 접근할 수 있게 0700으로 만든다. 첫 코드를 `start.py`, 두 번째를 `capture.py`로 저장한다. 티켓·세션 출력은 이 파일들과 같은 비공개 디렉터리에만 생성된다. 성공한 세션을 운영 파일에 설치하는 방법은 운영 절차 4절을 따른다. @@ -91,18 +91,25 @@ finally: 초기 bridge를 읽은 뒤 운영 절차의 SPA 이동·네이버 화면 채널 확인·필요한 경우 공식 `loginNaver` 코드 교환을 수행한다. -## 2. GS authReturn만 비공개 파일로 저장 +## 2. 같은 GS 탭에서 코드 교환 -브라우저가 최종 GS 반환 주소에 도달한 뒤 `python3 capture.py`를 실행한다. URL이 아직 네이버/허브 callback이면 저장하지 않는다. 출력은 성공 여부뿐이다. `agent-browser` 자체 명령을 별도로 실행해 callback URL 전체를 터미널에 출력하지 않는다. +`start.py`는 티켓 발급까지만 하고 `capture.py`는 이미 발급된 GS 토큰을 저장한다. **네이버 로그인 → GS 코드 교환은 이 두 파일에 포함되어 있지 않다.** [로그인 가이드 2~4단계](gs25-auth-login-guide.md#2-gs-초기-bridge를-열고-같은-탭에서-이동합니다)의 초기 SPA 이동·사용자 로그인·최소 교환 스크립트를 순서대로 수행한다. Network에서 공식 v2 코드 교환 요청과 최종 GS 반환을 확인한다. + +## 3. GS authReturn만 비공개 파일로 저장 + +브라우저가 최종 GS 반환 주소에 도달한 뒤 `python3 capture.py`를 실행한다. 일반 Chrome으로 진행했다면 `python3 capture.py --manual`로 본인 터미널의 숨김 입력에 반환 주소를 붙여 넣는다. 숨김 입력이 지원되지 않으면 중단한다. URL이 아직 네이버/허브 callback이면 저장하지 않는다. 출력은 성공 여부뿐이다. `agent-browser` 자체 명령을 별도로 실행해 callback URL 전체를 터미널에 출력하지 않는다. ```python import base64 +import getpass import json import math import os import re import subprocess +import sys import time +import warnings import urllib.parse from pathlib import Path @@ -122,11 +129,19 @@ def valid_token(value): try: - result = subprocess.run( - ["agent-browser", "--session", "gs25-native-auth", "get", "url"], - capture_output=True, text=True, timeout=15, check=True, - ) - url = urllib.parse.urlsplit(result.stdout.strip()) + if sys.argv[1:] == ["--manual"]: + with warnings.catch_warnings(): + warnings.simplefilter("error", getpass.GetPassWarning) + raw_url = getpass.getpass("GS 반환 주소 (숨김 입력): ") + elif not sys.argv[1:]: + result = subprocess.run( + ["agent-browser", "--session", "gs25-native-auth", "get", "url"], + capture_output=True, text=True, timeout=15, check=True, + ) + raw_url = result.stdout.strip() + else: + raise ValueError() + url = urllib.parse.urlsplit(raw_url) if (url.scheme != "https" or url.netloc != "b2c-bff.woodongs.com" or url.path != "/api/bff/v4/grmHub/authReturn"): raise ValueError() @@ -158,3 +173,5 @@ except Exception: ``` `valid_token`은 형태·만료만 확인한다. 서명 검증 또는 로그인 성공의 대체물이 아니다. 이후 실제 GS 재고·갱신과 운영 REST/MCP/CLI 검증까지 완료해야 한다. 파일이 이미 있으면 덮어쓰지 않고 실패한다. 실패 시 생성된 파일의 토큰을 출력하지 말고 새 작업 디렉터리에서 정상 흐름을 다시 수행한다. + +Windows 주의: 이 예제의 0600 생성 인자는 NTFS ACL 보호를 대신하지 않는다. [로그인 가이드](gs25-auth-login-guide.md#1-비공개-작업-폴더를-만들고-시작-코드를-준비합니다)대로 폴더 권한을 먼저 설정한다. Windows 로그인 전체·agent-browser subprocess·중계 권한 검사는 실환경 미검증이다. 수동 입력 경로는 agent-browser 실행 대기 문제를 로그인 단계에서 분리하기 위한 선택지다. diff --git a/docs/gs25-auth-recovery-runbook.md b/docs/gs25-auth-recovery-runbook.md index 6b28eb0d..62b3e039 100644 --- a/docs/gs25-auth-recovery-runbook.md +++ b/docs/gs25-auth-recovery-runbook.md @@ -1,6 +1,8 @@ # GS25 직접 인증 복구 운영 절차 -검증일: 2026-10-05 KST. 이 문서는 정상 네이버 로그인으로 발급받은 **GS 세션**을 Mac HTTP 중계에서 사용한 복구 절차다. 다음 에이전트는 이 문서부터 읽는다. 2026-03의 무인증 리플레이·Frida 문서는 당시 조사 기록이며 현재 운영 복구 절차가 아니다. +전체 로그인 검증일: 2026-10-05 KST. 공개 로그인 번들 재확인: 2026-10-06 KST. 이 문서는 정상 네이버 로그인으로 발급받은 **GS 세션**을 Mac HTTP 중계에서 사용한 복구 절차다. 다음 에이전트는 이 문서부터 읽는다. 2026-03의 무인증 리플레이·Frida 문서는 당시 조사 기록이며 현재 운영 복구 절차가 아니다. + +개인 사이트에서 처음 재현하거나 네이버 로그인 뒤 ‘닫기’ 화면에 멈췄다면 [단계별 로그인 가이드](gs25-auth-login-guide.md)를 먼저 읽으세요. 이 문서는 기존 Mac 중계의 세션 교체·운영 검증을 다룹니다. Windows 전체 검증 기록은 없습니다. ## 완료된 구조와 근거 @@ -26,15 +28,15 @@ REST / MCP / CLI → Worker → Access/Tunnel → Mac 편의점 중계 운영 요청 전에 저장소 `AGENTS.md`, [편의점 중계](convenience-relay.md), [관측 가이드](relay-observability.md)를 읽는다. 현재 main/배포 SHA, 작업트리 변경, 중계 health 및 비밀값을 제외한 로그를 확인한다. 공개 `/health`의 설정 여부만으로 실재고 성공을 판단하지 않는다. -| 관측 | 다음 확인 | -| --- | --- | -| 상품 검색 200, 재고 401/403 | GS 세션·만료·권한·갱신 실패. 상품 검색 성공은 재고 인증 성공이 아니다. | -| 중계 `stage: quota`, 429 | `quota.blockedBy`, `retryAfter`, `resetAt` 확인 후 해당 시각까지 기다린다. 인증을 다시 발급하거나 한도를 올리지 않는다. | -| 원본 `stage: upstream`, 429 | GS 측 제한이다. 자체 원장 429와 구분한다. | -| 502/504 | 원본 상태, JSON 계약, Tunnel/Access, 조회/갱신 기한을 분리한다. | -| Worker 500 또는 MCP `isError` | 중계 로그와 diagnostics의 원본 상태를 본다. REST의 바깥 500만 보고 인증 장애로 단정하지 않는다. | -| 공개 주소에서 Python 기본 UA만 403 | 실제 CLI/SDK와 같은 요청으로 대조한다. 당시 `daiso-cli/1.2.9` UA는 200이었다. 차단 기준을 추측해 우회하지 않는다. | -| 200인데 다른 상품/지역 | 상품 코드·상품명·좌표·필터·수량을 검증한다. HTTP 200만으로 복구 완료가 아니다. | +| 관측 | 다음 확인 | +| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| 상품 검색 200, 재고 401/403 | GS 세션·만료·권한·갱신 실패. 상품 검색 성공은 재고 인증 성공이 아니다. | +| 중계 `stage: quota`, 429 | `quota.blockedBy`, `retryAfter`, `resetAt` 확인 후 해당 시각까지 기다린다. 인증을 다시 발급하거나 한도를 올리지 않는다. | +| 원본 `stage: upstream`, 429 | GS 측 제한이다. 자체 원장 429와 구분한다. | +| 502/504 | 원본 상태, JSON 계약, Tunnel/Access, 조회/갱신 기한을 분리한다. | +| Worker 500 또는 MCP `isError` | 중계 로그와 diagnostics의 원본 상태를 본다. REST의 바깥 500만 보고 인증 장애로 단정하지 않는다. | +| 공개 주소에서 Python 기본 UA만 403 | 실제 CLI/SDK와 같은 요청으로 대조한다. 당시 `daiso-cli/1.2.9` UA는 200이었다. 차단 기준을 추측해 우회하지 않는다. | +| 200인데 다른 상품/지역 | 상품 코드·상품명·좌표·필터·수량을 검증한다. HTTP 200만으로 복구 완료가 아니다. | 69/분·100,000/일은 편의점 중계의 **합산 자체 상한**이다. 동시 원본 4, 접수 32, 전체 15초, 재고 캐시 30초를 유지한다. CU/Seven의 운영 트래픽도 같은 편의점 원장을 소비한다. 캐시 적중·동일 진행 조회 병합은 원본 소비가 없거나 1회지만 서로 다른 조건은 각각 소비한다. 인증 갱신은 별도 인증 요청이다. @@ -42,16 +44,16 @@ REST / MCP / CLI → Worker → Access/Tunnel → Mac 편의점 중계 현재 설치는 아래 경로를 사용한다. 다른 호스트에서는 실제 LaunchAgent·환경 파일을 찾아 경로를 바꾼다. -| 항목 | 현재 Mac 위치 | -| --- | --- | -| 실행 서비스 | `gui/501/page.aka.daiso-dtryx` (UID는 `id -u`로 확인) | -| LaunchAgent | `~/Library/LaunchAgents/page.aka.daiso-dtryx.plist` | -| 런타임 | `~/Library/Application Support/DaisoRelay/dtryx-runtime` | -| 환경 파일 | 같은 기본 디렉터리의 `dtryx.env` | -| GS 세션 | 같은 기본 디렉터리의 `gs25-auth.json` | -| 편의점 원장/로그 | `convenience-state/quota.json`, `convenience-state/logs/` | -| Dtryx 원장/로그 | `dtryx-state/` — 별도 유지 | -| 로컬 health | `http://127.0.0.1:4320/v1/convenience/health`, `/v1/dtryx/health` | +| 항목 | 현재 Mac 위치 | +| ---------------- | ----------------------------------------------------------------- | +| 실행 서비스 | `gui/501/page.aka.daiso-dtryx` (UID는 `id -u`로 확인) | +| LaunchAgent | `~/Library/LaunchAgents/page.aka.daiso-dtryx.plist` | +| 런타임 | `~/Library/Application Support/DaisoRelay/dtryx-runtime` | +| 환경 파일 | 같은 기본 디렉터리의 `dtryx.env` | +| GS 세션 | 같은 기본 디렉터리의 `gs25-auth.json` | +| 편의점 원장/로그 | `convenience-state/quota.json`, `convenience-state/logs/` | +| Dtryx 원장/로그 | `dtryx-state/` — 별도 유지 | +| 로컬 health | `http://127.0.0.1:4320/v1/convenience/health`, `/v1/dtryx/health` | `GS25_AUTH_SESSION_FILE`은 **Mac 환경 변수**다. Worker secret으로 넣지 않는다. 세션은 현재 실행 사용자 소유의 일반 파일, 최대 16 KiB, 권한 0600이어야 한다. 내용은 `accessToken`, `refreshToken`, `deviceId` 세 필드다. 두 토큰은 `Bearer ` 접두사를 제거한 GS JWT다. `deviceId`는 티켓 발급 때 쓴 값을 유지한다. @@ -91,7 +93,9 @@ JWT의 `exp`만 로컬에서 검사하고 토큰 자체·URL query·JWT 전체 c 3. 처음 GS bridge에서 채널이 초기화된 뒤 아래 **SPA 이동**을 수행한다. full reload로 `/bridge/naver`를 직접 열면 GS SHOP 채널로 바뀔 수 있다. ```javascript -await document.querySelector('#__nuxt').__vue_app__.config.globalProperties.$router.push('/bizmgt/signin/bridge/naver'); +await document + .querySelector('#__nuxt') + .__vue_app__.config.globalProperties.$router.push('/bizmgt/signin/bridge/naver'); ``` 4. 네이버 공식 화면에 **‘우리동네GS 로그인 중’**이 표시되는지 확인한다. GS SHOP이라면 진행하지 말고 초기 bridge부터 새 티켓으로 다시 시작한다. @@ -106,9 +110,11 @@ CLI 결과에 GS 티켓 URL이 포함될 수 있으므로 stdout/stderr를 비 ### 3.2 ‘닫기’만 나타났을 때 정상 GS 코드 교환 완료 +[사람이 따라가는 로그인 가이드 4단계](gs25-auth-login-guide.md#4-닫기-화면에서-gs-코드-교환을-실행합니다)에 컨텍스트 검사·최소 교환 스크립트·현재 번들/export 탐색·실제 요청 확인 방법을 정리했습니다. agent-browser가 멈추면 일반 Chrome 개발자 도구 경로를 사용할 수 있습니다. + 당시 최초 bridge의 네이버 SDK 호출 전제 때문에 일반 브라우저에서는 ‘네이버 통신 장애’가 발생했다. 게시된 `/bizmgt/signin/bridge/naver` OAuth 라우트를 사용하면 정상 네이버 로그인에 도달했다. 로그인 후 허브 callback의 `code`/`state`는 네이버 승인 코드이며 **GS 재고 토큰이 아니다**. -callback에 ‘닫기’만 나타나는 것은 native 부모에 결과를 전달하려는 앱 흐름이었다. 당시 공식 GS 웹의 로그인 모듈로 정상 교환을 완료했다. 아래는 **2026-10-05에 실제 성공한 호출**이다. 모듈 해시/내보내기 이름은 배포마다 바뀔 수 있다. +callback에 ‘닫기’만 나타나는 것은 native 부모에 결과를 전달하려는 앱 흐름이었다. 당시 공식 GS 웹의 로그인 모듈로 정상 교환을 완료했다. 아래는 **2026-10-05에 실제 성공한 호출**이다. 2026-10-06 공개 번들에서도 같은 경로·export와 v2 endpoint를 확인했다. 이는 새 계정 로그인 재검증과는 다르다. 모듈 해시/내보내기 이름은 배포마다 바뀔 수 있다. ```javascript const params = new URLSearchParams(location.search); @@ -116,9 +122,12 @@ const vue = document.querySelector('#__nuxt').__vue_app__; const nuxt = vue.config.globalProperties.$nuxt; const mod = await import('/_nuxt/login.BDscyujx.js'); const api = nuxt.runWithContext(() => mod.u()); -await nuxt.runWithContext(() => api.loginNaver({ - code: params.get('code'), state: params.get('state'), -})); +await nuxt.runWithContext(() => + api.loginNaver({ + code: params.get('code'), + state: params.get('state'), + }), +); ``` 이 호출은 공식 `POST /api/hub/bridge/v2/login/naver`로 승인 코드를 교환하고 GS `authReturn`으로 정상 리다이렉트한다. `code`/`state`를 출력하거나 재사용하지 않는다. 만료/이미 사용된 코드면 새 티켓부터 다시 로그인한다. @@ -129,7 +138,7 @@ await nuxt.runWithContext(() => api.loginNaver({ 최종 주소의 origin은 `https://b2c-bff.woodongs.com`, path는 `/api/bff/v4/grmHub/authReturn`이어야 한다. query의 `result=Y`, `resultCode=0000`, 비어 있지 않은 `token`/`refresh`를 검증한다. 네이버 callback이나 GS SHOP 반환을 GS 성공으로 오인하지 않는다. -[참조 절차](gs25-auth-recovery-reference.md)의 저장 코드는 `agent-browser get url` stdout을 메모리에서 파싱해 URL을 출력하지 않고, 티켓의 `deviceId`와 GS 두 토큰만 0600 새 파일에 저장한다. 실패하면 성공 주소를 추측하지 않는다. capture 파일이 이미 있으면 삭제 덮어쓰기 대신 별도 새 작업 디렉터리를 사용한다. +[참조 절차](gs25-auth-recovery-reference.md)의 저장 코드는 `agent-browser get url` stdout 또는 `--manual` 숨김 입력을 메모리에서 파싱해 URL을 출력하지 않고, 티켓의 `deviceId`와 GS 두 토큰만 0600 새 파일에 저장한다. 실패하면 성공 주소를 추측하지 않는다. capture 파일이 이미 있으면 삭제 덮어쓰기 대신 별도 새 작업 디렉터리를 사용한다. ## 4. 재고·갱신을 검증한 뒤 운영 파일을 교체한다