From a288b406ac9cea01b117e7c82255abd687f0b6d4 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Mon, 30 Mar 2026 09:47:36 +0900 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=EC=95=A0=ED=94=8C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20nonce=EB=A5=BC=20iOS=EC=99=80=20=EB=8F=99?= =?UTF-8?q?=EC=9D=BC=ED=95=98=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260330_애플로그인추가.md | 17 ++++++++- src/views/Login/Login.vue | 67 ++++++++++++++++++++++++--------- 2 files changed, 64 insertions(+), 20 deletions(-) diff --git a/docs/20260330_애플로그인추가.md b/docs/20260330_애플로그인추가.md index 5751939..dfbde32 100644 --- a/docs/20260330_애플로그인추가.md +++ b/docs/20260330_애플로그인추가.md @@ -10,6 +10,7 @@ - [x] 환경변수 설정 추가 가이드: VUE_APP_APPLE_CLIENT_ID, VUE_APP_APPLE_REDIRECT_URI (.env.*) - [x] 서버 엔드포인트 요청 형식 정합화: POST /member/login/apple 본문에 container/identityToken/nonce 전달 - [x] 트러블슈팅 가이드 추가: 403 에러 및 200 응답 후 중단 현상 원인 분석 +- [x] nonce 정합화 보완: iOS와 동일한 raw nonce 생성 규칙 + SHA-256(Base64URL) 적용 - [ ] QA/수동검증: 실제 Apple 계정으로 팝업 로그인 플로우 확인 및 토큰 교환 성공 확인(테스트 서버 배포 후) ## 범위 변경 사항 @@ -45,10 +46,10 @@ Content-Type: application/json ``` - container 값은 web로 고정합니다. -- nonce는 프론트에서 매 로그인 시점에 보안 난수로 생성합니다(raw). Apple에 전달하는 nonce는 SHA-256 해시(hex)로 보냅니다. 서버에서는 raw nonce를 받아 동일한 방식으로 해시하여 id_token 내 nonce와 일치하는지 검증합니다. +- nonce는 프론트에서 매 로그인 시점에 보안 난수로 생성합니다(raw). Apple에 전달하는 nonce는 SHA-256 해시(Base64URL)로 보냅니다. 서버에서는 raw nonce를 받아 동일한 방식으로 해시하여 id_token 내 nonce와 일치하는지 검증합니다. 코드 반영 사항: -- src/views/Login/Login.vue: 로그인 직전 raw nonce 생성 → SHA-256 해시(hex)를 AppleID.auth.init의 nonce로 설정 → signIn 후 id_token과 raw nonce를 서버로 전송. +- src/views/Login/Login.vue: 로그인 직전 raw nonce 생성(iOS와 동일한 문자셋/샘플링 규칙) → SHA-256 해시(Base64URL)를 AppleID.auth.init의 nonce로 설정 → signIn 후 id_token과 raw nonce를 서버로 전송. - src/api/member.js: Authorization 헤더 제거. 요청 본문으로 { container: 'web', identityToken, nonce, ... } 전송. ## 환경변수 설정(redirect URI, apple_client_id) @@ -185,5 +186,17 @@ Content-Type: application/json - 조치: Apple Return URLs에 등록된 도메인으로 앱을 실제 배포하여 동일 오리진 환경에서 검증할 것을 권장함 - 결과: 테스트 서버 배포 시 오리진 불일치 문제가 해결되어 정상적인 Promise resolve 및 서버 전송이 가능해짐 +### 4차 수정(nonce 불일치 이슈 정합화) +- 무엇을: Web의 nonce 생성/해시 로직을 iOS 앱에서 사용하는 규칙과 동일하게 수정 +- 왜: Apple 로그인 시 서버 검증 단계에서 `invalid nonce`가 발생하는 문제를 해소하기 위해 +- 어떻게: + - 코드: `src/views/Login/Login.vue` + - `generateNonce`: iOS와 동일한 문자셋(`0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._`) + 16바이트 샘플링 방식으로 raw nonce 생성 + - `sha256Hex`: SHA-256 결과를 hex가 아닌 Base64URL(`+`→`-`, `/`→`_`, `=` 제거)로 인코딩하도록 변경 + - 해시 기능 미지원 브라우저에서는 예외를 발생시켜 잘못된 nonce 전송을 방지 + - 검증 명령: + - `npm run lint` → 성공 + - 결과: Apple SDK에 전달되는 nonce 포맷이 iOS와 동일해져 서버 nonce 검증 정합성 개선 + ## 정정/메모 - 초기 계획 문서는 구현 직후 정리되었습니다. 향후 환경변수/서버 설정 완료 시 체크박스 및 검증 기록을 추가 업데이트하세요. diff --git a/src/views/Login/Login.vue b/src/views/Login/Login.vue index 1562d19..b8b1bce 100644 --- a/src/views/Login/Login.vue +++ b/src/views/Login/Login.vue @@ -104,30 +104,61 @@ export default { }, methods: { - // 보안을 위한 랜덤 nonce 생성(Base64URL) + // iOS randomNonceString과 동일한 규칙으로 raw nonce 생성 generateNonce(length = 32) { - try { - const bytes = new Uint8Array(length); - (window.crypto || window.msCrypto).getRandomValues(bytes); - let binary = ''; - for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); - // base64url 인코딩 - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); - } catch (e) { - // crypto 사용 불가 시 폴백(난수품질 낮음) - return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2); + if (length <= 0) { + throw new Error('Nonce length must be greater than zero.'); } + + const crypto = window.crypto || window.msCrypto; + const charset = '0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._'; + let result = ''; + let remainingLength = length; + + while (remainingLength > 0) { + const randoms = new Uint8Array(16); + + try { + crypto.getRandomValues(randoms); + } catch (e) { + // iOS 코드와 동일하게 난수 생성 실패 시 UUID 폴백 + if (crypto && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11) + .replace(/[018]/g, c => ( + c ^ ((Math.random() * 16) >> (c / 4)) + ).toString(16)); + } + + for (let i = 0; i < randoms.length && remainingLength > 0; i++) { + if (randoms[i] < charset.length) { + result += charset[randoms[i]]; + remainingLength -= 1; + } + } + } + + return result; }, async sha256Hex(message) { - if (window.crypto && window.crypto.subtle && typeof TextEncoder !== 'undefined') { - const enc = new TextEncoder().encode(message); - const buf = await window.crypto.subtle.digest('SHA-256', enc); - const arr = Array.from(new Uint8Array(buf)); - return arr.map(b => b.toString(16).padStart(2, '0')).join(''); + if (!(window.crypto && window.crypto.subtle && typeof TextEncoder !== 'undefined')) { + throw new Error('SHA-256 해시를 지원하지 않는 환경입니다.'); } - // 폴백: 해시 불가 시 원본 반환(해시 미적용) - return message; + + const enc = new TextEncoder().encode(message); + const buf = await window.crypto.subtle.digest('SHA-256', enc); + const arr = new Uint8Array(buf); + let binary = ''; + for (let i = 0; i < arr.length; i++) { + binary += String.fromCharCode(arr[i]); + } + + return btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); }, initAppleLogin() {