/** * OPS_STATE=PROD(구 REAL) 일 때 이메일(@ncue.net) 매직 링크 인증 */ 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 = "@ncue.net"; /** 메일 안 인증 링크 유효 시간(토큰 만료) */ const MAGIC_LINK_MAX_AGE_MS = 15 * 60 * 1000; /** * 만기 없음 모드일 때 Set-Cookie max-age 상한(브라우저·HTTP 한도; 서명 payload의 exp는 Number.MAX_SAFE_INTEGER) * 10년마다 쿠키가 사라지면 다시 이메일 인증(매직 링크)이 필요할 수 있음 */ const OPS_SESSION_BROWSER_MAX_AGE_MS = 10 * 365 * 24 * 60 * 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; } /** * 로그인 세션 만료 시각(ms) * - OPS_SESSION_TTL_DAYS: 미설정·0·never·none → 만기 없음(서명 exp = Number.MAX_SAFE_INTEGER) * - 양의 정수 N → 로그인일(OPS_SESSION_TZ 달력) + N일의 마지막 순간 */ function getOpsSessionExpiresAtMs(nowMs = Date.now()) { const tz = (process.env.OPS_SESSION_TZ || "Asia/Seoul").trim() || "Asia/Seoul"; const raw = String(process.env.OPS_SESSION_TTL_DAYS ?? "0").trim() || "0"; const lower = raw.toLowerCase(); if (lower === "never" || lower === "none" || raw === "0") { return Number.MAX_SAFE_INTEGER; } const parsed = parseInt(raw, 10); if (Number.isFinite(parsed) && parsed > 0) { const loginDayKey = calendarDateKeyInTz(nowMs, tz); const targetKey = addCalendarDaysToKey(loginDayKey, parsed); return getLastMsOfCalendarDayInTz(targetKey, tz); } return Number.MAX_SAFE_INTEGER; } /** res.cookie maxAge(ms) — 만기 없음일 때 astronomically large 값 대신 브라우저에 맞는 상한 */ function getOpsSessionCookieMaxAgeMs(sessionExpMs) { const remain = Math.max(0, sessionExpMs - Date.now()); if (sessionExpMs === Number.MAX_SAFE_INTEGER) { return Math.min(remain, OPS_SESSION_BROWSER_MAX_AGE_MS); } return remain; } function isOpsProd() { return isOpsProdMode(); } function getAuthSecret() { return (process.env.AUTH_SECRET || process.env.ADMIN_TOKEN || "ncue-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; } const RETURN_TO_DEFAULT = "/learning"; /** * 인증 후·로그인 returnTo — 상대 경로만 허용(오픈 리다이렉트·한글 조사 등 오염 차단) * @param {unknown} v * @returns {string} */ function sanitizeReturnTo(v) { const s = (v || "").toString().trim(); if (!s.startsWith("/") || s.startsWith("//")) return RETURN_TO_DEFAULT; if (s.includes("\\") || s.includes("..")) return RETURN_TO_DEFAULT; const qIdx = s.indexOf("?"); const hIdx = s.indexOf("#"); let pathEnd = s.length; if (qIdx >= 0) pathEnd = Math.min(pathEnd, qIdx); if (hIdx >= 0) pathEnd = Math.min(pathEnd, hIdx); const pathPart = s.slice(0, pathEnd); const suffix = s.slice(pathEnd); if (pathPart !== "/" && !/^\/[a-zA-Z0-9][a-zA-Z0-9/_\-.]*$/.test(pathPart)) { if (s !== RETURN_TO_DEFAULT) { console.warn("[OPS] invalid returnTo path rejected:", s.slice(0, 160)); } return RETURN_TO_DEFAULT; } if (suffix && !/^[?#][a-zA-Z0-9_\-=&.%+]*$/.test(suffix)) { if (s !== RETURN_TO_DEFAULT) { console.warn("[OPS] invalid returnTo query rejected:", s.slice(0, 160)); } return RETURN_TO_DEFAULT; } 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, iatMs = Date.now()) { const exp = expMs; const iat = iatMs; const payload = `${email}|${exp}|${iat}`; const sig = crypto.createHmac("sha256", getAuthSecret()).update(payload).digest("hex"); return Buffer.from(JSON.stringify({ email, exp, iat, 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 email = String(j.email).toLowerCase(); const exp = j.exp; const iat = typeof j.iat === "number" ? j.iat : 0; const payloadNew = `${email}|${exp}|${iat}`; const sigNew = crypto.createHmac("sha256", getAuthSecret()).update(payloadNew).digest("hex"); if (sigNew === j.sig) { return { email, exp, iat }; } const payloadLegacy = `${email}|${exp}`; const sigLegacy = crypto.createHmac("sha256", getAuthSecret()).update(payloadLegacy).digest("hex"); if (sigLegacy === j.sig) { return { email, exp, iat: 0 }; } return null; } catch { return null; } } function getOpsSessionEmail(req) { const session = parseSessionCookie(req.cookies?.[OPS_AUTH_COOKIE]); return session ? session.email : null; } async function resolveOpsSessionEmail(req, hooks) { const session = parseSessionCookie(req.cookies?.[OPS_AUTH_COOKIE]); if (!session) return null; if (typeof hooks.isSessionRevoked === "function") { const revoked = await hooks.isSessionRevoked({ email: session.email, iatMs: session.iat, }); if (revoked) return null; } return session.email; } 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 `
|
© NCue · 본 메일은 시스템에 의해 자동 발송되었습니다. |