Initial commit: AI platform app (server, views, lib, data, deploy docs)
Made-with: Cursor
This commit is contained in:
446
ops-auth.js
Normal file
446
ops-auth.js
Normal file
@@ -0,0 +1,446 @@
|
||||
/**
|
||||
* 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, "<")
|
||||
.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 = `${BASE_URL}/auth/verify/${encodeURIComponent(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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user