BARON-SSO 로그인 기능 연동

This commit is contained in:
2026-06-30 15:05:24 +09:00
parent 933afb02b1
commit 792917aba6
11 changed files with 1138 additions and 3 deletions

View File

@@ -20,6 +20,13 @@ import { initGuide } from './components/Guide';
import { pcFlowModal } from './components/Modal/PCFlowModal';
import { createIcons, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw, BookOpen, Settings } from 'lucide';
interface AuthSessionResponse {
authenticated: boolean;
user: unknown;
}
let phoneLoginPollTimer: number | undefined;
// 화면 갱신 통합 핸들러
function refreshView(tab?: string) {
@@ -208,4 +215,210 @@ function initializeAppDirectly() {
renderNavigation((tab) => refreshView(tab));
}
document.addEventListener('DOMContentLoaded', initializeAppDirectly);
function showLoginScreen(errorMessage?: string) {
const loginContainer = document.getElementById('login-container');
const appLayout = document.getElementById('app-layout');
const loginError = document.getElementById('login-error');
const phoneLoginError = document.getElementById('phone-login-error');
const phoneLoginStatus = document.getElementById('phone-login-status');
const loginForm = document.getElementById('login-form') as HTMLFormElement | null;
const phoneLoginForm = document.getElementById('phone-login-form') as HTMLFormElement | null;
const loginModeTabs = document.querySelectorAll<HTMLButtonElement>('.login-mode-tab');
if (appLayout) appLayout.style.display = 'none';
if (loginContainer) loginContainer.style.display = 'flex';
const setMessage = (element: HTMLElement | null, message?: string) => {
if (!element) return;
if (message) {
element.textContent = message;
element.removeAttribute('hidden');
} else {
element.textContent = '';
element.setAttribute('hidden', 'true');
}
};
setMessage(loginError, errorMessage);
setMessage(phoneLoginError, undefined);
setMessage(phoneLoginStatus, undefined);
const switchLoginMode = (mode: 'password' | 'phone') => {
if (loginForm) loginForm.hidden = mode !== 'password';
if (phoneLoginForm) phoneLoginForm.hidden = mode !== 'phone';
loginModeTabs.forEach((tab) => tab.classList.toggle('active', tab.dataset.mode === mode));
setMessage(loginError, mode === 'password' ? errorMessage : undefined);
setMessage(phoneLoginError, mode === 'phone' ? errorMessage : undefined);
};
loginModeTabs.forEach((tab) => {
if (!tab.dataset.bound) {
tab.dataset.bound = 'true';
tab.addEventListener('click', () => switchLoginMode((tab.dataset.mode as 'password' | 'phone') || 'password'));
}
});
const clearPhonePollTimer = () => {
if (phoneLoginPollTimer) {
window.clearTimeout(phoneLoginPollTimer);
phoneLoginPollTimer = undefined;
}
};
const pollPhoneLogin = async (pendingRef: string, intervalMs: number) => {
clearPhonePollTimer();
phoneLoginPollTimer = window.setTimeout(async () => {
try {
const response = await fetch('/api/auth/headless/phone/poll', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pendingRef })
});
const payload = await response.json();
if (!response.ok) {
const message = payload.redirectTo
? '접근 권한이 없는 테넌트입니다. 관리자에게 문의하세요.'
: (payload.error || '전화번호 로그인 확인에 실패했습니다.');
clearPhonePollTimer();
setMessage(phoneLoginStatus, undefined);
setMessage(phoneLoginError, message);
return;
}
if (payload.status === 'authenticated') {
clearPhonePollTimer();
initializeAppDirectly();
return;
}
setMessage(phoneLoginStatus, '모바일에서 인증 링크를 승인하는 중입니다. 승인 후 자동으로 로그인됩니다.');
pollPhoneLogin(payload.pendingRef || pendingRef, payload.intervalMs || intervalMs);
} catch (error) {
console.error('Phone SSO poll failed:', error);
clearPhonePollTimer();
setMessage(phoneLoginStatus, undefined);
setMessage(phoneLoginError, '전화번호 로그인 확인 중 오류가 발생했습니다.');
}
}, intervalMs);
};
if (loginForm && !loginForm.dataset.bound) {
loginForm.dataset.bound = 'true';
loginForm.addEventListener('submit', async (event) => {
event.preventDefault();
const submitButton = document.getElementById('login-submit') as HTMLButtonElement | null;
const loginId = (document.getElementById('login-id') as HTMLInputElement | null)?.value.trim() || '';
const password = (document.getElementById('login-password') as HTMLInputElement | null)?.value || '';
if (!loginId || !password) {
showLoginScreen('사번과 비밀번호를 입력하세요.');
return;
}
if (submitButton) {
submitButton.disabled = true;
submitButton.textContent = '로그인 중...';
}
try {
const response = await fetch('/api/auth/headless/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ loginId, password })
});
const payload = await response.json();
if (!response.ok) {
const message = payload.redirectTo
? '접근 권한이 없는 테넌트입니다. 관리자에게 문의하세요.'
: (payload.error || '로그인에 실패했습니다.');
showLoginScreen(message);
return;
}
initializeAppDirectly();
} catch (error) {
console.error('SSO login failed:', error);
showLoginScreen('로그인 요청 처리 중 오류가 발생했습니다.');
} finally {
if (submitButton) {
submitButton.disabled = false;
submitButton.textContent = '로그인';
}
}
});
}
if (phoneLoginForm && !phoneLoginForm.dataset.bound) {
phoneLoginForm.dataset.bound = 'true';
phoneLoginForm.addEventListener('submit', async (event) => {
event.preventDefault();
const submitButton = document.getElementById('phone-login-submit') as HTMLButtonElement | null;
const loginId = (document.getElementById('phone-login-id') as HTMLInputElement | null)?.value.trim() || '';
if (!loginId) {
setMessage(phoneLoginError, '전화번호를 입력하세요.');
return;
}
clearPhonePollTimer();
setMessage(phoneLoginError, undefined);
setMessage(phoneLoginStatus, '인증 링크를 요청하는 중입니다...');
if (submitButton) {
submitButton.disabled = true;
submitButton.textContent = '링크 전송 중...';
}
try {
const response = await fetch('/api/auth/headless/phone/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ loginId })
});
const payload = await response.json();
if (!response.ok) {
setMessage(phoneLoginStatus, undefined);
setMessage(phoneLoginError, payload.error || '전화번호 로그인 시작에 실패했습니다.');
return;
}
setMessage(phoneLoginStatus, payload.message || '인증 링크를 발송했습니다. 모바일에서 승인해 주세요.');
pollPhoneLogin(payload.pendingRef, payload.intervalMs || 3000);
} catch (error) {
console.error('Phone SSO init failed:', error);
setMessage(phoneLoginStatus, undefined);
setMessage(phoneLoginError, '전화번호 로그인 요청 중 오류가 발생했습니다.');
} finally {
if (submitButton) {
submitButton.disabled = false;
submitButton.textContent = '인증 링크 보내기';
}
}
});
}
switchLoginMode('password');
}
async function bootstrapApp() {
const params = new URLSearchParams(window.location.search);
const authError = params.get('auth_error_description') || params.get('auth_error');
try {
const response = await fetch('/api/auth/session');
const sessionInfo = await response.json() as AuthSessionResponse;
if (response.ok && sessionInfo.authenticated) {
initializeAppDirectly();
return;
}
} catch (error) {
console.error('Failed to load auth session:', error);
}
showLoginScreen(authError || undefined);
}
document.addEventListener('DOMContentLoaded', bootstrapApp);

