494 lines
19 KiB
JavaScript
494 lines
19 KiB
JavaScript
/**
|
|
* 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, "<")
|
|
.replace(/>/g, ">");
|
|
}
|
|
|
|
/**
|
|
* 인증 메일 본문 HTML — 인라인 스타일·테이블 기반(주요 클라이언트 호환)
|
|
*/
|
|
function buildMagicLinkEmailHtml(linkUrl, linkMinutes) {
|
|
const href = String(linkUrl).replace(/&/g, "&").replace(/"/g, """);
|
|
const linkText = escapeHtmlEmail(linkUrl);
|
|
return `<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>인증 안내</title>
|
|
</head>
|
|
<body style="margin:0;padding:0;background-color:#f1f5f9;-webkit-text-size-adjust:100%;">
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color:#f1f5f9;padding:24px 12px;">
|
|
<tr>
|
|
<td align="center">
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="max-width:560px;background-color:#ffffff;border-radius:12px;overflow:hidden;border:1px solid #e2e8f0;">
|
|
<tr>
|
|
<td style="padding:28px 22px 8px 22px;font-family:'Apple SD Gothic Neo','Malgun Gothic',Helvetica,Arial,sans-serif;">
|
|
<p style="margin:0 0 4px 0;font-size:13px;font-weight:700;letter-spacing:0.02em;color:#dc2626;">XAVIS</p>
|
|
<p style="margin:0 0 20px 0;font-size:17px;font-weight:700;color:#0f172a;line-height:1.35;">AI Platform</p>
|
|
<p style="margin:0 0 14px 0;font-size:15px;line-height:1.65;color:#334155;">계정 인증을 위해 아래의 인증 링크를 안내드립니다.</p>
|
|
<p style="margin:0 0 24px 0;font-size:15px;line-height:1.65;color:#334155;">아래 버튼을 누르시면 인증이 완료되며, 이후 서비스를 정상적으로 이용하실 수 있습니다.</p>
|
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin:0 auto 20px auto;">
|
|
<tr>
|
|
<td align="center" style="border-radius:8px;background-color:#2563eb;">
|
|
<a href="${href}" target="_blank" rel="noopener noreferrer" style="display:inline-block;padding:14px 32px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;font-family:'Apple SD Gothic Neo','Malgun Gothic',Helvetica,Arial,sans-serif;">인증 완료하기</a>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
<p style="margin:0 0 8px 0;font-size:12px;line-height:1.5;color:#64748b;">버튼이 동작하지 않으면 아래 주소를 브라우저에 복사해 붙여 넣어 주세요.</p>
|
|
<p style="margin:0 0 22px 0;font-size:12px;line-height:1.5;color:#2563eb;word-break:break-all;"><a href="${href}" style="color:#2563eb;text-decoration:underline;">${linkText}</a></p>
|
|
<div style="border-top:1px solid #e2e8f0;padding-top:18px;margin-top:4px;">
|
|
<p style="margin:0 0 8px 0;font-size:13px;line-height:1.6;color:#64748b;">※ 본 인증 링크는 보안을 위해 발송 시점으로부터 <strong style="color:#475569;">${linkMinutes}분</strong> 동안만 유효합니다.</p>
|
|
<p style="margin:0 0 18px 0;font-size:13px;line-height:1.6;color:#64748b;">※ 인증 시간이 만료된 경우, 다시 인증 메일을 요청해 주시기 바랍니다.</p>
|
|
<p style="margin:0 0 6px 0;font-size:13px;line-height:1.6;color:#64748b;">본 메일은 발신 전용이며, 문의 사항이 있으실 경우 <strong style="color:#475569;">AI혁신팀</strong>으로 문의 부탁합니다.</p>
|
|
<p style="margin:0;font-size:14px;font-weight:600;color:#0f172a;">감사합니다.</p>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
<p style="margin:16px 0 0 0;font-size:11px;line-height:1.4;color:#94a3b8;font-family:'Apple SD Gothic Neo','Malgun Gothic',Helvetica,Arial,sans-serif;">© XAVIS · 본 메일은 시스템에 의해 자동 발송되었습니다.</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
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,
|
|
};
|
|
};
|