/** * 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}`; } /** * 로그인 세션 만료: OPS_SESSION_TZ(기본 Asia/Seoul)에서 해당 일자의 마지막 순간(23:59:59.999에 해당하는 epoch ms) */ function getOpsSessionExpiresAtMs(nowMs = Date.now()) { const tz = (process.env.OPS_SESSION_TZ || "Asia/Seoul").trim() || "Asia/Seoul"; const cur = calendarDateKeyInTz(nowMs, tz); let lo = nowMs; let hi = nowMs + 48 * 60 * 60 * 1000; let guard = 0; while (calendarDateKeyInTz(hi, tz) === cur && guard < 400) { hi += 24 * 60 * 60 * 1000; guard++; } if (calendarDateKeyInTz(hi, tz) === cur) { hi = nowMs + 400 * 24 * 60 * 60 * 1000; } while (hi - lo > 1) { const mid = Math.floor((lo + hi) / 2); if (calendarDateKeyInTz(mid, tz) === cur) lo = mid; else hi = mid; } return hi - 1; } 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}`; } 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 · 본 메일은 시스템에 의해 자동 발송되었습니다. |