View File

@@ -53,6 +53,91 @@
gap: 1.5rem;
}
.login-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.login-mode-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.login-mode-tab {
height: 48px;
border-radius: 999px;
border: 1px solid var(--border-color);
background: var(--bg-light);
color: var(--text-muted);
font-size: 1rem;
font-weight: 700;
cursor: pointer;
transition: all 0.2s ease;
}
.login-mode-tab.active {
background: var(--text-main);
color: var(--white);
border-color: var(--text-main);
}
.login-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
color: var(--text-main);
font-size: 1rem;
font-weight: 700;
}
.login-field input {
height: 52px;
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 0 1rem;
font-size: 1rem;
color: var(--text-main);
background: var(--white);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.login-field input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 4px rgba(23, 23, 23, 0.08);
}
.login-error {
min-height: 1.5rem;
color: var(--danger);
font-size: 0.95rem;
}
.login-status {
min-height: 1.5rem;
color: var(--success);
font-size: 0.95rem;
}
.login-hint {
color: var(--text-muted);
font-size: 0.95rem;
line-height: 1.5;
}
.login-submit {
width: 100%;
margin-top: 0.5rem;
}
.login-submit[disabled] {
opacity: 0.7;
cursor: wait;
}
.role-card {
display: flex;
flex-direction: column;

96
src/utils/jwks.js Normal file
View File

@@ -0,0 +1,96 @@
import { generateKeyPairSync, createPublicKey } from 'crypto';
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { join } from 'path';
const keysPath = process.env.KEYS_PATH || join(process.cwd(), 'keys.json');
let currentKey;
let currentKeySet;
const toPublicJwk = (jwk) => ({
kty: jwk.kty,
kid: jwk.kid,
use: jwk.use,
alg: jwk.alg,
n: jwk.n,
e: jwk.e
});
const isValidPublicJwk = (jwk) => {
return Boolean(
jwk &&
jwk.kty === 'RSA' &&
typeof jwk.n === 'string' &&
typeof jwk.e === 'string' &&
!jwk.n.startsWith('LS0tLS1CRUdJTi')
);
};
const generateNewKeyPair = () => {
const { privateKey, publicKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: {
type: 'spki',
format: 'pem'
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem'
}
});
const exportedJwk = createPublicKey(publicKey).export({ format: 'jwk' });
const jwk = {
kty: 'RSA',
kid: 'jwt-key-id',
use: 'sig',
alg: 'RS256',
n: exportedJwk.n,
e: exportedJwk.e
};
const keySet = {
privateKeyPem: privateKey,
publicKeyPem: publicKey,
keys: [jwk]
};
writeFileSync(keysPath, JSON.stringify(keySet, null, 2));
currentKeySet = keySet;
return keySet;
};
const getKeySet = () => {
if (currentKeySet) {
return currentKeySet;
}
if (existsSync(keysPath)) {
const keySet = JSON.parse(readFileSync(keysPath, 'utf8'));
if (keySet && keySet.keys && keySet.keys.length > 0) {
const candidateKey = toPublicJwk(keySet.keys[0]);
if (isValidPublicJwk(candidateKey) && keySet.privateKeyPem && keySet.publicKeyPem) {
currentKeySet = {
...keySet,
keys: [candidateKey]
};
return currentKeySet;
}
}
}
return generateNewKeyPair();
};
const getSigningKey = () => {
if (currentKey) {
return currentKey;
}
currentKey = toPublicJwk(getKeySet().keys[0]);
return currentKey;
};
const getPrivateKeyPem = () => getKeySet().privateKeyPem;
export { getSigningKey, getKeySet, getPrivateKeyPem };