Files

122 lines
11 KiB
JavaScript

'use strict';
// ============================================================================
// [Ariweb Suite 변경] Ariweb Suite 연동 모듈 (Ariweb Suite 0.1.1, 개발 지시 §0.2·§7·§9)
//
// 왜 — Webmaker 는 Ariweb Suite 의 "제작 모듈" 로 도입된다. 고객은 포털(my.ariweb.net)에서 로그인한 상태 그대로 제작 화면으로
// 들어와야 하고(별도 로그인 없음), Webmaker 의 프로젝트(data/sites/<id>.json)와 포털의 운영 사이트는 서로 다른 것으로 구분되어야 한다.
// 이 모듈은 그 연결에 필요한 최소 기능만 담는다. 기존 Webmaker 동작(제작·AI·도메인·배포)은 바꾸지 않는다.
//
// 기능
// 1) SSO 티켓 검증(verifyTicket): 포털이 공유 비밀(보관소 'ariweb')로 서명한 티켓. 60초 유효, 한 번만 사용(nonce 기억).
// 티켓 내용 { v:1, login, name, groupId, groupName, siteId, siteName, projectId, path, exp, nonce } — 비밀번호 없음.
// 2) 서비스 호출 검증(verifyService): 포털 → Webmaker 내부 API(/api/internal/ariweb/*). lib/serviceauth(포털) 와 같은 서명 규약
// X-Service-Key-Id('portal') · X-Service-Ts · X-Service-Sign = HMAC(secret, `${METHOD}|${path}|${ts}|${sha256(body)}`), ±5분.
// 3) 연결 등록부(data/ariweb-links.json): 프로젝트 id → { siteId, groupId, portalUrl, siteName, linkedAt }.
// 연결된 프로젝트는 "운영 사이트가 포털에 있다" 는 뜻이라, Webmaker 의 발행 대신 포털 배포 화면으로 안내하고(§0.2 "운영 Version 을 바로 덮어쓰지 않음"),
// 폐기 전에는 포털에 영향(운영 사이트·배포·도메인)을 물어 표시한다(§9.1 "자동으로 함께 삭제하지 않는다").
// 4) 화면 컨텍스트 쿠키(wm_ariweb): SSO 로 들어온 세션에 그룹·사이트 이름을 붙여 상단 배너에 보인다. 서명된 값이라 위조할 수 없다.
// 5) 포털 영향 조회(impactFromPortal): POST <portalUrl>/internal/webmaker/project-impact (같은 서명 규약, 키 id 'webmaker').
//
// 비밀값은 보관소(lib/vault.js, 종류 'ariweb')에만 있고 설정 파일·로그·응답에 쓰지 않는다.
// ============================================================================
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const js = require('./jsonstore');
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const LINKS_FILE = path.join(DATA_ROOT, 'ariweb-links.json');
const CTX_COOKIE = 'wm_ariweb';
const TICKET_TTL_MS = 60 * 1000;
const SKEW_MS = 5 * 60 * 1000;
const usedNonces = new Map(); // nonce → 만료 시각 (티켓 재사용 차단; 티켓이 60초만 살므로 메모리로 충분)
/** 공유 비밀(보관소 'ariweb'). 없으면 '' — 연동이 설정되지 않은 서버에서는 모든 검증이 실패한다(안전한 기본값). */
function secret() { try { return require('./vault').secretOf('ariweb') || ''; } catch (_) { return ''; } }
/** 포털 공개 주소(설정 ariweb.portalUrl) */
function portalUrl() { try { return String(require('./config').get('ariweb.portalUrl') || '').replace(/\/$/, ''); } catch (_) { return ''; } }
function hmac(key, data) { return crypto.createHmac('sha256', key).update(data).digest('base64url'); }
function safeEqual(a, b) { const x = Buffer.from(String(a || '')); const y = Buffer.from(String(b || '')); return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y); }
const sha256 = (s) => crypto.createHash('sha256').update(String(s || '')).digest('hex');
/** 사용한 nonce 정리 */
function sweep() { const now = Date.now(); for (const [k, v] of usedNonces) if (v < now) usedNonces.delete(k); }
/**
* SSO 티켓 검증. 성공하면 티켓 내용을, 실패하면 { error } 를 돌려준다(이유는 사용자에게 보여도 되는 문장).
* 한 번 쓴 티켓(nonce)은 만료 전에 다시 쓸 수 없다(링크 복사·뒤로 가기 재사용 차단).
*/
function verifyTicket(ticket, { now = Date.now(), key = secret() } = {}) {
if (!key) return { error: 'Ariweb Suite 연동이 설정되지 않았습니다(공유 비밀 없음)' };
const parts = String(ticket || '').split('.'); if (parts.length !== 2) return { error: '티켓 형식이 올바르지 않습니다' };
const [payload, sig] = parts;
if (!safeEqual(hmac(key, payload), sig)) return { error: '티켓 서명이 맞지 않습니다' };
let d; try { d = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); } catch (_) { return { error: '티켓 내용을 읽을 수 없습니다' }; }
if (!d || d.v !== 1 || typeof d.login !== 'string' || !Number.isFinite(d.exp)) return { error: '티켓 내용이 올바르지 않습니다' };
if (d.exp < now) return { error: '티켓이 만료되었습니다. 포털에서 다시 열어 주세요' };
if (d.exp - now > TICKET_TTL_MS + SKEW_MS) return { error: '티켓 유효 시간이 비정상입니다' };
sweep(); if (!d.nonce || usedNonces.has(d.nonce)) return { error: '이미 사용한 티켓입니다. 포털에서 다시 열어 주세요' };
usedNonces.set(d.nonce, d.exp);
return d;
}
/** (테스트·포털용) 티켓 만들기 — 포털이 하는 일과 같은 형식 */
function makeTicket(data, { key = secret(), now = Date.now() } = {}) {
const payload = Buffer.from(JSON.stringify({ v: 1, ...data, exp: data.exp || now + TICKET_TTL_MS, nonce: data.nonce || crypto.randomBytes(12).toString('base64url') })).toString('base64url');
return `${payload}.${hmac(key, payload)}`;
}
/**
* 서비스 호출 검증(포털 → Webmaker). rawBody 는 본문 원문 문자열.
* @returns {{ok:boolean, reason?:string}}
*/
function verifyService(req, pathWithQuery, rawBody, { key = secret(), now = Date.now() } = {}) {
const keyId = String(req.headers['x-service-key-id'] || ''); const ts = Number(req.headers['x-service-ts'] || 0); const sign = String(req.headers['x-service-sign'] || '');
if (!key) return { ok: false, reason: 'not_configured' };
if (keyId !== 'portal' || !ts || !sign) return { ok: false, reason: 'missing_headers' };
if (Math.abs(now - ts) > SKEW_MS) return { ok: false, reason: 'clock_skew' };
return safeEqual(hmac(key, `${req.method.toUpperCase()}|${pathWithQuery}|${ts}|${sha256(rawBody)}`), sign) ? { ok: true } : { ok: false, reason: 'bad_signature' };
}
/** 포털로 보내는 서비스 호출 헤더(키 id 'webmaker') */
function signHeaders(method, p, body, { key = secret(), now = Date.now() } = {}) { const ts = String(now); return { 'X-Service-Key-Id': 'webmaker', 'X-Service-Ts': ts, 'X-Service-Sign': hmac(key, `${method.toUpperCase()}|${p}|${ts}|${sha256(body == null ? '' : typeof body === 'string' ? body : JSON.stringify(body))}`), 'Content-Type': 'application/json' }; }
// ---------- 연결 등록부 ----------
function loadLinks() { const d = js.readJson(LINKS_FILE, null); return d && typeof d === 'object' && d.links ? d : { version: 1, links: {} }; }
function saveLinks(d) { js.writeJsonAtomic(LINKS_FILE, d, { mode: 0o600, keepBackup: true }); }
/** 프로젝트의 포털 연결 정보(없으면 null) */
function linkOf(projectId) { return loadLinks().links[projectId] || null; }
/** 연결 등록(포털이 사이트를 만들거나 기존 프로젝트를 연결할 때) */
function link(projectId, { siteId, groupId, portalUrl: pu, siteName, groupName, linkedAt }) { // linkedAt: 폐기 실패 보상(되살리기) 때 원래 연결 시각 보존
const d = loadLinks(); d.links[projectId] = { siteId: String(siteId), groupId: String(groupId), portalUrl: String(pu || portalUrl()), siteName: String(siteName || ''), groupName: String(groupName || ''), linkedAt: linkedAt || new Date().toISOString() }; saveLinks(d); return d.links[projectId];
}
function unlink(projectId) { const d = loadLinks(); const had = !!d.links[projectId]; delete d.links[projectId]; saveLinks(d); return had; }
/** 연결된 프로젝트 전체(포털 사이트 id → 프로젝트 id 역참조용) */
function allLinks() { return loadLinks().links; }
// ---------- 화면 컨텍스트 쿠키 ----------
/** SSO 로 들어온 세션의 그룹·사이트 표시 정보를 서명 쿠키로 둔다(값: base64url(JSON).sig). 세션과 같은 수명(30일)으로 두되 비밀 없음. */
function ctxCookie(ctx, { key = secret(), secure = false } = {}) {
const payload = Buffer.from(JSON.stringify({ v: 1, groupId: ctx.groupId || '', groupName: ctx.groupName || '', siteId: ctx.siteId || '', siteName: ctx.siteName || '', projectId: ctx.projectId || '', portalUrl: ctx.portalUrl || portalUrl() })).toString('base64url');
return `${CTX_COOKIE}=${payload}.${hmac(key, payload)}; Path=/; HttpOnly; SameSite=Lax${secure ? '; Secure' : ''}; Max-Age=${30 * 86400}`;
}
function clearCtxCookie() { return `${CTX_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`; }
/** 요청의 컨텍스트 쿠키 읽기(서명 확인). 없거나 위조면 null */
function readCtx(req, { key = secret() } = {}) {
const raw = (req.headers.cookie || '').split(';').map((s) => s.trim()).find((s) => s.startsWith(CTX_COOKIE + '='));
if (!raw || !key) return null; const v = raw.slice(CTX_COOKIE.length + 1); const [payload, sig] = v.split('.'); if (!payload || !sig || !safeEqual(hmac(key, payload), sig)) return null;
try { const d = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); return d && d.v === 1 ? d : null; } catch (_) { return null; }
}
/**
* 포털에 프로젝트 삭제 영향을 묻는다. 포털에 닿지 못하면 { reachable:false } — 호출자는 "영향을 확인할 수 없어 폐기를 보류" 한다(모르면 지우지 않는다).
* @returns {Promise<{reachable:boolean, impact?:{liveSite:boolean, lifecycle:string, deployments:number, domains:string[], message:string}}>}
*/
async function impactFromPortal(projectId, { fetchImpl = fetch } = {}) {
const l = linkOf(projectId); if (!l) return { reachable: true, impact: null };
const base = l.portalUrl || portalUrl(); if (!base || !secret()) return { reachable: false, reason: 'not_configured' };
const p = '/internal/webmaker/project-impact'; const body = JSON.stringify({ projectId, siteId: l.siteId, groupId: l.groupId });
try {
const r = await fetchImpl(base + p, { method: 'POST', headers: signHeaders('POST', p, body), body, signal: AbortSignal.timeout(8000) });
const j = await r.json().catch(() => null); if (!r.ok || !j) return { reachable: false, reason: `portal_${r.status}` };
return { reachable: true, impact: j.impact || null };
} catch (e) { return { reachable: false, reason: e.name === 'TimeoutError' ? 'timeout' : 'unreachable' }; }
}
module.exports = { CTX_COOKIE, TICKET_TTL_MS, secret, portalUrl, verifyTicket, makeTicket, verifyService, signHeaders, linkOf, link, unlink, allLinks, ctxCookie, clearCtxCookie, readCtx, impactFromPortal, _usedNonces: usedNonces };