전화번호 로그인 요청/응답 처리

This commit is contained in:
2026-04-10 11:01:16 +09:00
parent 8fe4caa9a9
commit 022594f8dc
4 changed files with 216 additions and 99 deletions

281
server.js
View File

@@ -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