/** * 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 · 본 메일은 시스템에 의해 자동 발송되었습니다. |