전화번호 로그인 요청/응답 처리
This commit is contained in:
13
README.md
13
README.md
@@ -5,7 +5,7 @@
|
|||||||
## 주요 특징
|
## 주요 특징
|
||||||
|
|
||||||
- **Headless 인증**: IdP가 제공하는 UI를 거치지 않고, RP(데모 앱)가 사용자 자격 증명을 직접 받아 백채널로 인증을 수행합니다.
|
- **Headless 인증**: IdP가 제공하는 UI를 거치지 않고, RP(데모 앱)가 사용자 자격 증명을 직접 받아 백채널로 인증을 수행합니다.
|
||||||
- **동적 UI 전환**: 입력값(숫자 vs 문자)을 실시간으로 분석하여 '전화번호 인증' 또는 '사번 로그인' 모드로 자동 전환됩니다.
|
- **동적 UI 전환**: 입력값(숫자 vs 문자)을 실시간으로 분석하여 '전화번호 SSO 인증' 또는 '사번 로그인' 모드로 자동 전환됩니다.
|
||||||
- **Trusted RP 구현**:
|
- **Trusted RP 구현**:
|
||||||
- **OIDC Discovery**: `sso-test.hmac.kr`의 메타데이터를 동적으로 로드합니다.
|
- **OIDC Discovery**: `sso-test.hmac.kr`의 메타데이터를 동적으로 로드합니다.
|
||||||
- **JWKS Endpoint**: 서버 시작 시 생성된 RSA 공개키를 `/.well-known/jwks.json`을 통해 서빙하여 IdP와의 신뢰 관계를 형성합니다.
|
- **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
|
ISSUER=https://sso-test.hmac.kr/oidc
|
||||||
REDIRECT_URI=http://localhost:3000/callback
|
REDIRECT_URI=http://localhost:3000/callback
|
||||||
JWKS_URI=http://localhost:3000/.well-known/jwks.json
|
JWKS_URI=http://localhost:3000/.well-known/jwks.json
|
||||||
|
# 필요 시 전화번호용 headless link endpoint를 별도로 덮어쓸 수 있음
|
||||||
|
PHONE_HEADLESS_LINK_INIT_ENDPOINT=
|
||||||
|
PHONE_HEADLESS_LINK_POLL_ENDPOINT=
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 의존성 설치
|
### 2. 의존성 설치
|
||||||
@@ -58,12 +61,14 @@ npm start
|
|||||||
|
|
||||||
### 1. 입력값 분류 (Classify Input)
|
### 1. 입력값 분류 (Classify Input)
|
||||||
사용자가 입력한 값이 숫자만 포함되어 있으면 전화번호(`phone`) 모드로, 문자가 포함되어 있으면 사번(`employee`) 모드로 인식합니다.
|
사용자가 입력한 값이 숫자만 포함되어 있으면 전화번호(`phone`) 모드로, 문자가 포함되어 있으면 사번(`employee`) 모드로 인식합니다.
|
||||||
- **Phone**: 인증 링크 발송 시뮬레이션 실행.
|
- **Phone**: 전화번호를 SSO headless 인증 흐름에 전달합니다.
|
||||||
- **Employee**: 비밀번호 입력란 노출 및 OIDC Password Grant 요청 실행.
|
- **Employee**: 비밀번호 입력란 노출 및 OIDC Password Grant 요청 실행.
|
||||||
|
|
||||||
### 2. OIDC Password Grant (Real Communication)
|
### 2. SSO Headless 인증 (Real Communication)
|
||||||
데모 앱은 사용자로부터 받은 `loginId`와 `password`를 SSO 서버의 토큰 엔드포인트로 직접 전달합니다.
|
데모 앱은 사용자로부터 받은 식별자와 자격 증명을 SSO 서버의 headless 인증 엔드포인트로 직접 전달합니다.
|
||||||
- SSO 서버가 해당 방식을 허용하도록 설정되어 있어야 하며, 화이트리스트에 등록된 `REDIRECT_URI`와 일치해야 합니다.
|
- 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`로 오버라이드할 수 있습니다.
|
||||||
|
|
||||||
## 라이선스
|
## 라이선스
|
||||||
이 프로젝트는 내부 테스트 및 데모 목적으로 제작되었습니다.
|
이 프로젝트는 내부 테스트 및 데모 목적으로 제작되었습니다.
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ function updateUI() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
passwordField.classList.add('hidden');
|
passwordField.classList.add('hidden');
|
||||||
inputHint.textContent = '전화번호가 확인되었습니다. 인증링크 발송 흐름을 사용할 수 있습니다.';
|
inputHint.textContent = '전화번호가 확인되었습니다. SSO 인증링크를 발송할 수 있습니다.';
|
||||||
submitButton.textContent = '인증링크 발송';
|
submitButton.textContent = '인증링크 발송';
|
||||||
submitButton.disabled = value.replace(/\D/g, '').length < 10;
|
submitButton.disabled = value.replace(/\D/g, '').length < 10;
|
||||||
statusText.textContent = '인증링크 발송 단계를 준비했습니다.';
|
statusText.textContent = '전화번호 기반 SSO 인증 단계를 준비했습니다.';
|
||||||
} else if (mode === 'employee') {
|
} else if (mode === 'employee') {
|
||||||
passwordField.classList.remove('hidden');
|
passwordField.classList.remove('hidden');
|
||||||
inputHint.textContent = 'ID 입력이 확인되었습니다. 비밀번호를 입력해 로그인하세요.';
|
inputHint.textContent = 'ID 입력이 확인되었습니다. 비밀번호를 입력해 로그인하세요.';
|
||||||
@@ -74,6 +74,9 @@ loginForm.addEventListener('submit', async (e) => {
|
|||||||
submitButton.disabled = true;
|
submitButton.disabled = true;
|
||||||
const originalBtnText = submitButton.textContent;
|
const originalBtnText = submitButton.textContent;
|
||||||
submitButton.textContent = '처리 중...';
|
submitButton.textContent = '처리 중...';
|
||||||
|
if (currentMode === 'phone') {
|
||||||
|
statusText.textContent = '휴대폰으로 인증링크를 발송하고 승인 완료를 기다리고 있습니다.';
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let response;
|
let response;
|
||||||
@@ -98,11 +101,13 @@ loginForm.addEventListener('submit', async (e) => {
|
|||||||
statusDisplay.classList.add('hidden');
|
statusDisplay.classList.add('hidden');
|
||||||
successPanel.classList.remove('hidden');
|
successPanel.classList.remove('hidden');
|
||||||
|
|
||||||
successTitle.textContent = currentMode === 'phone' ? '인증링크 발송 완료' : '로그인 성공';
|
if (currentMode === 'phone') {
|
||||||
successDescription.textContent = currentMode === 'phone'
|
successTitle.textContent = '전화번호 인증 완료';
|
||||||
? `${identifier} 번호로 인증 링크를 보냈습니다.`
|
successDescription.textContent = result.message || `${identifier} 번호의 SSO 인증이 완료되었습니다.`;
|
||||||
: `${identifier} 계정으로 접속되었습니다. 잠시 후 홈 화면으로 이동합니다...`;
|
} else {
|
||||||
|
successTitle.textContent = '로그인 성공';
|
||||||
|
successDescription.textContent = `${identifier} 계정으로 접속되었습니다. 잠시 후 홈 화면으로 이동합니다...`;
|
||||||
|
}
|
||||||
if (result.redirectTo) {
|
if (result.redirectTo) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = result.redirectTo;
|
window.location.href = result.redirectTo;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<article class="login-panel">
|
<article class="login-panel">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
<h1>PM 데모 로그인</h1>
|
<h1>PM 데모 로그인</h1>
|
||||||
<p>숫자만 입력하면 전화번호 인증, 그 외 입력은 ID/PW 로그인으로 전환됩니다.</p>
|
<p>숫자만 입력하면 전화번호 SSO 인증, 그 외 입력은 ID/PW 로그인으로 전환됩니다.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="login-form" class="login-form">
|
<form id="login-form" class="login-form">
|
||||||
|
|||||||
247
server.js
247
server.js
@@ -99,12 +99,109 @@ async function buildClientAssertion(clientId, audience) {
|
|||||||
return jwt;
|
return jwt;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 로그인 API (PM-fork 방식: Headless Password Login Flow)
|
function buildHeadlessAudienceCandidates(endpoint) {
|
||||||
app.post('/api/login', async (req, res) => {
|
const audiences = [endpoint];
|
||||||
const { loginId, password } = req.body;
|
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 crypto = require('crypto');
|
||||||
|
const isPhoneMode = mode === 'phone';
|
||||||
|
const identifierLabel = isPhoneMode ? '전화번호' : '사번';
|
||||||
|
|
||||||
console.log(`\n================================================================`);
|
console.log(`\n================================================================`);
|
||||||
console.log(`[로그인 시작] 사번 ${loginId} 님에 대한 안전한 로그인을 시도합니다.`);
|
console.log(`[로그인 시작] ${identifierLabel} ${identifier} 님에 대한 안전한 로그인을 시도합니다.`);
|
||||||
console.log(`================================================================`);
|
console.log(`================================================================`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -113,7 +210,6 @@ app.post('/api/login', async (req, res) => {
|
|||||||
const tokenEndpoint = issuerInfo.token_endpoint;
|
const tokenEndpoint = issuerInfo.token_endpoint;
|
||||||
const redirectUri = process.env.REDIRECT_URI;
|
const redirectUri = process.env.REDIRECT_URI;
|
||||||
|
|
||||||
// 1. Authorization Flow 시작 -> login_challenge 획득
|
|
||||||
console.log(`[1단계: 신호 요청] SSO 서버로부터 인증을 위한 고유 신호(Challenge)를 받아오고 있습니다.`);
|
console.log(`[1단계: 신호 요청] SSO 서버로부터 인증을 위한 고유 신호(Challenge)를 받아오고 있습니다.`);
|
||||||
const state = crypto.randomUUID();
|
const state = crypto.randomUUID();
|
||||||
const nonce = crypto.randomUUID();
|
const nonce = crypto.randomUUID();
|
||||||
@@ -142,56 +238,85 @@ app.post('/api/login', async (req, res) => {
|
|||||||
|
|
||||||
let cookies = authRes.headers.get('set-cookie') || '';
|
let cookies = authRes.headers.get('set-cookie') || '';
|
||||||
|
|
||||||
// 2. Headless Password API 호출 (client_assertion 사용)
|
let redirectTo;
|
||||||
console.log(`[2단계: 본인 인증] 사번과 비밀번호를 보안 도장(Digital Signature)과 함께 서버에 전달합니다.`);
|
|
||||||
const headlessEndpoint = process.env.ISSUER.replace('/oidc', '/api/v1/auth/headless/password/login');
|
|
||||||
|
|
||||||
const audiences = [headlessEndpoint];
|
if (isPhoneMode) {
|
||||||
try {
|
console.log(`[2단계: 본인 인증] 전화번호 SSO 링크 발송을 요청합니다.`);
|
||||||
const pathOnly = new URL(headlessEndpoint).pathname;
|
const linkInitEndpoint = process.env.PHONE_HEADLESS_LINK_INIT_ENDPOINT
|
||||||
if (!audiences.includes(pathOnly)) audiences.push(pathOnly);
|
|| process.env.ISSUER.replace('/oidc', '/api/v1/auth/headless/link/init');
|
||||||
} catch (e) {}
|
const linkPollEndpoint = process.env.PHONE_HEADLESS_LINK_POLL_ENDPOINT
|
||||||
if (!audiences.includes(process.env.ISSUER)) audiences.push(process.env.ISSUER);
|
|| process.env.ISSUER.replace('/oidc', '/api/v1/auth/headless/link/poll');
|
||||||
|
|
||||||
let loginPayload;
|
const initBody = {
|
||||||
let loginRes;
|
|
||||||
|
|
||||||
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_id: process.env.CLIENT_ID,
|
||||||
client_assertion: clientAssertion,
|
|
||||||
login_challenge: loginChallenge,
|
login_challenge: loginChallenge,
|
||||||
loginId: loginId,
|
loginId: identifier,
|
||||||
password: password
|
};
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
loginPayload = await loginRes.json();
|
const { response: initRes, payload: initPayload } = await postHeadlessRequest(
|
||||||
if (loginRes.ok) break;
|
linkInitEndpoint,
|
||||||
|
initBody,
|
||||||
|
'headless link init',
|
||||||
|
);
|
||||||
|
|
||||||
const errorMsg = String(loginPayload.error || loginPayload.error_description || "").toLowerCase();
|
if (!initRes.ok) {
|
||||||
if (!errorMsg.includes('audience mismatch')) break;
|
console.error(' [실패] 서버에서 전화번호 링크 발송을 거부했습니다.');
|
||||||
console.log(` --> 보안 검증 대상을 조정하여 다시 시도합니다...`);
|
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})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
if (!loginRes.ok) {
|
||||||
console.error(' [실패] 서버에서 인증을 거부했습니다.');
|
console.error(' [실패] 서버에서 인증을 거부했습니다.');
|
||||||
console.error(' [상세 사유]:', JSON.stringify(loginPayload, null, 2));
|
console.error(' [상세 사유]:', JSON.stringify(loginPayload, null, 2));
|
||||||
throw new Error(loginPayload.message || loginPayload.error_description || 'ID/PW 인증 거부됨');
|
throw new Error(
|
||||||
|
loginPayload.message ||
|
||||||
|
loginPayload.error_description ||
|
||||||
|
loginPayload.rawBody ||
|
||||||
|
`${identifierLabel} 인증 거부됨 (${loginRes.status})`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectTo = loginPayload.redirectTo;
|
redirectTo = loginPayload.redirectTo;
|
||||||
if (!redirectTo) throw new Error('인증 후 리다이렉트 정보를 받지 못했습니다.');
|
if (!redirectTo) throw new Error('인증 후 리다이렉트 정보를 받지 못했습니다.');
|
||||||
|
|
||||||
let newCookies = loginRes.headers.get('set-cookie');
|
const newCookies = loginRes.headers.get('set-cookie');
|
||||||
if (newCookies) cookies = cookies ? `${cookies}; ${newCookies}` : newCookies;
|
if (newCookies) cookies = cookies ? `${cookies}; ${newCookies}` : newCookies;
|
||||||
console.log(` --> 본인 인증 성공! 다음 단계로 이동합니다.`);
|
console.log(` --> 본인 인증 성공! 다음 단계로 이동합니다.`);
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 리다이렉트를 따라가서 Authorization Code 획득 (Consent 자동 승인 포함)
|
// 3. 리다이렉트를 따라가서 Authorization Code 획득 (Consent 자동 승인 포함)
|
||||||
console.log(`[3단계: 권한 획득] 인증 완료 후 필요한 권한(프로필 등)을 최종적으로 승인받는 과정입니다.`);
|
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 idTokenPayload = JSON.parse(Buffer.from(tokenData.id_token.split('.')[1], 'base64url').toString());
|
||||||
const userData = {
|
const userData = {
|
||||||
id: idTokenPayload.sub,
|
id: idTokenPayload.sub,
|
||||||
name: idTokenPayload.name || idTokenPayload.preferred_username || loginId,
|
name: idTokenPayload.name || idTokenPayload.preferred_username || identifier,
|
||||||
loginTime: new Date().toLocaleString('ko-KR'),
|
loginTime: new Date().toLocaleString('ko-KR'),
|
||||||
method: 'PM-fork 방식(보안 강화형)'
|
method: isPhoneMode ? '전화번호 SSO 인증' : '사번 SSO 인증'
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(`\n[5단계: 로그인 완료] 모든 과정이 성공했습니다!`);
|
console.log(`\n[5단계: 로그인 완료] 모든 과정이 성공했습니다!`);
|
||||||
@@ -312,47 +437,29 @@ app.post('/api/login', async (req, res) => {
|
|||||||
user: userData,
|
user: userData,
|
||||||
redirectTo: '/home.html'
|
redirectTo: '/home.html'
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`\n[!!! 로그인 실패 !!!]`);
|
console.error(`\n[!!! 로그인 실패 !!!]`);
|
||||||
console.error(` - 사유: ${err.message}`);
|
console.error(` - 사유: ${err.message}`);
|
||||||
console.log(`================================================================\n`);
|
console.log(`================================================================\n`);
|
||||||
res.status(500).json({ success: false, message: `로그인 실패: ${err.message}` });
|
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) => {
|
app.post('/api/send-link', async (req, res) => {
|
||||||
const { phoneNumber } = req.body;
|
const { phoneNumber } = req.body;
|
||||||
console.log(`\n[전화번호 인증 요청] 번호: ${phoneNumber}`);
|
if (!phoneNumber) {
|
||||||
|
return res.status(400).json({ success: false, message: '전화번호를 입력해주세요.' });
|
||||||
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: '인증 과정 중 오류가 발생했습니다.' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`\n[전화번호 SSO 인증 요청] 번호: ${phoneNumber}`);
|
||||||
|
return runHeadlessSsoLogin({ req, res, identifier: phoneNumber, mode: 'phone' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// 내 정보 확인 API
|
// 내 정보 확인 API
|
||||||
|
|||||||
Reference in New Issue
Block a user