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

4
.env
View File

@@ -3,4 +3,8 @@ DB_PORT=3306
DB_USER=itam
DB_PASS=itam1234
DB_NAME=itam
CLIENT_ID=836cd2e1-995a-4027-bcb5-5dd9c94c2b84
ISSUER=https://sso.hmac.kr/oidc
REDIRECT_URI=http://172.16.9.44:8080/callback
JWKS_URI=http://172.16.9.44:8080/.well-known/jwks.json
PORT=3000

View File

@@ -11,9 +11,13 @@ services:
environment:
NODE_ENV: production
PORT: 3000
KEYS_PATH: /app/data/keys.json
REDIRECT_URI: ${PROD_REDIRECT_URI:-http://172.16.10.175:9090/callback}
JWKS_URI: ${PROD_JWKS_URI:-http://172.16.10.175:9090/.well-known/jwks.json}
volumes:
- ./uploads:/app/uploads
- ./map_config.json:/app/map_config.json:ro
- backend_keys:/app/data
expose:
- "3000"
restart: unless-stopped
@@ -70,4 +74,7 @@ services:
restart: always
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --collation-server=utf8mb4_unicode_ci
volumes:
backend_keys:

View File

@@ -13,11 +13,13 @@ services:
environment:
NODE_ENV: development
PORT: 3000
DB_HOST: ${DB_HOST:-172.16.8.151}
DB_HOST: ${TEST_DB_HOST:-host.docker.internal}
DB_PORT: ${DB_PORT:-3306}
DB_USER: ${DB_USER:-root}
DB_PASS: ${DB_PASS:-}
DB_NAME: ${DB_NAME:-itam}
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "3000:3000"
volumes:

View File

@@ -31,6 +31,16 @@ server {
application/json application/javascript;
gzip_min_length 1000;
# Expose the backend JWKS document for Baron SSO headless login verification.
location = /.well-known/jwks.json {
proxy_pass http://backend/.well-known/jwks.json;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
}
# Forward all app requests to the frontend container
location / {
proxy_pass http://frontend;

View File

@@ -14,6 +14,49 @@
</head>
<body>
<div class="login-layout" id="login-container" style="display: none;">
<section class="login-card">
<div class="login-header">
<img src="/image 92.png" alt="Logo" class="login-logo" />
<h2>한맥자산관리시스템</h2>
<p>Baron SSO 계정으로 로그인하세요.</p>
</div>
<div class="login-mode-tabs" id="login-mode-tabs">
<button type="button" class="login-mode-tab active" data-mode="password">사번 로그인</button>
<button type="button" class="login-mode-tab" data-mode="phone">전화번호 로그인</button>
</div>
<form id="login-form" class="login-form" data-mode="password">
<label class="login-field">
<span>사번</span>
<input id="login-id" name="loginId" type="text" autocomplete="username" placeholder="사번 입력" required />
</label>
<label class="login-field">
<span>비밀번호</span>
<input id="login-password" name="password" type="password" autocomplete="current-password" placeholder="비밀번호 입력" required />
</label>
<p id="login-error" class="login-error" hidden></p>
<button id="login-submit" type="submit" class="btn btn-primary login-submit">로그인</button>
</form>
<form id="phone-login-form" class="login-form" data-mode="phone" hidden>
<label class="login-field">
<span>전화번호</span>
<input id="phone-login-id" name="phoneLoginId" type="tel" autocomplete="tel" placeholder="휴대전화 번호 입력" required />
</label>
<p id="phone-login-hint" class="login-hint">숫자만 입력하면 됩니다. 인증 링크는 등록된 카카오톡 또는 SMS로 전송됩니다.</p>
<p id="phone-login-status" class="login-status" hidden></p>
<p id="phone-login-error" class="login-error" hidden></p>
<button id="phone-login-submit" type="submit" class="btn btn-primary login-submit">인증 링크 보내기</button>
</form>
<div class="login-footer">
Headless Baron SSO 연동을 통해 로그인 세션을 생성합니다.
</div>
</section>
</div>
<div class="app-layout" id="app-layout" style="display: none;">
<!-- Single-Line Integrated Header -->
<header class="main-header">

95
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"express-session": "^1.18.1",
"iconv-lite": "^0.7.2",
"lucide": "^0.364.0",
"mysql2": "^3.22.1",
@@ -1250,6 +1251,50 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-session": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz",
"integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==",
"license": "MIT",
"dependencies": {
"cookie": "~0.7.2",
"cookie-signature": "~1.0.7",
"debug": "~2.6.9",
"depd": "~2.0.0",
"on-headers": "~1.1.0",
"parseurl": "~1.3.3",
"safe-buffer": "~5.2.1",
"uid-safe": "~2.1.5"
},
"engines": {
"node": ">= 0.8.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/express-session/node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/express-session/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/express-session/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
@@ -1689,6 +1734,15 @@
"node": ">= 0.8"
}
},
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -1846,6 +1900,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/random-bytes": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -1944,6 +2007,26 @@
"node": ">= 18"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -2185,6 +2268,18 @@
"node": ">=14.17"
}
},
"node_modules/uid-safe": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
"license": "MIT",
"dependencies": {
"random-bytes": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/undici-types": {
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",

View File

@@ -19,6 +19,7 @@
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"express-session": "^1.18.1",
"iconv-lite": "^0.7.2",
"lucide": "^0.364.0",
"mysql2": "^3.22.1",

579
server.js
View File

@@ -3,9 +3,25 @@ import mysql from 'mysql2/promise';
import cors from 'cors';
import dotenv from 'dotenv';
import fs from 'fs';
import crypto from 'crypto';
import session from 'express-session';
import { getSigningKey, getPrivateKeyPem } from './src/utils/jwks.js';
dotenv.config();
const {
CLIENT_ID,
ISSUER,
REDIRECT_URI,
JWKS_URI,
SESSION_SECRET,
ERROR_LOCALE_PATH
} = process.env;
const SESSION_SECRET_VALUE = SESSION_SECRET || 'itam-headless-session-secret';
const DEFAULT_SCOPES = ['openid', 'profile', 'email'];
const DEFAULT_ERROR_PATH = ERROR_LOCALE_PATH || '/ko/error';
const dbConfig = {
host: process.env.DB_HOST,
user: process.env.DB_USER,
@@ -24,6 +40,17 @@ const getDbConnectionSummary = () => ({
const app = express();
app.use(cors());
app.use(express.json({ limit: '50mb' }));
app.use(session({
secret: SESSION_SECRET_VALUE,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: false,
maxAge: 1000 * 60 * 60 * 8
}
}));
app.use('/uploads', express.static('uploads')); // 업로드 파일 정적 서빙
// uploads 폴더가 없으면 생성
@@ -113,6 +140,401 @@ const ASSET_TABLES = [
'asset_core'
];
const ensureSsoConfig = () => {
const missing = [];
if (!CLIENT_ID) missing.push('CLIENT_ID');
if (!ISSUER) missing.push('ISSUER');
if (!REDIRECT_URI) missing.push('REDIRECT_URI');
if (!JWKS_URI) missing.push('JWKS_URI');
if (missing.length > 0) {
throw new Error(`Missing SSO configuration: ${missing.join(', ')}`);
}
};
const base64Url = (input) => Buffer.from(input).toString('base64url');
const sha256Base64Url = (input) => crypto.createHash('sha256').update(input).digest('base64url');
const randomString = (size = 32) => crypto.randomBytes(size).toString('base64url');
const parseJsonSafely = async (response) => {
const text = await response.text();
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return { raw: text };
}
};
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const appendCookies = (currentCookies, response) => {
const rawSetCookie = response.headers.get('set-cookie');
if (!rawSetCookie) {
return currentCookies;
}
const cookieMap = new Map();
(currentCookies || '').split(';').map((entry) => entry.trim()).filter(Boolean).forEach((entry) => {
const [name, ...rest] = entry.split('=');
cookieMap.set(name, `${name}=${rest.join('=')}`);
});
rawSetCookie
.split(/,(?=[^;]+=[^;]+)/)
.map((entry) => entry.split(';')[0].trim())
.filter(Boolean)
.forEach((cookie) => {
const [name] = cookie.split('=');
cookieMap.set(name, cookie);
});
return Array.from(cookieMap.values()).join('; ');
};
const createClientAssertion = (audience) => {
const now = Math.floor(Date.now() / 1000);
const header = {
alg: 'RS256',
typ: 'JWT',
kid: getSigningKey().kid
};
const payload = {
iss: CLIENT_ID,
sub: CLIENT_ID,
aud: audience,
jti: randomString(16),
iat: now,
exp: now + 300
};
const encodedHeader = base64Url(JSON.stringify(header));
const encodedPayload = base64Url(JSON.stringify(payload));
const signingInput = `${encodedHeader}.${encodedPayload}`;
const signature = crypto.sign('RSA-SHA256', Buffer.from(signingInput), getPrivateKeyPem()).toString('base64url');
return `${signingInput}.${signature}`;
};
const fetchDiscoveryDocument = async () => {
ensureSsoConfig();
const discoveryUrl = `${ISSUER.replace(/\/$/, '')}/.well-known/openid-configuration`;
const response = await fetch(discoveryUrl);
if (!response.ok) {
throw new Error(`OIDC discovery failed: ${response.status}`);
}
return response.json();
};
const beginAuthorizationFlow = async () => {
const discovery = await fetchDiscoveryDocument();
const codeVerifier = randomString(48);
const codeChallenge = sha256Base64Url(codeVerifier);
const stateToken = randomString(16);
const nonce = randomString(16);
const authUrl = new URL(discovery.authorization_endpoint);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', CLIENT_ID);
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
authUrl.searchParams.set('scope', DEFAULT_SCOPES.join(' '));
authUrl.searchParams.set('state', stateToken);
authUrl.searchParams.set('nonce', nonce);
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
const authRes = await fetch(authUrl.toString(), { redirect: 'manual' });
const cookies = appendCookies('', authRes);
const location = authRes.headers.get('location');
if (!location) {
throw new Error('Authorization redirect did not provide login_challenge');
}
const authRedirectUrl = new URL(location, authUrl.toString());
const loginChallenge = authRedirectUrl.searchParams.get('login_challenge');
if (!loginChallenge) {
throw new Error('login_challenge not found');
}
return {
discovery,
cookies,
loginChallenge,
authState: {
stateToken,
nonce,
codeVerifier
}
};
};
const getCodeFromRedirect = (redirectTo) => {
const url = new URL(redirectTo, ISSUER);
return url.searchParams.get('code');
};
const resolveRedirects = async (redirectTo, cookies, depth = 0) => {
if (depth > 10) {
throw new Error('Redirect resolution exceeded limit');
}
const currentUrl = new URL(redirectTo, ISSUER).toString();
if (currentUrl.includes('/consent')) {
const consentUrl = new URL(currentUrl);
const consentChallenge = consentUrl.searchParams.get('consent_challenge');
if (!consentChallenge) {
throw new Error('Missing consent_challenge');
}
const detailsUrl = new URL('/api/v1/auth/consent', ISSUER);
detailsUrl.searchParams.set('consent_challenge', consentChallenge);
const detailsRes = await fetch(detailsUrl.toString(), {
headers: cookies ? { Cookie: cookies } : {}
});
if (!detailsRes.ok) {
const detailsBody = await parseJsonSafely(detailsRes);
if (detailsRes.status === 403 && detailsBody?.code === 'tenant_not_allowed') {
const errorUrl = new URL(DEFAULT_ERROR_PATH, ISSUER);
errorUrl.searchParams.set('error', detailsBody.code);
errorUrl.searchParams.set('error_description', detailsBody.message || 'Tenant not allowed');
if (detailsBody.details) {
errorUrl.searchParams.set('details', JSON.stringify(detailsBody.details));
}
return { finalUrl: errorUrl.toString(), cookies, code: null, isErrorRedirect: true };
}
throw new Error(`Consent details failed: ${detailsRes.status}`);
}
const consentInfo = await detailsRes.json();
const acceptUrl = new URL('/api/v1/auth/consent/accept', ISSUER);
const acceptRes = await fetch(acceptUrl.toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(cookies ? { Cookie: cookies } : {})
},
body: JSON.stringify({
consent_challenge: consentChallenge,
grant_scope: consentInfo.requested_scope || DEFAULT_SCOPES,
grant_access_token_audience: consentInfo.requested_access_token_audience || []
})
});
const nextCookies = appendCookies(cookies, acceptRes);
const acceptBody = await parseJsonSafely(acceptRes);
if (!acceptRes.ok || !acceptBody?.redirectTo) {
throw new Error(`Consent accept failed: ${acceptRes.status}`);
}
return resolveRedirects(acceptBody.redirectTo, nextCookies, depth + 1);
}
const response = await fetch(currentUrl, {
redirect: 'manual',
headers: cookies ? { Cookie: cookies } : {}
});
const nextCookies = appendCookies(cookies, response);
const location = response.headers.get('location');
if (location) {
const nextUrl = new URL(location, currentUrl).toString();
if (nextUrl.startsWith(REDIRECT_URI)) {
return {
finalUrl: nextUrl,
cookies: nextCookies,
code: getCodeFromRedirect(nextUrl),
isErrorRedirect: false
};
}
return resolveRedirects(nextUrl, nextCookies, depth + 1);
}
if (response.ok && currentUrl.startsWith(REDIRECT_URI)) {
return {
finalUrl: currentUrl,
cookies: nextCookies,
code: getCodeFromRedirect(currentUrl),
isErrorRedirect: false
};
}
throw new Error('Could not resolve authorization redirect');
};
const exchangeAuthorizationCode = async (code, discovery) => {
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
client_assertion: createClientAssertion(discovery.token_endpoint)
});
const tokenRes = await fetch(discovery.token_endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: body.toString()
});
const tokenBody = await parseJsonSafely(tokenRes);
if (!tokenRes.ok) {
throw new Error(`Token exchange failed: ${tokenRes.status} ${JSON.stringify(tokenBody)}`);
}
return tokenBody;
};
const decodeJwtPayload = (jwt) => {
const [, payload] = jwt.split('.');
if (!payload) {
throw new Error('Invalid JWT payload');
}
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
};
const runHeadlessSsoLogin = async ({ loginId, password }) => {
const { discovery, cookies, loginChallenge, authState } = await beginAuthorizationFlow();
const headlessEndpoint = new URL('/api/v1/auth/headless/password/login', ISSUER).toString();
const headlessRes = await fetch(headlessEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(cookies ? { Cookie: cookies } : {})
},
body: JSON.stringify({
client_id: CLIENT_ID,
login_challenge: loginChallenge,
loginId,
password,
client_assertion: createClientAssertion(headlessEndpoint)
})
});
const nextCookies = appendCookies(cookies, headlessRes);
const headlessBody = await parseJsonSafely(headlessRes);
if (!headlessRes.ok || !headlessBody?.redirectTo) {
throw new Error(`Headless login failed: ${headlessRes.status} ${JSON.stringify(headlessBody)}`);
}
const resolution = await resolveRedirects(headlessBody.redirectTo, nextCookies);
if (resolution.isErrorRedirect) {
return { errorRedirect: resolution.finalUrl };
}
if (!resolution.code) {
throw new Error('Authorization code not found after redirect resolution');
}
const tokenResponse = await exchangeAuthorizationCode(resolution.code, discovery);
const idTokenPayload = decodeJwtPayload(tokenResponse.id_token);
return {
tokens: tokenResponse,
profile: idTokenPayload,
authState
};
};
const initHeadlessPhoneLogin = async ({ loginId }) => {
const { discovery, cookies, loginChallenge, authState } = await beginAuthorizationFlow();
const headlessEndpoint = new URL('/api/v1/auth/headless/link/init', ISSUER).toString();
const initRes = await fetch(headlessEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(cookies ? { Cookie: cookies } : {})
},
body: JSON.stringify({
client_id: CLIENT_ID,
login_challenge: loginChallenge,
loginId,
client_assertion: createClientAssertion(headlessEndpoint)
})
});
const nextCookies = appendCookies(cookies, initRes);
const initBody = await parseJsonSafely(initRes);
if (!initRes.ok || !initBody?.pendingRef) {
throw new Error(`Phone link init failed: ${initRes.status} ${JSON.stringify(initBody)}`);
}
return {
discovery,
cookies: nextCookies,
pendingRef: initBody.pendingRef,
intervalMs: Math.max(2000, Number(initBody.interval || 3) * 1000),
expiresInMs: Math.max(60000, Number(initBody.expiresIn || 180) * 1000),
authState,
loginId
};
};
const pollHeadlessPhoneLogin = async (pendingContext) => {
const pollEndpoint = new URL('/api/v1/auth/headless/link/poll', ISSUER).toString();
const pollRes = await fetch(pollEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(pendingContext.cookies ? { Cookie: pendingContext.cookies } : {})
},
body: JSON.stringify({
client_id: CLIENT_ID,
pendingRef: pendingContext.pendingRef,
client_assertion: createClientAssertion(pollEndpoint)
})
});
const nextCookies = appendCookies(pendingContext.cookies, pollRes);
const pollBody = await parseJsonSafely(pollRes);
if (pollRes.ok && pollBody?.redirectTo) {
const resolution = await resolveRedirects(pollBody.redirectTo, nextCookies);
if (resolution.isErrorRedirect) {
return { status: 'error_redirect', redirectTo: resolution.finalUrl };
}
if (!resolution.code) {
throw new Error('Authorization code not found after phone redirect resolution');
}
const tokenResponse = await exchangeAuthorizationCode(resolution.code, pendingContext.discovery);
const idTokenPayload = decodeJwtPayload(tokenResponse.id_token);
return {
status: 'authenticated',
tokens: tokenResponse,
profile: idTokenPayload
};
}
const statusCode = pollBody?.code || pollBody?.error;
if (statusCode === 'authorization_pending') {
return {
status: 'pending',
cookies: nextCookies,
intervalMs: Math.max(2000, Number(pollBody?.interval || pendingContext.intervalMs / 1000 || 3) * 1000)
};
}
if (statusCode === 'slow_down') {
return {
status: 'pending',
cookies: nextCookies,
intervalMs: Math.max(pendingContext.intervalMs + 2000, Number(pollBody?.interval || 5) * 1000)
};
}
if (statusCode === 'expired_token') {
return { status: 'expired' };
}
throw new Error(`Phone poll failed: ${pollRes.status} ${JSON.stringify(pollBody)}`);
};
// --- Helper Functions for Maps ---
function getCleanMapKey(path) {
let clean = path.replace('img/location_photo/', '').replace('.png', '');
@@ -139,6 +561,149 @@ function getLocationDetail(path, idx) {
// --- API Endpoints ---
app.get('/api/auth/session', (req, res) => {
res.json({
authenticated: Boolean(req.session.user),
user: req.session.user || null
});
});
app.post('/api/auth/logout', (req, res) => {
req.session.destroy(() => {
res.json({ success: true });
});
});
app.post('/api/auth/headless/login', async (req, res) => {
const { loginId, password } = req.body;
if (!loginId || !password) {
return res.status(400).json({ error: 'loginId and password are required' });
}
try {
const loginResult = await runHeadlessSsoLogin({ loginId, password });
if (loginResult.errorRedirect) {
return res.status(403).json({ redirectTo: loginResult.errorRedirect, code: 'tenant_not_allowed' });
}
req.session.user = {
loginId,
profile: loginResult.profile,
tokens: {
accessToken: loginResult.tokens.access_token,
idToken: loginResult.tokens.id_token,
expiresIn: loginResult.tokens.expires_in,
scope: loginResult.tokens.scope,
tokenType: loginResult.tokens.token_type
}
};
res.json({ success: true, user: req.session.user });
} catch (error) {
console.error('Headless SSO login failed:', error);
res.status(500).json({ error: error.message || 'Headless SSO login failed' });
}
});
app.post('/api/auth/headless/phone/init', async (req, res) => {
const { loginId } = req.body;
if (!loginId) {
return res.status(400).json({ error: 'loginId is required' });
}
try {
const pendingLogin = await initHeadlessPhoneLogin({ loginId });
req.session.pendingPhoneLogin = pendingLogin;
res.json({
success: true,
pendingRef: pendingLogin.pendingRef,
intervalMs: pendingLogin.intervalMs,
expiresInMs: pendingLogin.expiresInMs,
message: '인증 링크를 발송했습니다. 모바일에서 승인 후 잠시만 기다려주세요.'
});
} catch (error) {
console.error('Headless phone login init failed:', error);
res.status(500).json({ error: error.message || 'Headless phone login init failed' });
}
});
app.post('/api/auth/headless/phone/poll', async (req, res) => {
const pendingLogin = req.session.pendingPhoneLogin;
const { pendingRef } = req.body || {};
if (!pendingLogin || !pendingLogin.pendingRef) {
return res.status(400).json({ error: 'No pending phone login session found' });
}
if (pendingRef && pendingRef !== pendingLogin.pendingRef) {
return res.status(400).json({ error: 'Pending reference does not match current session' });
}
if (pendingLogin.startedAt && Date.now() - pendingLogin.startedAt > pendingLogin.expiresInMs) {
delete req.session.pendingPhoneLogin;
return res.status(410).json({ code: 'expired_token', error: 'Phone login request expired' });
}
try {
const result = await pollHeadlessPhoneLogin(pendingLogin);
if (result.status === 'pending') {
req.session.pendingPhoneLogin = {
...pendingLogin,
cookies: result.cookies,
intervalMs: result.intervalMs,
startedAt: pendingLogin.startedAt || Date.now()
};
return res.json({
success: true,
status: 'pending',
pendingRef: pendingLogin.pendingRef,
intervalMs: result.intervalMs
});
}
if (result.status === 'expired') {
delete req.session.pendingPhoneLogin;
return res.status(410).json({ code: 'expired_token', error: 'Phone login request expired' });
}
if (result.status === 'error_redirect') {
delete req.session.pendingPhoneLogin;
return res.status(403).json({ redirectTo: result.redirectTo, code: 'tenant_not_allowed' });
}
delete req.session.pendingPhoneLogin;
req.session.user = {
loginId: pendingLogin.loginId,
profile: result.profile,
tokens: {
accessToken: result.tokens.access_token,
idToken: result.tokens.id_token,
expiresIn: result.tokens.expires_in,
scope: result.tokens.scope,
tokenType: result.tokens.token_type
}
};
return res.json({ success: true, status: 'authenticated', user: req.session.user });
} catch (error) {
console.error('Headless phone login poll failed:', error);
res.status(500).json({ error: error.message || 'Headless phone login poll failed' });
}
});
app.get('/callback', (req, res) => {
if (req.session.user) {
return res.redirect('/');
}
const error = req.query.error || 'login_failed';
const description = req.query.error_description || 'Authentication failed';
return res.redirect(`/?auth_error=${encodeURIComponent(error)}&auth_error_description=${encodeURIComponent(description)}`);
});
// 1. Generic Batch Save (Dynamic Table Detection)
app.post('/api/:table/batch', async (req, res) => {
const { table } = req.params;
@@ -1207,3 +1772,17 @@ app.get('/ready', async (req, res) => {
app.listen(process.env.PORT || 3000, '0.0.0.0', () => {
console.log(`📡 ITAM BACKEND SERVER RUNNING ON PORT ${process.env.PORT || 3000} (V3 Normalized)`);
});
// Ensure keys are generated on startup
getSigningKey();
// JWKS Endpoint
app.get('/.well-known/jwks.json', (req, res) => {
try {
const jwk = getSigningKey();
res.json({ keys: [jwk] });
} catch (error) {
console.error('Error serving JWKS endpoint:', error);
res.status(500).json({ error: 'Could not retrieve JWKS' });
}
});

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 };