/** * OPS_STATE=PROD(구 REAL) 일 때 이메일(@xavis.co.kr) 매직 링크 인증 */ const crypto = require("crypto"); const { isOpsProdMode } = require("./lib/ops-state"); const path = require("path"); const fsSync = require("fs"); const { v4: uuidv4 } = require("uuid"); let nodemailer = null; try { nodemailer = require("nodemailer"); } catch { /* optional */ } const OPS_AUTH_COOKIE = "ops_user_session"; const ALLOWED_EMAIL_SUFFIX = "@xavis.co.kr"; /** 메일 안 인증 링크 유효 시간(토큰 만료) */ const MAGIC_LINK_MAX_AGE_MS = 15 * 60 * 1000; /** OPS_SESSION_TZ 기준 달력 날짜 키 (YYYY-MM-DD) */ function calendarDateKeyInTz(tsMs, tz) { const parts = new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit", }).formatToParts(new Date(tsMs)); const y = parts.find((p) => p.type === "year").value; const mo = parts.find((p) => p.type === "month").value; const da = parts.find((p) => p.type === "day").value; return `${y}-${mo}-${da}`; } /** 그레고리력 YYYY-MM-DD 문자열에 일수 더하기(타임존 무관, 달력 날짜만) */ function addCalendarDaysToKey(key, days) { const n = Math.floor(Number(days) || 0); const [y, m, d] = key.split("-").map(Number); const base = new Date(Date.UTC(y, m - 1, d)); base.setUTCDate(base.getUTCDate() + n); const yy = base.getUTCFullYear(); const mm = String(base.getUTCMonth() + 1).padStart(2, "0"); const dd = String(base.getUTCDate()).padStart(2, "0"); return `${yy}-${mm}-${dd}`; } /** dateKey 달력 날짜에 속하는 임의 시각(ms) 찾기 */ function findMsOnCalendarDay(dateKey, tz) { const [y, m, d] = dateKey.split("-").map(Number); const start = Date.UTC(y, m - 1, d, 0, 0, 0, 0); for (let k = -24 * 4; k < 96 * 4; k += 1) { const ms = start + k * 15 * 60 * 1000; if (calendarDateKeyInTz(ms, tz) === dateKey) return ms; } throw new Error(`findMsOnCalendarDay: ${dateKey} (${tz})`); } /** * OPS_SESSION_TZ에서 dateKey 해당 날의 마지막 순간(그날 23:59:59.999에 해당하는 epoch ms) */ function getLastMsOfCalendarDayInTz(dateKey, tz) { const anchor = findMsOnCalendarDay(dateKey, tz); let lo = anchor; let hi = anchor + 48 * 60 * 60 * 1000; let guard = 0; while (calendarDateKeyInTz(hi, tz) === dateKey && guard < 400) { hi += 24 * 60 * 60 * 1000; guard += 1; } if (calendarDateKeyInTz(hi, tz) === dateKey) { hi = anchor + 400 * 24 * 60 * 60 * 1000; } while (hi - lo > 1) { const mid = Math.floor((lo + hi) / 2); if (calendarDateKeyInTz(mid, tz) === dateKey) lo = mid; else hi = mid; } return hi - 1; } /** * 로그인 세션 만료: 로그인일(OPS_SESSION_TZ 달력) + OPS_SESSION_TTL_DAYS(기본 15)일의 마지막 순간 */ function getOpsSessionExpiresAtMs(nowMs = Date.now()) { const tz = (process.env.OPS_SESSION_TZ || "Asia/Seoul").trim() || "Asia/Seoul"; const raw = (process.env.OPS_SESSION_TTL_DAYS || "15").trim(); const parsed = parseInt(raw, 10); const ttlDays = Number.isFinite(parsed) && parsed >= 0 ? parsed : 15; const loginDayKey = calendarDateKeyInTz(nowMs, tz); const targetKey = addCalendarDaysToKey(loginDayKey, ttlDays); return getLastMsOfCalendarDayInTz(targetKey, tz); } function isOpsProd() { return isOpsProdMode(); } function getAuthSecret() { return (process.env.AUTH_SECRET || process.env.ADMIN_TOKEN || "xavis-admin").trim(); } function getBaseUrl() { const b = (process.env.BASE_URL || "").trim().replace(/\/$/, ""); if (b) return b; const port = process.env.PORT || 8030; return `http://localhost:${port}`; } /** * 매직 링크 절대 URL — BASE_URL + `/auth/verify/:token` 만 조합 (본문 문장·조사와 문자열 결합 금지) */ function buildMagicVerifyUrl(baseUrl, token) { const base = String(baseUrl || "").trim().replace(/\/$/, ""); const t = String(token || "").trim(); if (!base) throw new Error("BASE_URL이 비어 있습니다."); if (!t) throw new Error("인증 토큰이 비어 있습니다."); const path = `/auth/verify/${encodeURIComponent(t)}`; return new URL(path, `${base}/`).href; } function sanitizeReturnTo(v) { const s = (v || "").toString().trim(); if (!s.startsWith("/") || s.startsWith("//")) return "/learning"; return s; } function appendVerifiedParam(returnPath) { const s = sanitizeReturnTo(returnPath); return s.includes("?") ? `${s}&verified=1` : `${s}?verified=1`; } function isAllowedXavisEmail(email) { const s = String(email || "") .trim() .toLowerCase(); if (!s.includes("@")) return false; return s.endsWith(ALLOWED_EMAIL_SUFFIX); } function signSessionCookie(email, expMs) { const exp = expMs; const payload = `${email}|${exp}`; const sig = crypto.createHmac("sha256", getAuthSecret()).update(payload).digest("hex"); return Buffer.from(JSON.stringify({ email, exp, sig })).toString("base64url"); } function parseSessionCookie(val) { if (!val || typeof val !== "string") return null; try { const j = JSON.parse(Buffer.from(val, "base64url").toString("utf8")); if (!j.email || typeof j.exp !== "number") return null; if (Date.now() > j.exp) return null; const payload = `${j.email}|${j.exp}`; const sig = crypto.createHmac("sha256", getAuthSecret()).update(payload).digest("hex"); if (sig !== j.sig) return null; return String(j.email).toLowerCase(); } catch { return null; } } function getOpsSessionEmail(req) { return parseSessionCookie(req.cookies?.[OPS_AUTH_COOKIE]); } function loadMagicLinks(MAGIC_LINK_PATH) { try { const raw = fsSync.readFileSync(MAGIC_LINK_PATH, "utf8"); const arr = JSON.parse(raw); return Array.isArray(arr) ? arr : []; } catch { return []; } } function saveMagicLinks(MAGIC_LINK_PATH, list) { fsSync.mkdirSync(path.dirname(MAGIC_LINK_PATH), { recursive: true }); fsSync.writeFileSync(MAGIC_LINK_PATH, JSON.stringify(list, null, 2), "utf8"); } function pruneExpired(list) { const now = Date.now(); return list.filter((x) => x.expiresAt > now); } /** 클라이언트에 내부 주소·스택이 노출되지 않도록 SMTP 오류 메시지 정리 */ function publicSmtpErrorMessage(err) { const raw = String(err?.message || err || ""); const code = err?.code || err?.cause?.code; if (code === "ECONNREFUSED" || raw.includes("ECONNREFUSED")) { return "메일 서버에 연결할 수 없습니다. PROD 환경의 SMTP_HOST·포트가 앱 서버에서 접근 가능한지(방화벽·사내 전용 게이트웨이 여부)를 확인하세요."; } if (code === "ETIMEDOUT" || raw.includes("ETIMEDOUT") || /timeout/i.test(raw)) { return "메일 서버 연결 시간이 초과되었습니다."; } if (code === "ENOTFOUND" || raw.includes("ENOTFOUND")) { return "메일 서버 주소(SMTP_HOST)를 찾을 수 없습니다."; } if (code === "ECONNRESET" || raw.includes("ECONNRESET")) { return "메일 서버와의 연결이 끊겼습니다. TLS/포트(SMTP_SECURE, SMTP_PORT) 설정을 확인하세요."; } return "인증 메일을 보내지 못했습니다. 잠시 후 다시 시도하거나 관리자에게 문의하세요."; } function createSmtpTransport() { const host = (process.env.SMTP_HOST || "").trim(); const port = Number(process.env.SMTP_PORT || 587); const secure = process.env.SMTP_SECURE === "1"; const user = (process.env.SMTP_USER || "").trim(); const pass = (process.env.SMTP_PASS || "").trim(); const requireTlsEnv = process.env.SMTP_REQUIRE_TLS; const requireTLS = requireTlsEnv === "0" ? false : requireTlsEnv === "1" || (!secure && port === 587); return nodemailer.createTransport({ host, port, secure, auth: user ? { user, pass } : undefined, requireTLS, connectionTimeout: 15000, greetingTimeout: 15000, socketTimeout: 20000, tls: { minVersion: "TLSv1.2" }, }); } function isOpsPublicPath(req) { const p = req.path || ""; if (p === "/login") return true; if (p === "/logout") return true; /** 쿼리형·경로형 인증 링크 모두 허용 (메일 클라이언트는 ?token= 링크를 깨뜨리는 경우가 있음) */ if (p === "/auth/verify" || p.startsWith("/auth/verify/")) return true; if (p === "/api/auth/request-link" && req.method === "POST") return true; return false; } /** HTML 이메일용 이스케이프 (속성·본문 공통) */ function escapeHtmlEmail(s) { return String(s) .replace(/&/g, "&") .replace(/"/g, """) .replace(//g, ">"); } /** * 인증 메일 본문 HTML — 인라인 스타일·테이블 기반(주요 클라이언트 호환) */ function buildMagicLinkEmailHtml(linkUrl, linkMinutes) { const href = String(linkUrl).replace(/&/g, "&").replace(/"/g, """); const linkText = escapeHtmlEmail(linkUrl); return ` 인증 안내

XAVIS

AI Platform

계정 인증을 위해 아래의 인증 링크를 안내드립니다.

아래 버튼을 누르시면 인증이 완료되며, 이후 서비스를 정상적으로 이용하실 수 있습니다.

인증 완료하기

버튼이 동작하지 않으면 아래 주소를 브라우저에 복사해 붙여 넣어 주세요.

${linkText}

※ 본 인증 링크는 보안을 위해 발송 시점으로부터 ${linkMinutes}분 동안만 유효합니다.

※ 인증 시간이 만료된 경우, 다시 인증 메일을 요청해 주시기 바랍니다.

본 메일은 발신 전용이며, 문의 사항이 있으실 경우 AI혁신팀으로 문의 부탁합니다.

감사합니다.

© XAVIS · 본 메일은 시스템에 의해 자동 발송되었습니다.

`; } async function sendMagicLinkEmail(to, linkUrl) { const host = (process.env.SMTP_HOST || "").trim(); if (!nodemailer || !host) { console.warn("[OPS] SMTP 미설정 — 아래 링크를 수동으로 전달하거나 SMTP_HOST 등을 설정하세요."); console.warn("[OPS] Magic link for", to, "→", linkUrl); return; } const from = (process.env.SMTP_FROM || (process.env.SMTP_USER || "").trim() || "noreply@xavis.co.kr").trim(); const transporter = createSmtpTransport(); const linkMinutes = Math.max(1, Math.floor(MAGIC_LINK_MAX_AGE_MS / 60000)); const textBody = [ "계정 인증을 위해 아래의 인증 링크를 안내드립니다.", "아래 링크를 클릭하시면 인증이 완료되며, 이후 서비스를 정상적으로 이용하실 수 있습니다.", "", "인증 링크:", linkUrl, "", `※ 본 인증 링크는 보안을 위해 발송 시점으로부터 ${linkMinutes}분 동안만 유효합니다.`, "※ 인증 시간이 만료된 경우, 다시 인증 메일을 요청해 주시기 바랍니다.", "", "본 메일은 발신 전용이며, 문의 사항이 있으실 경우 AI혁신팀으로 문의 부탁합니다.", "", "감사합니다.", ].join("\n"); const htmlBody = buildMagicLinkEmailHtml(linkUrl, linkMinutes); await transporter.sendMail({ from, to, subject: "[인증] XAVIS AI Platform 인증 링크", text: textBody, html: htmlBody, }); } module.exports = function createOpsAuth(DATA_DIR, hooks = {}) { const MAGIC_LINK_PATH = path.join(DATA_DIR, "ops-magic-links.json"); const BASE_URL = getBaseUrl(); function opsAccessMiddleware(req, res, next) { if (!isOpsProd()) { res.locals.opsAuthRequired = false; res.locals.opsUserEmail = null; return next(); } res.locals.opsAuthRequired = true; if (isOpsPublicPath(req)) { return next(); } const email = getOpsSessionEmail(req); if (email) { res.locals.opsUserEmail = email; return next(); } if (req.path.startsWith("/api/")) { return res.status(401).json({ error: "로그인이 필요합니다." }); } const returnTo = req.originalUrl || "/learning"; return res.redirect("/login?returnTo=" + encodeURIComponent(returnTo)); } function registerRoutes(app) { app.get("/login", (req, res) => { if (!isOpsProd()) { return res.redirect("/learning"); } if (getOpsSessionEmail(req)) { return res.redirect(sanitizeReturnTo(req.query.returnTo)); } return res.render("login", { returnTo: sanitizeReturnTo(req.query.returnTo), layoutMinimal: true, }); }); app.get("/logout", async (req, res) => { const sessionEmail = getOpsSessionEmail(req); if (typeof hooks.onLogout === "function" && sessionEmail) { try { await hooks.onLogout({ email: sessionEmail, req }); } catch (hookErr) { console.error("[OPS] onLogout hook:", hookErr?.message || hookErr); } } res.clearCookie(OPS_AUTH_COOKIE, { path: "/" }); if (isOpsProd()) { return res.redirect("/login"); } return res.redirect("/learning"); }); app.post("/api/auth/request-link", async (req, res) => { try { if (!isOpsProd()) { return res.status(400).json({ error: "PROD(운영) 모드가 아닙니다." }); } const email = String(req.body?.email || "") .trim() .toLowerCase(); if (!email) { return res.status(400).json({ error: "이메일을 입력해 주세요." }); } if (!isAllowedXavisEmail(email)) { return res.status(403).json({ error: "허용된 임직원이 아닙니다." }); } let list = pruneExpired(loadMagicLinks(MAGIC_LINK_PATH)); const token = uuidv4(); const expiresAt = Date.now() + MAGIC_LINK_MAX_AGE_MS; const ret = sanitizeReturnTo(req.body?.returnTo); list.push({ token, email, expiresAt, used: false, createdAt: new Date().toISOString(), returnTo: ret, }); saveMagicLinks(MAGIC_LINK_PATH, list); /** 경로에 토큰만 두기 (쿼리 `?token=`는 일부 웹메일/Outlook에서 href가 잘림) */ const linkUrl = buildMagicVerifyUrl(BASE_URL, token); await sendMagicLinkEmail(email, linkUrl); if (typeof hooks.onMagicLinkRequested === "function") { try { await hooks.onMagicLinkRequested({ email, returnTo: ret, req }); } catch (hookErr) { console.error("[OPS] onMagicLinkRequested hook:", hookErr?.message || hookErr); } } return res.json({ ok: true, message: "회사 메일로 인증 링크를 보냈습니다. 메일함을 확인해 주세요.", }); } catch (err) { console.error("[OPS] request-link:", err?.message || err); return res.status(500).json({ error: publicSmtpErrorMessage(err) }); } }); async function handleMagicLinkVerify(req, res) { if (!isOpsProd()) { return res.redirect("/learning"); } const token = String(req.params.token || req.query.token || "").trim(); if (!token) { return res.status(400).send("유효하지 않은 링크입니다."); } let list = pruneExpired(loadMagicLinks(MAGIC_LINK_PATH)); const idx = list.findIndex((x) => x.token === token && !x.used); if (idx === -1) { return res.status(400).send("유효하지 않거나 만료된 링크입니다."); } const row = list[idx]; if (Date.now() > row.expiresAt) { return res.status(400).send("만료된 링크입니다."); } row.used = true; saveMagicLinks(MAGIC_LINK_PATH, list); if (typeof hooks.onLoginSuccess === "function") { try { await hooks.onLoginSuccess({ email: row.email, returnTo: row.returnTo, req, }); } catch (hookErr) { console.error("[OPS] onLoginSuccess hook:", hookErr?.message || hookErr); } } const sessionExp = getOpsSessionExpiresAtMs(); const cookieVal = signSessionCookie(row.email, sessionExp); const secure = process.env.NODE_ENV === "production"; res.cookie(OPS_AUTH_COOKIE, cookieVal, { httpOnly: true, maxAge: Math.max(0, sessionExp - Date.now()), sameSite: "lax", path: "/", secure, }); const dest = appendVerifiedParam(sanitizeReturnTo(row.returnTo || "/learning")); return res.redirect(dest); } /** 구 메일 호환: ?token= */ app.get("/auth/verify", handleMagicLinkVerify); /** 권장: 쿼리 없이 경로만 (메일 HTML 파서 깨짐 방지) */ app.get("/auth/verify/:token", handleMagicLinkVerify); } return { middleware: opsAccessMiddleware, registerRoutes, isOpsProd, /** @deprecated REAL→PROD 이전 호환용 */ isOpsReal: isOpsProd, }; };