diff --git a/README.md b/README.md index 274db42..f8d24da 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ## 주요 특징 - **Headless 인증**: IdP가 제공하는 UI를 거치지 않고, RP(데모 앱)가 사용자 자격 증명을 직접 받아 백채널로 인증을 수행합니다. -- **동적 UI 전환**: 입력값(숫자 vs 문자)을 실시간으로 분석하여 '전화번호 인증' 또는 '사번 로그인' 모드로 자동 전환됩니다. +- **동적 UI 전환**: 입력값(숫자 vs 문자)을 실시간으로 분석하여 '전화번호 SSO 인증' 또는 '사번 로그인' 모드로 자동 전환됩니다. - **Trusted RP 구현**: - **OIDC Discovery**: `sso-test.hmac.kr`의 메타데이터를 동적으로 로드합니다. - **JWKS Endpoint**: 서버 시작 시 생성된 RSA 공개키를 `/.well-known/jwks.json`을 통해 서빙하여 IdP와의 신뢰 관계를 형성합니다. @@ -29,6 +29,9 @@ CLIENT_ID=15cfb85c-f75f-4b51-a13d-d04f87d39739 ISSUER=https://sso-test.hmac.kr/oidc REDIRECT_URI=http://localhost:3000/callback JWKS_URI=http://localhost:3000/.well-known/jwks.json +# 필요 시 전화번호용 headless link endpoint를 별도로 덮어쓸 수 있음 +PHONE_HEADLESS_LINK_INIT_ENDPOINT= +PHONE_HEADLESS_LINK_POLL_ENDPOINT= ``` ### 2. 의존성 설치 @@ -58,12 +61,14 @@ npm start ### 1. 입력값 분류 (Classify Input) 사용자가 입력한 값이 숫자만 포함되어 있으면 전화번호(`phone`) 모드로, 문자가 포함되어 있으면 사번(`employee`) 모드로 인식합니다. -- **Phone**: 인증 링크 발송 시뮬레이션 실행. +- **Phone**: 전화번호를 SSO headless 인증 흐름에 전달합니다. - **Employee**: 비밀번호 입력란 노출 및 OIDC Password Grant 요청 실행. -### 2. OIDC Password Grant (Real Communication) -데모 앱은 사용자로부터 받은 `loginId`와 `password`를 SSO 서버의 토큰 엔드포인트로 직접 전달합니다. +### 2. SSO Headless 인증 (Real Communication) +데모 앱은 사용자로부터 받은 식별자와 자격 증명을 SSO 서버의 headless 인증 엔드포인트로 직접 전달합니다. - SSO 서버가 해당 방식을 허용하도록 설정되어 있어야 하며, 화이트리스트에 등록된 `REDIRECT_URI`와 일치해야 합니다. +- 전화번호 로그인은 `POST /api/v1/auth/headless/link/init`로 링크를 발송한 뒤 `POST /api/v1/auth/headless/link/poll`로 승인 완료를 기다리는 흐름입니다. +- 필요하면 `PHONE_HEADLESS_LINK_INIT_ENDPOINT`와 `PHONE_HEADLESS_LINK_POLL_ENDPOINT`로 오버라이드할 수 있습니다. ## 라이선스 이 프로젝트는 내부 테스트 및 데모 목적으로 제작되었습니다. diff --git a/public/app.js b/public/app.js index d080eeb..e8f5590 100644 --- a/public/app.js +++ b/public/app.js @@ -43,10 +43,10 @@ function updateUI() { } passwordField.classList.add('hidden'); - inputHint.textContent = '전화번호가 확인되었습니다. 인증링크 발송 흐름을 사용할 수 있습니다.'; + inputHint.textContent = '전화번호가 확인되었습니다. SSO 인증링크를 발송할 수 있습니다.'; submitButton.textContent = '인증링크 발송'; submitButton.disabled = value.replace(/\D/g, '').length < 10; - statusText.textContent = '인증링크 발송 단계를 준비했습니다.'; + statusText.textContent = '전화번호 기반 SSO 인증 단계를 준비했습니다.'; } else if (mode === 'employee') { passwordField.classList.remove('hidden'); inputHint.textContent = 'ID 입력이 확인되었습니다. 비밀번호를 입력해 로그인하세요.'; @@ -74,6 +74,9 @@ loginForm.addEventListener('submit', async (e) => { submitButton.disabled = true; const originalBtnText = submitButton.textContent; submitButton.textContent = '처리 중...'; + if (currentMode === 'phone') { + statusText.textContent = '휴대폰으로 인증링크를 발송하고 승인 완료를 기다리고 있습니다.'; + } try { let response; @@ -98,11 +101,13 @@ loginForm.addEventListener('submit', async (e) => { statusDisplay.classList.add('hidden'); successPanel.classList.remove('hidden'); - successTitle.textContent = currentMode === 'phone' ? '인증링크 발송 완료' : '로그인 성공'; - successDescription.textContent = currentMode === 'phone' - ? `${identifier} 번호로 인증 링크를 보냈습니다.` - : `${identifier} 계정으로 접속되었습니다. 잠시 후 홈 화면으로 이동합니다...`; - + if (currentMode === 'phone') { + successTitle.textContent = '전화번호 인증 완료'; + successDescription.textContent = result.message || `${identifier} 번호의 SSO 인증이 완료되었습니다.`; + } else { + successTitle.textContent = '로그인 성공'; + successDescription.textContent = `${identifier} 계정으로 접속되었습니다. 잠시 후 홈 화면으로 이동합니다...`; + } if (result.redirectTo) { setTimeout(() => { window.location.href = result.redirectTo; diff --git a/public/index.html b/public/index.html index 136e749..607eec5 100644 --- a/public/index.html +++ b/public/index.html @@ -11,7 +11,7 @@

