BARON-SSO 로그인 기능 연동
This commit is contained in:
4
.env
4
.env
@@ -3,4 +3,8 @@ DB_PORT=3306
|
|||||||
DB_USER=itam
|
DB_USER=itam
|
||||||
DB_PASS=itam1234
|
DB_PASS=itam1234
|
||||||
DB_NAME=itam
|
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
|
PORT=3000
|
||||||
@@ -11,9 +11,13 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: 3000
|
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:
|
volumes:
|
||||||
- ./uploads:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
- ./map_config.json:/app/map_config.json:ro
|
- ./map_config.json:/app/map_config.json:ro
|
||||||
|
- backend_keys:/app/data
|
||||||
expose:
|
expose:
|
||||||
- "3000"
|
- "3000"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -70,4 +74,7 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
command:
|
command:
|
||||||
- --character-set-server=utf8mb4
|
- --character-set-server=utf8mb4
|
||||||
- --collation-server=utf8mb4_unicode_ci
|
- --collation-server=utf8mb4_unicode_ci
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
backend_keys:
|
||||||
@@ -13,11 +13,13 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
DB_HOST: ${DB_HOST:-172.16.8.151}
|
DB_HOST: ${TEST_DB_HOST:-host.docker.internal}
|
||||||
DB_PORT: ${DB_PORT:-3306}
|
DB_PORT: ${DB_PORT:-3306}
|
||||||
DB_USER: ${DB_USER:-root}
|
DB_USER: ${DB_USER:-root}
|
||||||
DB_PASS: ${DB_PASS:-}
|
DB_PASS: ${DB_PASS:-}
|
||||||
DB_NAME: ${DB_NAME:-itam}
|
DB_NAME: ${DB_NAME:-itam}
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -31,6 +31,16 @@ server {
|
|||||||
application/json application/javascript;
|
application/json application/javascript;
|
||||||
gzip_min_length 1000;
|
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
|
# Forward all app requests to the frontend container
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://frontend;
|
proxy_pass http://frontend;
|
||||||
|
|||||||
43
index.html
43
index.html
@@ -14,6 +14,49 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<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;">
|
<div class="app-layout" id="app-layout" style="display: none;">
|
||||||
<!-- Single-Line Integrated Header -->
|
<!-- Single-Line Integrated Header -->
|
||||||
<header class="main-header">
|
<header class="main-header">
|
||||||
|
|||||||
95
package-lock.json
generated
95
package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"express-session": "^1.18.1",
|
||||||
"iconv-lite": "^0.7.2",
|
"iconv-lite": "^0.7.2",
|
||||||
"lucide": "^0.364.0",
|
"lucide": "^0.364.0",
|
||||||
"mysql2": "^3.22.1",
|
"mysql2": "^3.22.1",
|
||||||
@@ -1250,6 +1251,50 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/finalhandler": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
||||||
@@ -1689,6 +1734,15 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/once": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
@@ -1846,6 +1900,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/range-parser": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||||
@@ -1944,6 +2007,26 @@
|
|||||||
"node": ">= 18"
|
"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": {
|
"node_modules/safer-buffer": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
@@ -2185,6 +2268,18 @@
|
|||||||
"node": ">=14.17"
|
"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": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.19.2",
|
"version": "7.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"express-session": "^1.18.1",
|
||||||
"iconv-lite": "^0.7.2",
|
"iconv-lite": "^0.7.2",
|
||||||
"lucide": "^0.364.0",
|
"lucide": "^0.364.0",
|
||||||
"mysql2": "^3.22.1",
|
"mysql2": "^3.22.1",
|
||||||
|
|||||||
579
server.js
579
server.js
@@ -3,9 +3,25 @@ import mysql from 'mysql2/promise';
|
|||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import session from 'express-session';
|
||||||
|
import { getSigningKey, getPrivateKeyPem } from './src/utils/jwks.js';
|
||||||
|
|
||||||
dotenv.config();
|
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 = {
|
const dbConfig = {
|
||||||
host: process.env.DB_HOST,
|
host: process.env.DB_HOST,
|
||||||
user: process.env.DB_USER,
|
user: process.env.DB_USER,
|
||||||
@@ -24,6 +40,17 @@ const getDbConnectionSummary = () => ({
|
|||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json({ limit: '50mb' }));
|
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')); // 업로드 파일 정적 서빙
|
app.use('/uploads', express.static('uploads')); // 업로드 파일 정적 서빙
|
||||||
|
|
||||||
// uploads 폴더가 없으면 생성
|
// uploads 폴더가 없으면 생성
|
||||||
@@ -113,6 +140,401 @@ const ASSET_TABLES = [
|
|||||||
'asset_core'
|
'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 ---
|
// --- Helper Functions for Maps ---
|
||||||
function getCleanMapKey(path) {
|
function getCleanMapKey(path) {
|
||||||
let clean = path.replace('img/location_photo/', '').replace('.png', '');
|
let clean = path.replace('img/location_photo/', '').replace('.png', '');
|
||||||
@@ -139,6 +561,149 @@ function getLocationDetail(path, idx) {
|
|||||||
|
|
||||||
// --- API Endpoints ---
|
// --- 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)
|
// 1. Generic Batch Save (Dynamic Table Detection)
|
||||||
app.post('/api/:table/batch', async (req, res) => {
|
app.post('/api/:table/batch', async (req, res) => {
|
||||||
const { table } = req.params;
|
const { table } = req.params;
|
||||||
@@ -1207,3 +1772,17 @@ app.get('/ready', async (req, res) => {
|
|||||||
app.listen(process.env.PORT || 3000, '0.0.0.0', () => {
|
app.listen(process.env.PORT || 3000, '0.0.0.0', () => {
|
||||||
console.log(`📡 ITAM BACKEND SERVER RUNNING ON PORT ${process.env.PORT || 3000} (V3 Normalized)`);
|
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' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
215
src/main.ts
215
src/main.ts
@@ -20,6 +20,13 @@ import { initGuide } from './components/Guide';
|
|||||||
import { pcFlowModal } from './components/Modal/PCFlowModal';
|
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';
|
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) {
|
function refreshView(tab?: string) {
|
||||||
@@ -208,4 +215,210 @@ function initializeAppDirectly() {
|
|||||||
renderNavigation((tab) => refreshView(tab));
|
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);
|
||||||
|
|||||||
@@ -53,6 +53,91 @@
|
|||||||
gap: 1.5rem;
|
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 {
|
.role-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
96
src/utils/jwks.js
Normal file
96
src/utils/jwks.js
Normal 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 };
|
||||||
Reference in New Issue
Block a user