208 lines
17 KiB
JavaScript
208 lines
17 KiB
JavaScript
/**
|
|||
|
|
* webmaker 인증 모드(1.0.8, 요구사항 1) — 웹메이커(WebHosting_AI_agent)의 사용자 계정과 인증을 AriMail 이 그대로 씁니다.
|
||
|
|
*
|
||
|
|
* 왜
|
||
|
|
* 웹메이커는 사이트(예: site.ariweb.net)를 만들고 그 사이트의 관리자 계정·회원을 둡니다. 그 사용자들에게 메일을 열어 주려면 예전에는 웹메이커가 AriMail 관리자
|
||
|
|
* API 로 계정을 따로 만들고 메일 비밀번호를 따로 정해야 했습니다(웹메이커는 scrypt 해시만 갖고 있어 원문 비밀번호를 넘길 수 없음). 이제 AriMail 이 웹메이커의
|
||
|
|
* 사용자 파일을 **읽기 전용**으로 참조해 같은 방식(scrypt)으로 비밀번호를 검사하므로, 웹메이커 계정 그대로 웹 메일·IMAP·POP·SMTP 에 로그인합니다.
|
||
|
|
*
|
||
|
|
* 도메인 ↔ 웹메이커 계정 대응(accountsFor)
|
||
|
|
* 도메인(테넌트)의 메일 도메인(MAILBOX 의 @ 뒤)으로 웹메이커의 사이트를 찾습니다: 등록부 항목의 siteId 가 있으면 그 사이트, 없으면 domains.json(서브 도메인 등록) →
|
||
|
|
* 사이트의 domains 목록 → 사이트의 메일 도메인(webmail.domain) 순으로 같은 이름을 찾습니다.
|
||
|
|
* - 사이트가 있으면: 그 사이트의 회원 중 로그인 ID(loginId)가 있는 계정(사이트 관리자 페이지에서 추가한 "사용자") → `loginId@도메인`(역할 user),
|
||
|
|
* 사이트 소유자(users.json 의 ownerId) → `소유자ID@도메인`(역할 user; 소유자가 웹메이커 관리자이기도 하면 admin), 웹메이커 관리자(role=admin, 보통 admin) → `admin@도메인`(역할 admin).
|
||
|
|
* 같은 로그인 ID 가 겹치면 사이트 회원 → 소유자 → 플랫폼 관리자 순으로 먼저 찾은 쪽이 그 주소를 씁니다.
|
||
|
|
* - 사이트가 없으면(플랫폼 도메인 자체 등): 웹메이커의 모든 회원 → `아이디@도메인`(관리자는 admin, 나머지는 user).
|
||
|
|
* 요구사항의 예: 웹메이커에서 site.ariweb.net 을 만들고 메일을 허용하면 admin@site.ariweb.net(웹메이커 admin)으로 수·발신하고, 사이트에 사용자를 추가하면
|
||
|
|
* user@site.ariweb.net 도 같은 비밀번호로 씁니다. 이 규칙은 모든 도메인(기본·추가)에 똑같이 적용됩니다.
|
||
|
|
*
|
||
|
|
* 파일 읽기(snapshot)
|
||
|
|
* 웹메이커 데이터(users.json 0600, DATA_ROOT 0700)는 보통 웹메이커 서비스 계정만 읽을 수 있습니다. 같은 계정이거나 읽기 권한이 있으면 이 프로세스가 바로 읽고(readSnapshotSync),
|
||
|
|
* 아니면 등록부의 webmakerUser(예: master)가 적힌 도메인은 "읽기 도우미" 로 JSON 을 받습니다(deploy/webmaker-access.sh 가 설치):
|
||
|
|
* 1) systemd 소켓(/run/arimail-webmaker/<디렉터리 슬러그>.sock): 접속마다 그 계정으로 webmaker-snapshot.mjs 를 띄워 결과를 소켓에 씁니다(Accept=yes).
|
||
|
|
* AriMail 서비스는 NoNewPrivileges·CapabilityBoundingSet 로 잠겨 있어 sudo 를 쓸 수 없으므로 이것이 기본 경로입니다(ProtectSystem=strict 에서도 소켓 connect 는 됨).
|
||
|
|
* 2) sudo 도우미(`sudo -n -u <계정> /usr/local/lib/arimail/webmaker-snapshot <디렉터리>`): systemd 가 없는 서버용 대안(소켓 파일이 없을 때).
|
||
|
|
* 스냅샷은 3초 캐시하고, 로그인 실패 때는 한 번 다시 읽어
|
||
|
|
* 방금 바뀐 비밀번호·새 계정을 바로 반영합니다. 읽기에 실패하면 마지막 성공 스냅샷으로 계속 동작하며 오류를 기록합니다(웹메이커 쪽 일시 장애로 메일이 멈추지 않게).
|
||
|
|
*
|
||
|
|
* 주체(principal) id: 'wm:u:<아이디>'(플랫폼 회원), 'wm:m:<사이트 id>:<회원 id>'(사이트 회원). 세션·설정(prefs)·메일함 크기(quota)는 이 id 로 저장되며, 비밀번호·이름·역할은
|
||
|
|
* 웹메이커가 주인이므로 AriMail 에서 바꿀 수 없습니다(관리자 페이지에는 읽기 전용 행으로 보이고 POP/IMAP 허용·메일함 크기만 바꿉니다).
|
||
|
|
*/
|
||
|
|
import { execFile } from 'node:child_process';
|
||
|
|
import { connect } from 'node:net';
|
||
|
|
import { existsSync } from 'node:fs';
|
||
|
|
import { scrypt, timingSafeEqual, randomBytes } from 'node:crypto';
|
||
|
|
import { readSnapshotSync } from './webmaker-snapshot.mjs';
|
||
|
|
|
||
|
|
/** sudo 도우미 경로(deploy/webmaker-access.sh 가 설치). sudoers 규칙은 이 경로 + 데이터 디렉터리 인수 하나만 허용합니다. */
|
||
|
|
export const WEBMAKER_HELPER = '/usr/local/lib/arimail/webmaker-snapshot';
|
||
|
|
/** systemd 소켓 도우미의 소켓 파일: 데이터 디렉터리마다 하나(/data/webmaker → /run/arimail-webmaker/data-webmaker.sock). webmaker-access.sh 와 같은 규칙. */
|
||
|
|
export const webmakerSocketPath = (dir) => '/run/arimail-webmaker/' + String(dir || '').replace(/^\/+/, '').replace(/[^A-Za-z0-9]/g, '-') + '.sock';
|
||
|
|
const CACHE_MS = 3000, FRESH_MIN_MS = 2000;
|
||
|
|
/** 메일 주소의 @ 앞부분으로 쓸 수 있는 로그인 ID(웹메이커 lib/mailsys.js 의 LOCAL_RE 와 같은 규칙). */
|
||
|
|
const LOCAL_RE = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/;
|
||
|
|
|
||
|
|
/** 이 env(도메인)가 webmaker 인증 모드인지. LOGIN_MODE=webmaker 와 데이터 디렉터리(WEBMAKER_DIR)가 모두 있어야 합니다. */
|
||
|
|
export const isWebmaker = (env) => String(env?.LOGIN_MODE || '') === 'webmaker' && !!env?.WEBMAKER_DIR;
|
||
|
|
const mailDomainOf = (env) => String(env.MAILBOX || 'admin@arihakdang.com').trim().toLowerCase().split('@')[1] || '';
|
||
|
|
|
||
|
|
// ------------------------------------------------------------------ 비밀번호(scrypt)
|
||
|
|
/**
|
||
|
|
* 웹메이커와 같은 방식으로 비밀번호를 검사합니다: scrypt(비밀번호, salt 문자열, 64바이트, 기본 매개변수 N=16384·r=8·p=1) 의 16진수를 저장값과 상수 시간 비교.
|
||
|
|
* 웹메이커(lib/auth.js hashPassword / lib/modules.js hashPw)는 crypto.scryptSync(pw, salt, 64) 를 쓰며 salt 는 16진수 "문자열" 그대로 입력합니다(디코딩하지 않음).
|
||
|
|
*/
|
||
|
|
export function verifyScrypt(password, salt, storedHex) {
|
||
|
|
return new Promise((resolve) => {
|
||
|
|
if (!salt || !storedHex) return resolve(false);
|
||
|
|
scrypt(String(password ?? ''), String(salt), 64, (err, key) => {
|
||
|
|
if (err) return resolve(false);
|
||
|
|
let stored; try { stored = Buffer.from(String(storedHex), 'hex'); } catch { return resolve(false); }
|
||
|
|
resolve(stored.length === key.length && timingSafeEqual(stored, key));
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
// 없는 계정의 로그인에도 실제 scrypt 를 한 번 돌려 걸리는 시간을 맞춥니다(계정 존재 여부가 응답 시간으로 새지 않게).
|
||
|
|
const DUMMY_SALT = randomBytes(16).toString('hex');
|
||
|
|
const dummyVerify = () => verifyScrypt(randomBytes(8).toString('hex'), DUMMY_SALT, '00'.repeat(64));
|
||
|
|
|
||
|
|
// ------------------------------------------------------------------ 스냅샷 읽기(캐시·sudo 도우미)
|
||
|
|
const cache = new Map(); // "<dir>|<user>" → { at, data, loading }
|
||
|
|
/** sudo 도우미로 스냅샷을 받습니다(JSON 표준 출력). 실패 사유(권한 없음·도우미 없음·JSON 아님)는 오류 메시지에 담습니다. */
|
||
|
|
function viaHelper(dir, user) {
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
execFile('sudo', ['-n', '-u', user, WEBMAKER_HELPER, dir], { timeout: 15000, maxBuffer: 64 * 1024 * 1024 }, (err, stdout, stderr) => {
|
||
|
|
if (err) return reject(new Error(`webmaker_helper_failed: ${String(stderr || err.message).trim().split('\n').pop()}`));
|
||
|
|
try { resolve(JSON.parse(stdout)); } catch { reject(new Error('webmaker_helper_bad_output')); }
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
/** systemd 소켓 도우미로 스냅샷을 받습니다: 접속하면 도우미가 JSON 을 쓰고 끊습니다(끝까지 읽어 파싱). */
|
||
|
|
function viaSocket(path) {
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
const sock = connect(path); let buf = '';
|
||
|
|
sock.setEncoding('utf8'); sock.setTimeout(15000, () => sock.destroy(new Error('timeout')));
|
||
|
|
sock.on('data', (d) => { buf += d; });
|
||
|
|
sock.on('end', () => { try { resolve(JSON.parse(buf)); } catch { reject(new Error('webmaker_socket_bad_output')); } });
|
||
|
|
sock.on('error', (e) => reject(new Error(`webmaker_socket_failed: ${e.message}`)));
|
||
|
|
});
|
||
|
|
}
|
||
|
|
async function fetchSnapshot(env) {
|
||
|
|
if (env.WEBMAKER_USER) {
|
||
|
|
// 소켓 도우미(시험에서는 WEBMAKER_SOCKET 으로 경로 지정)가 있으면 그것, 없으면 sudo 도우미.
|
||
|
|
const sock = env.WEBMAKER_SOCKET || webmakerSocketPath(env.WEBMAKER_DIR);
|
||
|
|
if (existsSync(sock)) return viaSocket(sock);
|
||
|
|
return viaHelper(env.WEBMAKER_DIR, env.WEBMAKER_USER);
|
||
|
|
}
|
||
|
|
return readSnapshotSync(env.WEBMAKER_DIR);
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* 스냅샷(캐시 3초). fresh 면 캐시를 무시하고 다시 읽습니다(로그인 실패 뒤 재확인용).
|
||
|
|
* 읽기에 실패했는데 예전 스냅샷이 있으면 그것을 돌려주고 경고를 남깁니다(한 번만). 예전 것도 없으면 오류를 던집니다 → 호출자는 "웹메이커 계정 없음" 으로 처리.
|
||
|
|
*/
|
||
|
|
export async function snapshot(env, { fresh = false } = {}) {
|
||
|
|
const key = `${env.WEBMAKER_DIR}|${env.WEBMAKER_USER || ''}`;
|
||
|
|
let c = cache.get(key); if (!c) { c = { at: 0, data: null, loading: null, warned: false, freshAt: 0 }; cache.set(key, c); }
|
||
|
|
// fresh(로그인 실패 뒤 재확인)는 디렉터리마다 FRESH_MIN_MS 에 한 번만 실제로 다시 읽습니다 — 틀린 비밀번호를 퍼부어 파일 읽기(sudo 도우미 실행)를 되풀이시키지 못하게.
|
||
|
|
if (fresh && Date.now() - c.freshAt < FRESH_MIN_MS) fresh = false;
|
||
|
|
if (!fresh && c.data && Date.now() - c.at < CACHE_MS) return c.data;
|
||
|
|
// 다시 읽기를 요구했는데 이미 읽는 중이면 그 결과(변경 전일 수 있음)를 기다린 뒤 한 번 더 읽습니다.
|
||
|
|
if (fresh && c.loading) { try { await c.loading; } catch { /* 아래에서 다시 시도 */ } }
|
||
|
|
if (fresh) c.freshAt = Date.now();
|
||
|
|
if (!c.loading) {
|
||
|
|
c.loading = fetchSnapshot(env).then((d) => { c.data = d; c.at = Date.now(); c.warned = false; return d; }).catch((e) => {
|
||
|
|
if (c.data) { if (!c.warned) { c.warned = true; console.error(` ⚠️ [${env.DOMAIN || mailDomainOf(env)}] 웹메이커 계정 파일을 읽지 못해 마지막 스냅샷을 씁니다: ${e.message}`); } c.at = Date.now(); return c.data; }
|
||
|
|
throw e;
|
||
|
|
}).finally(() => { c.loading = null; });
|
||
|
|
}
|
||
|
|
return c.loading;
|
||
|
|
}
|
||
|
|
/** 시험·재시작용: 캐시 비우기. */
|
||
|
|
export function clearSnapshotCache() { cache.clear(); }
|
||
|
|
|
||
|
|
// ------------------------------------------------------------------ 도메인의 계정 목록
|
||
|
|
/** 도메인에 대응하는 웹메이커 사이트를 찾습니다(없으면 null). env.WEBMAKER_SITE(등록부의 siteId)가 우선입니다. */
|
||
|
|
export function siteFor(snap, domain, siteId = '') {
|
||
|
|
if (siteId && snap.sites[siteId]) return snap.sites[siteId];
|
||
|
|
const d = String(domain || '').toLowerCase();
|
||
|
|
if (snap.domainSites[d] && snap.sites[snap.domainSites[d]]) return snap.sites[snap.domainSites[d]];
|
||
|
|
for (const s of Object.values(snap.sites)) if (s.domains.includes(d)) return s;
|
||
|
|
for (const s of Object.values(snap.sites)) if (s.webmailDomain === d) return s;
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* 도메인의 웹메이커 계정 목록(해시 포함 — 모듈 안에서만 씀). 같은 로그인 ID 는 먼저 넣은 쪽이 이깁니다(사이트 회원 → 소유자 → 플랫폼 관리자/회원).
|
||
|
|
* @returns {Array<{id,login,name,role,mailbox,kind,salt,hash,approved}>} kind: member(사이트 회원) | owner(사이트 소유자) | admin(웹메이커 관리자) | user(플랫폼 회원)
|
||
|
|
*/
|
||
|
|
export function accountsFor(env, snap) {
|
||
|
|
const domain = mailDomainOf(env), site = siteFor(snap, domain, env.WEBMAKER_SITE || '');
|
||
|
|
const out = [], seen = new Set();
|
||
|
|
const push = (a) => { if (!LOCAL_RE.test(a.login) || seen.has(a.login)) return; seen.add(a.login); out.push({ ...a, mailbox: `${a.login}@${domain}` }); };
|
||
|
|
// 역할: 웹메이커 관리자(role=admin)만 이 도메인의 관리자(/admin·/setup — 발신 모듈·DNS 인증 정보까지 다룸). 사이트 소유자·사용자·일반 회원은 일반 계정입니다(최소 권한).
|
||
|
|
const platform = (u, kind) => push({ id: 'wm:u:' + u.id, login: u.id, name: u.name || u.id, role: u.role === 'admin' ? 'admin' : 'user', kind, salt: u.salt, hash: u.passHash, approved: true });
|
||
|
|
if (site) {
|
||
|
|
for (const m of snap.members[site.id] || []) if (m.loginId) push({ id: `wm:m:${site.id}:${m.id}`, login: m.loginId, name: m.name || m.loginId, role: 'user', kind: 'member', salt: m.salt, hash: m.hash, approved: m.approved !== false });
|
||
|
|
if (site.ownerId && snap.users[site.ownerId]) platform(snap.users[site.ownerId], 'owner');
|
||
|
|
for (const u of Object.values(snap.users)) if (u.role === 'admin') platform(u, 'admin');
|
||
|
|
} else {
|
||
|
|
for (const u of Object.values(snap.users)) platform(u, 'user');
|
||
|
|
}
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
/** 로그인 입력(아이디 또는 아이디@이 도메인)을 로그인 ID 로. 다른 도메인의 주소는 null. */
|
||
|
|
function loginKey(env, login) {
|
||
|
|
const key = String(login || '').trim().toLowerCase(); if (!key) return null;
|
||
|
|
if (!key.includes('@')) return key;
|
||
|
|
const [local, dom] = key.split('@'); return dom === mailDomainOf(env) ? local : null;
|
||
|
|
}
|
||
|
|
const principal = (a) => ({ id: a.id, source: 'webmaker', login: a.login, email: a.mailbox, name: a.name, role: a.role, mailbox: a.mailbox, kind: a.kind });
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 로그인 이름과 비밀번호로 웹메이커 계정을 검사합니다. 맞으면 주체(principal), 아니면 null.
|
||
|
|
* 캐시된 스냅샷에서 실패하면 파일을 다시 읽어 한 번 더 검사합니다(웹메이커에서 방금 바꾼 비밀번호·새 계정 반영). 스냅샷을 읽을 수 없으면 null(기록만).
|
||
|
|
*/
|
||
|
|
export async function verifyWebmaker(env, login, password) {
|
||
|
|
const key = loginKey(env, login); if (!key || !password) return null;
|
||
|
|
let snap; try { snap = await snapshot(env); } catch (e) { console.error(` ✖ [${env.DOMAIN || mailDomainOf(env)}] 웹메이커 계정 파일을 읽을 수 없습니다: ${e.message}`); await dummyVerify(); return null; }
|
||
|
|
// 계정이 있든 없든(승인 취소 포함) 시도마다 scrypt 를 꼭 한 번 돌려, 걸리는 시간으로 계정 존재 여부가 새지 않게 합니다(두 시도 모두 같은 횟수).
|
||
|
|
const check = async (a) => (a && a.approved ? verifyScrypt(password, a.salt, a.hash) : dummyVerify().then(() => false));
|
||
|
|
let a = accountsFor(env, snap).find((x) => x.login === key);
|
||
|
|
if (await check(a)) return principal(a);
|
||
|
|
// 두 번째 시도: 방금 바뀐 파일. 캐시가 아주 새것이어도(3초) 비밀번호 변경 직후의 로그인을 놓치지 않게 한 번은 다시 읽습니다.
|
||
|
|
try { snap = await snapshot(env, { fresh: true }); } catch { await dummyVerify(); return null; }
|
||
|
|
a = accountsFor(env, snap).find((x) => x.login === key);
|
||
|
|
if (await check(a)) return principal(a);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* 주체 id('wm:…')로 계정을 찾습니다(세션 확인용). 웹메이커에서 지워졌거나 승인이 풀린 계정은 null → 세션 종료.
|
||
|
|
* 파일을 읽지 못한 경우(예전 스냅샷도 없음)는 예외를 그대로 던집니다 — auth.currentUser 가 "일시 장애" 로 보고 세션을 지우지 않습니다.
|
||
|
|
*/
|
||
|
|
export async function webmakerPrincipalById(env, id) {
|
||
|
|
const snap = await snapshot(env);
|
||
|
|
const a = accountsFor(env, snap).find((x) => x.id === id && x.approved);
|
||
|
|
return a ? principal(a) : null;
|
||
|
|
}
|
||
|
|
/** 메일함 주소로 웹메이커 계정을 찾습니다(자동 처리·수신 판단용). */
|
||
|
|
export async function webmakerByMailbox(env, mailbox) {
|
||
|
|
let snap; try { snap = await snapshot(env); } catch { return null; }
|
||
|
|
const a = accountsFor(env, snap).find((x) => x.mailbox === String(mailbox || '').toLowerCase() && x.approved);
|
||
|
|
return a ? principal(a) : null;
|
||
|
|
}
|
||
|
|
/** 웹메이커 계정의 메일함 주소 목록(수신 허용 판단). 승인이 풀린 회원의 메일함도 넣습니다(정지된 로컬 계정과 같은 취급: 메일은 받아 둠). */
|
||
|
|
export async function webmakerMailboxes(env) {
|
||
|
|
let snap; try { snap = await snapshot(env); } catch { return []; }
|
||
|
|
return accountsFor(env, snap).map((a) => a.mailbox);
|
||
|
|
}
|
||
|
|
/** 관리자 페이지·설정 화면용 목록(해시 없음). status 는 승인 여부로 active / suspended. */
|
||
|
|
export async function webmakerAccounts(env) {
|
||
|
|
let snap; try { snap = await snapshot(env); } catch (e) { return { ok: false, error: e.message, accounts: [] }; }
|
||
|
|
const site = siteFor(snap, mailDomainOf(env), env.WEBMAKER_SITE || '');
|
||
|
|
return { ok: true, readAt: snap.readAt, site: site ? { id: site.id, title: site.title, ownerId: site.ownerId } : null, accounts: accountsFor(env, snap).map((a) => ({ id: a.id, login: a.login, name: a.name, mailbox: a.mailbox, role: a.role, kind: a.kind, status: a.approved ? 'active' : 'suspended', source: 'webmaker' })) };
|
||
|
|
}
|
||
|
|
/** 로그인 ID 가 웹메이커 계정과 겹치는지(로컬 계정을 만들 때 login_taken 으로 막기 위해). */
|
||
|
|
export async function webmakerHasLogin(env, login) {
|
||
|
|
let snap; try { snap = await snapshot(env); } catch { return false; }
|
||
|
|
const key = String(login || '').toLowerCase();
|
||
|
|
return accountsFor(env, snap).some((a) => a.login === key);
|
||
|
|
}
|