PM 데모 로그인

-

숫자만 입력하면 전화번호 인증, 그 외 입력은 ID/PW 로그인으로 전환됩니다.

+

숫자만 입력하면 전화번호 SSO 인증, 그 외 입력은 ID/PW 로그인으로 전환됩니다.

diff --git a/server.js b/server.js index c0f1562..7847c6a 100644 --- a/server.js +++ b/server.js @@ -99,12 +99,109 @@ async function buildClientAssertion(clientId, audience) { return jwt; } -// 로그인 API (PM-fork 방식: Headless Password Login Flow) -app.post('/api/login', async (req, res) => { - const { loginId, password } = req.body; +function buildHeadlessAudienceCandidates(endpoint) { + const audiences = [endpoint]; + try { + const pathOnly = new URL(endpoint).pathname; + if (!audiences.includes(pathOnly)) audiences.push(pathOnly); + } catch (e) {} + if (!audiences.includes(process.env.ISSUER)) audiences.push(process.env.ISSUER); + return audiences; +} + +async function postHeadlessRequest(endpoint, body, label) { + const audiences = buildHeadlessAudienceCandidates(endpoint); + let payload; + let response; + + for (const aud of audiences) { + console.log(` --> 보안 검증 중 (${label}, 대상: ${aud})`); + const clientAssertion = await buildClientAssertion(process.env.CLIENT_ID, aud); + + response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...body, + client_assertion: clientAssertion, + }), + }); + + const rawBody = await response.text(); + if (!rawBody.trim()) { + payload = {}; + } else { + try { + payload = JSON.parse(rawBody); + } catch (parseErr) { + payload = { rawBody }; + } + } + if (payload === null) { + payload = {}; + } + if (response.ok) break; + + const errorMsg = String(payload.error || payload.error_description || payload.rawBody || '').toLowerCase(); + if (!errorMsg.includes('audience mismatch')) break; + console.log(` --> 보안 검증 대상을 조정하여 다시 시도합니다...`); + } + + return { response, payload }; +} + +async function pollHeadlessLink(endpoint, pendingRef, initialIntervalSeconds = 2) { + const timeoutMs = 3 * 60 * 1000; + const startedAt = Date.now(); + let intervalSeconds = Math.max(1, Number(initialIntervalSeconds) || 2); + + while (Date.now() - startedAt < timeoutMs) { + const { response, payload } = await postHeadlessRequest( + endpoint, + { + client_id: process.env.CLIENT_ID, + pendingRef, + }, + 'headless link poll', + ); + + if (response.ok && payload.redirectTo) { + return payload; + } + + const code = String(payload.code || payload.error || '').toLowerCase(); + if (code === 'authorization_pending' || code === 'slow_down') { + const nextInterval = Number(payload.interval); + if (Number.isFinite(nextInterval) && nextInterval > 0) { + intervalSeconds = nextInterval; + } else if (code === 'slow_down') { + intervalSeconds += 1; + } + await delay(intervalSeconds * 1000); + continue; + } + + if (code === 'expired_token') { + throw new Error('전화번호 인증 링크가 만료되었습니다.'); + } + + if (response.ok) { + throw new Error(payload.message || payload.error_description || '전화번호 인증 상태를 확인할 수 없습니다.'); + } + + throw new Error(payload.message || payload.error_description || payload.error || '전화번호 인증 상태 확인 실패'); + } + + throw new Error('전화번호 인증 시간이 초과되었습니다.'); +} + +async function runHeadlessSsoLogin({ req, res, identifier, password, mode }) { const crypto = require('crypto'); + const isPhoneMode = mode === 'phone'; + const identifierLabel = isPhoneMode ? '전화번호' : '사번'; + console.log(`\n================================================================`); - console.log(`[로그인 시작] 사번 ${loginId} 님에 대한 안전한 로그인을 시도합니다.`); + console.log(`[로그인 시작] ${identifierLabel} ${identifier} 님에 대한 안전한 로그인을 시도합니다.`); console.log(`================================================================`); try { @@ -112,8 +209,7 @@ app.post('/api/login', async (req, res) => { const authEndpoint = issuerInfo.authorization_endpoint; const tokenEndpoint = issuerInfo.token_endpoint; const redirectUri = process.env.REDIRECT_URI; - - // 1. Authorization Flow 시작 -> login_challenge 획득 + console.log(`[1단계: 신호 요청] SSO 서버로부터 인증을 위한 고유 신호(Challenge)를 받아오고 있습니다.`); const state = crypto.randomUUID(); const nonce = crypto.randomUUID(); @@ -127,7 +223,7 @@ app.post('/api/login', async (req, res) => { const authRes = await fetch(authUrl.toString(), { redirect: 'manual' }); const location = authRes.headers.get('location') || authRes.url; - + let loginChallenge; try { loginChallenge = new URL(location).searchParams.get('login_challenge'); @@ -139,59 +235,88 @@ app.post('/api/login', async (req, res) => { throw new Error(`인증 신호를 받지 못했습니다. (URL: ${location})`); } console.log(` --> 신호 획득 완료: ${loginChallenge.substring(0, 10)}...`); - + let cookies = authRes.headers.get('set-cookie') || ''; - - // 2. Headless Password API 호출 (client_assertion 사용) - console.log(`[2단계: 본인 인증] 사번과 비밀번호를 보안 도장(Digital Signature)과 함께 서버에 전달합니다.`); - const headlessEndpoint = process.env.ISSUER.replace('/oidc', '/api/v1/auth/headless/password/login'); - - const audiences = [headlessEndpoint]; - try { - const pathOnly = new URL(headlessEndpoint).pathname; - if (!audiences.includes(pathOnly)) audiences.push(pathOnly); - } catch (e) {} - if (!audiences.includes(process.env.ISSUER)) audiences.push(process.env.ISSUER); - let loginPayload; - let loginRes; + let redirectTo; - for (const aud of audiences) { - console.log(` --> 보안 검증 중 (대상: ${aud})`); - const clientAssertion = await buildClientAssertion(process.env.CLIENT_ID, aud); - - loginRes = await fetch(headlessEndpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - client_id: process.env.CLIENT_ID, - client_assertion: clientAssertion, - login_challenge: loginChallenge, - loginId: loginId, - password: password - }) - }); - - loginPayload = await loginRes.json(); - if (loginRes.ok) break; - - const errorMsg = String(loginPayload.error || loginPayload.error_description || "").toLowerCase(); - if (!errorMsg.includes('audience mismatch')) break; - console.log(` --> 보안 검증 대상을 조정하여 다시 시도합니다...`); + if (isPhoneMode) { + console.log(`[2단계: 본인 인증] 전화번호 SSO 링크 발송을 요청합니다.`); + const linkInitEndpoint = process.env.PHONE_HEADLESS_LINK_INIT_ENDPOINT + || process.env.ISSUER.replace('/oidc', '/api/v1/auth/headless/link/init'); + const linkPollEndpoint = process.env.PHONE_HEADLESS_LINK_POLL_ENDPOINT + || process.env.ISSUER.replace('/oidc', '/api/v1/auth/headless/link/poll'); + + const initBody = { + client_id: process.env.CLIENT_ID, + login_challenge: loginChallenge, + loginId: identifier, + }; + + const { response: initRes, payload: initPayload } = await postHeadlessRequest( + linkInitEndpoint, + initBody, + 'headless link init', + ); + + if (!initRes.ok) { + console.error(' [실패] 서버에서 전화번호 링크 발송을 거부했습니다.'); + console.error(' [상세 사유]:', JSON.stringify(initPayload, null, 2)); + if (initRes.status === 503 && Object.keys(initPayload).length === 0) { + throw new Error('전화번호 인증 요청이 SSO 서버에서 503으로 거부되었습니다. (응답 본문이 비어 있음)'); + } + throw new Error( + initPayload.message || + initPayload.error_description || + initPayload.rawBody || + `전화번호 인증 요청 거부됨 (${initRes.status})` + ); } - - if (!loginRes.ok) { - console.error(' [실패] 서버에서 인증을 거부했습니다.'); - console.error(' [상세 사유]:', JSON.stringify(loginPayload, null, 2)); - throw new Error(loginPayload.message || loginPayload.error_description || 'ID/PW 인증 거부됨'); + + const pendingRef = initPayload.pendingRef; + if (!pendingRef) { + throw new Error('전화번호 인증 요청 응답에 pendingRef가 없습니다.'); + } + + console.log(` --> 링크 발송 완료, 승인 대기 중입니다. (pendingRef: ${pendingRef})`); + const pollResult = await pollHeadlessLink(linkPollEndpoint, pendingRef, initPayload.interval); + redirectTo = pollResult.redirectTo; + } else { + console.log(`[2단계: 본인 인증] 사번과 비밀번호를 보안 도장(Digital Signature)과 함께 서버에 전달합니다.`); + const defaultHeadlessEndpoint = process.env.ISSUER.replace('/oidc', '/api/v1/auth/headless/password/login'); + const headlessEndpoint = defaultHeadlessEndpoint; + + const requestBody = { + client_id: process.env.CLIENT_ID, + login_challenge: loginChallenge, + loginId: identifier, + password: password, + }; + + const { response: loginRes, payload: loginPayload } = await postHeadlessRequest( + headlessEndpoint, + requestBody, + 'headless password login', + ); + + if (!loginRes.ok) { + console.error(' [실패] 서버에서 인증을 거부했습니다.'); + console.error(' [상세 사유]:', JSON.stringify(loginPayload, null, 2)); + throw new Error( + loginPayload.message || + loginPayload.error_description || + loginPayload.rawBody || + `${identifierLabel} 인증 거부됨 (${loginRes.status})` + ); + } + + redirectTo = loginPayload.redirectTo; + if (!redirectTo) throw new Error('인증 후 리다이렉트 정보를 받지 못했습니다.'); + + const newCookies = loginRes.headers.get('set-cookie'); + if (newCookies) cookies = cookies ? `${cookies}; ${newCookies}` : newCookies; + console.log(` --> 본인 인증 성공! 다음 단계로 이동합니다.`); } - - const redirectTo = loginPayload.redirectTo; - if (!redirectTo) throw new Error('인증 후 리다이렉트 정보를 받지 못했습니다.'); - - let newCookies = loginRes.headers.get('set-cookie'); - if (newCookies) cookies = cookies ? `${cookies}; ${newCookies}` : newCookies; - console.log(` --> 본인 인증 성공! 다음 단계로 이동합니다.`); // 3. 리다이렉트를 따라가서 Authorization Code 획득 (Consent 자동 승인 포함) console.log(`[3단계: 권한 획득] 인증 완료 후 필요한 권한(프로필 등)을 최종적으로 승인받는 과정입니다.`); @@ -294,9 +419,9 @@ app.post('/api/login', async (req, res) => { const idTokenPayload = JSON.parse(Buffer.from(tokenData.id_token.split('.')[1], 'base64url').toString()); const userData = { id: idTokenPayload.sub, - name: idTokenPayload.name || idTokenPayload.preferred_username || loginId, + name: idTokenPayload.name || idTokenPayload.preferred_username || identifier, loginTime: new Date().toLocaleString('ko-KR'), - method: 'PM-fork 방식(보안 강화형)' + method: isPhoneMode ? '전화번호 SSO 인증' : '사번 SSO 인증' }; console.log(`\n[5단계: 로그인 완료] 모든 과정이 성공했습니다!`); @@ -312,47 +437,29 @@ app.post('/api/login', async (req, res) => { user: userData, redirectTo: '/home.html' }); - } catch (err) { console.error(`\n[!!! 로그인 실패 !!!]`); console.error(` - 사유: ${err.message}`); console.log(`================================================================\n`); res.status(500).json({ success: false, message: `로그인 실패: ${err.message}` }); } +} + +// 로그인 API (PM-fork 방식: Headless Password Login Flow) +app.post('/api/login', async (req, res) => { + const { loginId, password } = req.body; + return runHeadlessSsoLogin({ req, res, identifier: loginId, password, mode: 'employee' }); }); -// 인증 링크 발송 API +// 전화번호 SSO 인증 요청 API app.post('/api/send-link', async (req, res) => { const { phoneNumber } = req.body; - console.log(`\n[전화번호 인증 요청] 번호: ${phoneNumber}`); - - try { - console.log(` - 해당 번호로 일회성 인증 링크를 생성하고 있습니다.`); - await delay(1000); - - if (phoneNumber) { - console.log(` - [성공] 가상의 인증 링크가 발송되었습니다.`); - - const userData = { - id: phoneNumber, - name: '휴대폰 사용자', - loginTime: new Date().toLocaleString('ko-KR'), - method: '전화번호 인증(데모)' - }; - req.session.user = userData; - - res.json({ - success: true, - message: '인증 링크가 발송되었으며, 테스트를 위해 인증이 즉시 완료되었습니다.', - redirectTo: '/home.html' - }); - } else { - res.status(400).json({ success: false, message: '전화번호를 입력해주세요.' }); - } - } catch (err) { - console.error('전화번호 인증 오류:', err); - res.status(500).json({ success: false, message: '인증 과정 중 오류가 발생했습니다.' }); + if (!phoneNumber) { + return res.status(400).json({ success: false, message: '전화번호를 입력해주세요.' }); } + + console.log(`\n[전화번호 SSO 인증 요청] 번호: ${phoneNumber}`); + return runHeadlessSsoLogin({ req, res, identifier: phoneNumber, mode: 'phone' }); }); // 내 정보 확인 API