Files

417 lines
37 KiB
JavaScript
Raw Permalink Normal View History

/**
* 인증.
* - www.arihakdang.com 관리자 계정: www DB(users)를 읽기 전용으로 읽어 login_id/email + PBKDF2(www 와 같은 형식)로 검사합니다.
* 이 계정은 게이트웨이의 관리자(계정 관리 가능)이며 기본 메일함(MAILBOX)을 씁니다. 별칭 admin / admin@... 도 여기로 연결됩니다.
* - 게이트웨이 사용자 계정(mail_users): 관리자가 만들거나(0.9.0 부터는 회원 가입으로도) 생기는 계정. 자기 메일함 주소를 갖고 웹·POP·IMAP·SMTP 에 같은 비밀번호로 접속합니다.
* - 0.9.0 부터 관리자도 메일 기능에서는 자기 메일함만 봅니다(다른 메일함으로 전환하는 기능 삭제). 관리자 권한은 관리자 페이지(/admin)의 계정·설정 관리에만 쓰입니다.
* - webmaker 연동(1.0.8, LOGIN_MODE=webmaker): 웹메이커의 사용자 파일을 읽기 전용으로 참조해 같은 방식(scrypt)으로 검사합니다(server/webmaker.mjs).
* 웹메이커 계정이 먼저이고, 그다음 게이트웨이 계정(mail_users)을 봅니다. 웹·POP·IMAP·SMTP 모두 이 verifyUser 를 거치므로 어디서나 같은 비밀번호입니다.
*/
import { openDb } from './db.mjs';
import { json, now, plus, secret, sha256, safeEqual, clean, uid, isEmail, addresses } from './util.mjs';
import { systemLimits, accountCount, clearUserQuota } from './limits.mjs';
import { isWebmaker, verifyWebmaker, webmakerPrincipalById, webmakerByMailbox, webmakerMailboxes, webmakerAccounts, webmakerHasLogin } from './webmaker.mjs';
export const SESSION_COOKIE = 'mg_session';
// 관리자 페이지(/admin)는 웹 메일과 별도의 로그인·세션을 씁니다(0.8.4~). 쿠키 이름이 달라 두 로그인이 서로 영향을 주지 않습니다.
export const ADMIN_COOKIE = 'mg_admin';
// 세션 범위: 'mail' = 웹 메일(/), 'admin' = 관리자 페이지(/admin). 범위가 다른 세션 쿠키로는 상대편 API 를 쓸 수 없습니다.
export const SCOPES = ['mail', 'admin'];
const cookieName = (scope) => scope === 'admin' ? ADMIN_COOKIE : SESSION_COOKIE;
const SESSION_DAYS = 7;
/** 메일함 주소의 도메인 부분(예: arihakdang.com). 계정의 메일함은 항상 "로그인 ID@이 도메인" 입니다. */
export const mailDomain = (env) => mailboxOf(env).split('@')[1];
const mailboxOf = (env) => String(env.MAILBOX || 'admin@arihakdang.com').trim().toLowerCase();
/**
* 독립 실행(standalone) 여부(0.8.18~). www 사이트 DB(WWW_DB_FILE / 테스트의 WWW_DB)가 없으면 독립 실행입니다.
* 독립 실행에서는 www 관리자가 없으므로 기본 메일함(MAILBOX)도 게이트웨이 계정(mail_users)이 갖습니다:
* - 로그인 ID 가 기본 메일함의 앞부분(예: admin)인 계정을 만들 수 있고, 그 계정의 메일함이 곧 기본 메일함입니다.
* - 첫 관리자 계정은 화면에서 만들 수 없으므로 server/user-cli.mjs 로 만듭니다.
*/
export const standalone = (env) => !env.WWW_DB && !env.WWW_DB_FILE;
/** 로그인 방식 이름(1.0.8): 'webmaker'(웹메이커 계정 연동) | 'www'(웹 서비스 DB 연동) | 'local'(자체 계정만). 화면·API 안내용. */
export const authMode = (env) => isWebmaker(env) ? 'webmaker' : standalone(env) ? 'local' : 'www';
async function pbkdf2(password, saltB64, iterations) {
const salt = Uint8Array.from(atob(saltB64), (c) => c.charCodeAt(0));
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits']);
const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt, iterations, hash: 'SHA-256' }, key, 256);
return btoa(String.fromCharCode(...new Uint8Array(bits)));
}
export async function verifyPassword(password, stored) {
if (!stored) return false;
const [scheme, iterations, salt, digest] = String(stored).split('$');
const n = Number(iterations);
if (scheme !== 'pbkdf2' || !salt || !digest || !Number.isInteger(n) || n < 1000 || n > 5000000) return false;
return safeEqual(await pbkdf2(password, salt, n), digest);
}
// 없는 계정의 로그인 시도에 쓰는 더미 해시(프로세스마다 한 번 만들어 재사용). 반복 횟수가 실제 계정과 같아 걸리는 시간이 같습니다.
let dummy = null; const dummyHash = async () => (dummy ??= await hashPassword(crypto.randomUUID()));
export async function hashPassword(password, iterations = 210000) {
const salt = btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(16))));
return `pbkdf2$${iterations}$${salt}$${await pbkdf2(password, salt, iterations)}`;
}
// ------------------------------------------------------------------ www 관리자
// www DB 핸들은 파일 경로별로 하나씩 둡니다(1.0.7: 도메인마다 다른 웹 서비스 DB 와 연동할 수 있으므로 모듈 전역 하나로는 부족).
const wwwDbs = new Map(); // WWW_DB_FILE → DatabaseSync(읽기 전용)
function wwwHandle(env) {
if (env.WWW_DB) return env.WWW_DB;
if (!env.WWW_DB_FILE) throw new Error('www_db_not_configured');
let db = wwwDbs.get(env.WWW_DB_FILE);
if (!db) { db = openDb(env.WWW_DB_FILE, { readOnly: true }); wwwDbs.set(env.WWW_DB_FILE, db); }
return db;
}
function query(env, sql, params) {
try { return wwwHandle(env).prepare(sql).get(...params); }
catch {
// 배포 중 www DB 파일이 교체되면 핸들이 깨질 수 있어 한 번 닫고 다시 엽니다.
if (!env.WWW_DB && env.WWW_DB_FILE) { try { wwwDbs.get(env.WWW_DB_FILE)?.close(); } catch {} wwwDbs.delete(env.WWW_DB_FILE); }
return wwwHandle(env).prepare(sql).get(...params);
}
}
const COLS = 'SELECT id,email,login_id,name,role,status,password_hash FROM users';
/** 메일함 주소(admin@...)와 그 앞부분(admin)은 메일함 소유 관리자 계정의 별칭입니다.
* 소유자는 MAILBOX_OWNER_LOGIN → 이메일이 메일함 주소인 관리자 → 유일한 활성 관리자 순입니다. */
export function mailboxOwner(env) {
const mailbox = mailboxOf(env), configured = clean(env.MAILBOX_OWNER_LOGIN, 200).toLowerCase();
if (configured) return query(env, `${COLS} WHERE ${configured.includes('@') ? 'email' : 'login_id'} = ?`, [configured]) || null;
const byEmail = query(env, `${COLS} WHERE email = ?`, [mailbox]); if (byEmail) return byEmail;
const admins = wwwHandle(env).prepare(`${COLS} WHERE role = 'admin' AND status = 'active'`).all();
return admins.length === 1 ? admins[0] : null;
}
export function isMailboxAlias(env, login) { const mailbox = mailboxOf(env), key = clean(login, 200).toLowerCase(); return key === mailbox || key === mailbox.split('@')[0]; }
const wwwPrincipal = (row, env) => ({ id: 'www:' + row.id, source: 'www', login: row.login_id || row.email, email: row.email, name: row.name || '', role: 'admin', mailbox: mailboxOf(env) });
const localPrincipal = (row) => ({ id: row.id, source: 'local', login: row.login, email: row.mailbox, name: row.name || '', role: row.role, mailbox: row.mailbox });
/** 로그인 이름과 비밀번호로 사용자를 찾습니다. www 관리자(별칭 포함) → 게이트웨이 계정 순. 실패 사유는 나누지 않습니다. */
export async function verifyUser(env, login, password) {
const key = clean(login, 200).toLowerCase();
if (!key || !password) return null;
// webmaker 연동(1.0.8): 웹메이커 계정이 먼저입니다. 같은 로그인 ID 의 게이트웨이 계정이 있어도(createUser 가 막지만 연동 전에 만든 것) 웹메이커 쪽이 그 주소의 주인입니다.
// 웹메이커 계정이 아니거나 비밀번호가 틀리면 아래 게이트웨이 계정으로 넘어갑니다(관리자가 따로 만든 로컬 계정도 함께 쓸 수 있게).
if (isWebmaker(env)) { const wm = await verifyWebmaker(env, key, password); if (wm) return wm; }
let row = null;
try {
row = isMailboxAlias(env, key) ? mailboxOwner(env) : key.includes('@') ? query(env, `${COLS} WHERE email = ?`, [key]) : query(env, `${COLS} WHERE login_id = ?`, [key]);
} catch (e) { if (e.message !== 'www_db_not_configured') throw e; }
if (row && row.role === 'admin' && row.status === 'active' && (await verifyPassword(password, row.password_hash))) return wwwPrincipal(row, env);
const local = await env.DB.prepare('SELECT * FROM mail_users WHERE (login = ? OR mailbox = ?) AND status = ?').bind(key, key, 'active').first();
if (local && (await verifyPassword(password, local.password_hash))) { await env.DB.prepare('UPDATE mail_users SET last_login_at = ? WHERE id = ?').bind(now(), local.id).run(); return localPrincipal(local); }
// 시간 차이로 계정 존재 여부가 드러나지 않게 합니다. 예전에는 verifyPassword(password, null) 을 불렀는데 저장값이 없으면 해시 없이 바로 false 라
// 없는 계정은 0.2ms, 있는 계정은 100ms 넘게 걸려 로그인 ID 를 추측할 수 있었습니다(0.8.24). 이제 더미 해시로 실제 PBKDF2 를 돕니다.
if (!row && !local) await verifyPassword(password, await dummyHash());
return null;
}
/** @deprecated verifyUser 로 대체. 기존 호출부 호환. */
export const verifyAdmin = verifyUser;
export async function principalById(env, id) {
if (String(id).startsWith('wm:')) return isWebmaker(env) ? await webmakerPrincipalById(env, String(id)) : null;
if (String(id).startsWith('www:')) { const row = query(env, `${COLS} WHERE id = ?`, [String(id).slice(4)]); return row && row.role === 'admin' && row.status === 'active' ? wwwPrincipal(row, env) : null; }
const local = await env.DB.prepare("SELECT * FROM mail_users WHERE id = ? AND status = 'active'").bind(id).first();
return local ? localPrincipal(local) : null;
}
// ------------------------------------------------------------------ 비밀번호 정책 (0.8.24~)
// 예전에는 "10자 이상" 이 코드에 고정돼 있었습니다. 이제 관리자가 관리자 페이지에서 정하며 settings 표의 password_policy 키에 JSON 으로 저장합니다.
// minLength : 최소 글자 수. 6 ~ 64. 기본 6 (요구사항: 최소 6자 이상, 관리자가 조정)
// mixedCase : true 면 영문 대문자와 소문자를 모두 포함해야 함
// digit : true 면 숫자를 포함해야 함
// special : true 면 특수문자(영문·숫자·공백이 아닌 글자)를 포함해야 함
export const DEFAULT_PASSWORD_POLICY = { minLength: 6, mixedCase: false, digit: false, special: false };
export const PASSWORD_MIN_FLOOR = 6, PASSWORD_MIN_CEIL = 64;
/** 독립 실행의 초기 관리자 비밀번호. 정책과 무관하게 글자 수 하한(6)만 만족하면 되고, 로그인 뒤 바꾸라고 안내합니다. */
export const INITIAL_ADMIN_PASSWORD = 'adminpasswd';
const POLICY_KEY = 'password_policy';
/** 저장된 정책을 기본값과 합쳐 돌려줍니다. 손상된 값은 기본값으로 대체합니다. */
export async function readPasswordPolicy(env) {
const row = await env.DB.prepare('SELECT value FROM settings WHERE key=?').bind(POLICY_KEY).first();
let stored = {}; try { stored = row ? JSON.parse(row.value) : {}; } catch { stored = {}; }
const p = { ...DEFAULT_PASSWORD_POLICY, ...(stored && typeof stored === 'object' ? stored : {}) };
p.minLength = Number.isInteger(p.minLength) && p.minLength >= PASSWORD_MIN_FLOOR && p.minLength <= PASSWORD_MIN_CEIL ? p.minLength : DEFAULT_PASSWORD_POLICY.minLength;
for (const k of ['mixedCase', 'digit', 'special']) p[k] = !!p[k];
return p;
}
/**
* 정책을 저장합니다(관리자 전용). minLength 는 6~64 의 정수여야 하며(아니면 policy_invalid), 나머지는 불리언으로 바꿉니다.
* 이미 있는 계정의 비밀번호에는 소급 적용하지 않습니다(새 비밀번호를 정할 때만 검사).
*/
export async function writePasswordPolicy(env, patch) {
const current = await readPasswordPolicy(env), next = { ...current };
if (patch.minLength !== undefined) { const n = Number(patch.minLength); if (!Number.isInteger(n) || n < PASSWORD_MIN_FLOOR || n > PASSWORD_MIN_CEIL) throw new Error('policy_invalid'); next.minLength = n; }
for (const k of ['mixedCase', 'digit', 'special']) if (patch[k] !== undefined) next[k] = !!patch[k];
await env.DB.prepare('INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at').bind(POLICY_KEY, JSON.stringify(next), now()).run();
return next;
}
/** 비밀번호가 정책에 어긋나는 항목 목록(비어 있으면 통과): 'length' | 'mixedCase' | 'digit' | 'special'. 화면이 같은 규칙으로 안내문을 만듭니다. */
export function passwordProblems(password, policy) {
const pw = String(password ?? ''), problems = [];
if ([...pw].length < policy.minLength) problems.push('length');
if (policy.mixedCase && !(/[A-Z]/.test(pw) && /[a-z]/.test(pw))) problems.push('mixedCase');
if (policy.digit && !/[0-9]/.test(pw)) problems.push('digit');
if (policy.special && !/[^A-Za-z0-9\s]/.test(pw)) problems.push('special');
return problems;
}
/** 정책 검사. 어긋나면 weak_password 오류를 던지며, 오류 객체에 problems(어긋난 항목)와 policy 를 붙여 API 가 화면에 그대로 전달합니다. */
async function checkPassword(env, password) {
const policy = await readPasswordPolicy(env), problems = passwordProblems(password, policy);
if (problems.length) { const e = new Error('weak_password'); e.problems = problems; e.policy = policy; throw e; }
}
// 초기 비밀번호 표시: settings 의 initial_password:<계정 id> 키가 있으면 그 계정은 아직 초기 비밀번호를 씁니다. 비밀번호를 바꾸면 지웁니다.
const initialKey = (id) => 'initial_password:' + id;
export async function usesInitialPassword(env, user) { return !!(await env.DB.prepare('SELECT 1 AS x FROM settings WHERE key=?').bind(initialKey(user.id)).first()); }
/**
* 독립 실행의 초기 관리자 계정(0.8.24~). 서버가 시작할 때 부르며, 독립 실행이고 게이트웨이 계정이 하나도 없을 때만
* 기본 메일함의 앞부분(보통 admin)을 로그인 ID 로, 비밀번호 adminpasswd 로 관리자 계정을 만듭니다.
* 예전에는 첫 관리자를 user-cli 로만 만들 수 있었고 "10자 이상" 규칙 때문에 admin 같은 짧은 초기 비밀번호가 거부돼 설치가 막혔습니다.
* @param {object} [o] password: 초기 비밀번호(기본 adminpasswd; 1.0.7 도메인 추가 폼에서 지정)
* @returns {Promise<{login:string, password:string}|null>} 만들었으면 계정 정보(시작 로그에 안내용), 아니면 null
*/
export async function ensureInitialAdmin(env, { password = INITIAL_ADMIN_PASSWORD } = {}) {
// webmaker 연동(1.0.8)에서는 웹메이커의 관리자(admin)가 곧 이 도메인의 관리자이므로 adminpasswd 계정을 만들지 않습니다.
if (!standalone(env) || isWebmaker(env)) return null;
const count = await env.DB.prepare('SELECT COUNT(*) AS n FROM mail_users').first();
if (Number(count?.n || 0) > 0) return null;
const login = mailboxOf(env).split('@')[0];
// password(1.0.7): 전체 설정 화면에서 도메인을 추가할 때 지정한 초기 비밀번호. 기본값은 예전과 같은 adminpasswd.
const id = await createUser(env, { login, name: '관리자', password, role: 'admin' }, 'system', { initial: true });
await audit(env, 'system', 'user_create', id, { login, initial: true });
return { login, password };
}
// ------------------------------------------------------------------ 게이트웨이 계정 관리 (관리자 전용)
const LOGIN_RE = /^[a-z0-9][a-z0-9._-]{1,63}$/;
// 승인 대기(0.9.0~): 회원 가입으로 만들어졌지만 관리자 승인 전인 계정. mail_users.status 는 'suspended'(로그인 불가)로 두고
// settings 의 pending:<계정 id> 키로 "정지" 가 아니라 "승인 대기" 임을 표시합니다. 스키마의 status 제약(active/suspended)을 바꾸지 않으려는 선택이며,
// 로그인·세션·메일함 주인 판단은 모두 status='active' 만 보므로 대기 계정은 어디서도 들어올 수 없습니다. 승인 = status 를 active 로 바꾸고 이 키를 지움.
const pendingKey = (id) => 'pending:' + id;
/** 계정 목록. 승인 대기 계정은 status 를 'pending' 으로 바꿔 돌려줍니다(화면·API 용; DB 값은 suspended). */
export async function listUsers(env) {
const rows = (await env.DB.prepare(`SELECT u.id,u.login,u.name,u.mailbox,u.role,CASE WHEN p.key IS NULL THEN u.status ELSE 'pending' END AS status,u.created_by,u.created_at,u.updated_at,u.last_login_at
FROM mail_users u LEFT JOIN settings p ON p.key = 'pending:' || u.id ORDER BY u.created_at`).all()).results;
return rows;
}
/**
* 게이트웨이 계정을 만듭니다(관리자 페이지·회원 가입·명령줄 도구).
* 메일함 주소는 따로 받지 않고 항상 "로그인 ID@도메인"(예: info → info@arihakdang.com)으로 정합니다(0.8.4~).
* 예전에는 메일함 주소를 직접 입력해 여러 계정이 한 메일함을 함께 쓸 수 있었지만, 계정마다 자기 주소를 갖는 편이
* 받는 사람 주소와 로그인 ID 가 일치해 혼동이 없습니다. 기존 계정의 메일함 주소는 바꾸지 않습니다.
* 비밀번호는 관리자가 정한 정책(readPasswordPolicy)으로 검사합니다(0.8.24~, 예전에는 10자 이상 고정).
* 시스템 설정의 계정 수 제한(LIMIT_ACCOUNTS, 0.9.0~)에 이르렀으면 account_limit_reached 를 던집니다(정지·대기 계정도 셉니다).
* @param {object} body { login, name?, password, role?, status? } — body.mailbox 는 무시합니다.
* @param {string} actor 만든 주체(관리자 로그인 이름, 'signup', 'user-cli', 'system')(기록용)
* @param {object} [options]
* @param {boolean} [options.initial] 초기 관리자 계정: 정책 대신 글자 수 하한(6)만 보고, 초기 비밀번호 표시를 남깁니다.
* @param {boolean} [options.pending] 회원 가입 승인 대기 계정으로 만듭니다(관리자가 승인하기 전에는 로그인 불가).
* @returns {Promise<string>} 새 계정 id
*/
export async function createUser(env, body, actor, { initial = false, pending = false } = {}) {
const login = clean(body.login, 64).toLowerCase(), password = String(body.password ?? '');
// 기본 메일함의 별칭(admin)은 www 관리자 몫이라 막지만, 독립 실행에서는 그 메일함을 가질 게이트웨이 계정이 필요하므로 허용합니다.
if (!LOGIN_RE.test(login) || (isMailboxAlias(env, login) && !standalone(env))) throw new Error('invalid_login');
const mailbox = `${login}@${mailDomain(env)}`;
if (!isEmail(mailbox)) throw new Error('invalid_login');
if (initial) { if ([...password].length < PASSWORD_MIN_FLOOR) throw new Error('weak_password'); } else await checkPassword(env, password);
if (await env.DB.prepare('SELECT id FROM mail_users WHERE login = ?').bind(login).first()) throw new Error('login_taken');
// webmaker 연동(1.0.8): 웹메이커 계정과 같은 로그인 ID 는 만들 수 없습니다(그 주소의 주인은 웹메이커 계정이고 verifyUser 도 그쪽을 먼저 봄).
if (isWebmaker(env) && (await webmakerHasLogin(env, login))) throw new Error('login_taken');
const limit = systemLimits(env).accounts;
if (limit > 0 && (await accountCount(env)) >= limit) { const e = new Error('account_limit_reached'); e.limit = limit; throw e; }
const id = uid(), stamp = now();
await env.DB.prepare('INSERT INTO mail_users(id,login,name,mailbox,role,status,password_hash,created_by,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)')
.bind(id, login, clean(body.name, 80), mailbox, body.role === 'admin' ? 'admin' : 'user', pending || body.status === 'suspended' ? 'suspended' : 'active', await hashPassword(password), actor || null, stamp, stamp).run();
const mark = (key) => env.DB.prepare('INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at').bind(key, '1', stamp).run();
if (initial) await mark(initialKey(id));
if (pending) await mark(pendingKey(id));
return id;
}
/** 승인 대기 계정인지(settings 의 pending:<id> 키). */
export async function isPending(env, id) { return !!(await env.DB.prepare('SELECT 1 AS x FROM settings WHERE key=?').bind(pendingKey(id)).first()); }
/**
* 게이트웨이 계정을 수정합니다(관리자 전용). 이름·역할·상태·비밀번호만 바꿀 수 있고 메일함 주소는 로그인 ID 에 묶여 있어 바꾸지 않습니다.
* body.services({ pop, imap }) 가 있으면 그 계정의 메일 프로그램 접속 허용 여부(prefs)도 함께 저장합니다.
* body.status 를 'active' 로 두면 승인 대기 계정은 승인됩니다(대기 표시를 지움). 'pending' 은 받지 않습니다(대기는 가입 때만 생김).
*/
export async function updateUser(env, id, body) {
const row = await env.DB.prepare('SELECT * FROM mail_users WHERE id = ?').bind(id).first(); if (!row) throw new Error('not_found');
// 역할·상태는 아는 값만 받습니다. 예전에는 'pending' 같은 다른 값이 모두 'active' 로 바뀌어 승인 대기 계정이 뜻하지 않게 승인됐습니다(0.9.3).
if (body.role !== undefined && !['admin', 'user'].includes(body.role)) throw new Error('validation');
if (body.status !== undefined && !['active', 'suspended'].includes(body.status)) throw new Error('validation');
const next = { name: body.name !== undefined ? clean(body.name, 80) : row.name, mailbox: row.mailbox, role: body.role !== undefined ? body.role : row.role, status: body.status !== undefined ? body.status : row.status };
if (body.services && typeof body.services === 'object') await writePrefs(env, { id }, body.services);
let hash = row.password_hash;
if (body.password !== undefined && body.password !== '') { await checkPassword(env, String(body.password)); hash = await hashPassword(String(body.password)); }
await env.DB.prepare('UPDATE mail_users SET name=?,role=?,status=?,password_hash=?,updated_at=? WHERE id=?').bind(next.name, next.role, next.status, hash, now(), id).run();
if (hash !== row.password_hash || next.status === 'suspended') await env.DB.prepare('DELETE FROM sessions WHERE user_id = ?').bind(id).run();
// 비밀번호를 바꿨으면 "초기 비밀번호 사용 중" 표시를 지웁니다(initial_password:<id>).
if (hash !== row.password_hash) await env.DB.prepare('DELETE FROM settings WHERE key = ?').bind(initialKey(id)).run();
// 상태를 명시해 바꾸면 승인 대기 표시를 지웁니다: '사용' = 승인, '정지' = 대기가 아니라 정지 계정으로 둠(예전에는 정지로 바꿔도 "승인 대기" 로 남았습니다, 0.9.3).
if (body.status !== undefined) await env.DB.prepare('DELETE FROM settings WHERE key = ?').bind(pendingKey(id)).run();
}
/**
* 로그인에 실패한 뒤 "승인 대기 계정이라서" 인지 알아봅니다(0.9.0~). 로그인 ID 와 비밀번호가 모두 맞는 대기 계정일 때만 true 이므로
* 계정 존재 여부가 새어 나가지 않습니다. API 는 이 경우 invalid_credentials 대신 pending_approval(403)을 돌려줍니다.
*/
export async function pendingLogin(env, login, password) {
const key = clean(login, 200).toLowerCase(); if (!key || !password) return false;
const row = await env.DB.prepare("SELECT u.id,u.password_hash FROM mail_users u JOIN settings p ON p.key = 'pending:' || u.id WHERE (u.login = ? OR u.mailbox = ?) AND u.status = 'suspended'").bind(key, key).first();
return !!row && (await verifyPassword(password, row.password_hash));
}
/**
* 자기 비밀번호를 바꿀 때 현재 비밀번호가 맞는지 확인합니다(0.8.25~). 게이트웨이 계정만 대상입니다(www 관리자는 www 사이트에서 관리).
* 세션을 훔친 사람이 비밀번호를 바꿔 계정을 가로채지 못하게 하는 장치이므로, 틀리면 false 만 돌려주고 사유는 나누지 않습니다.
*/
export async function checkCurrentPassword(env, id, password) {
const row = await env.DB.prepare('SELECT password_hash FROM mail_users WHERE id = ?').bind(id).first();
return !!row && (await verifyPassword(String(password ?? ''), row.password_hash));
}
/** 계정을 지웁니다(회원 가입 거절에도 씀). 세션과 계정에 딸린 설정 표시(초기 비밀번호·승인 대기·메일함 크기)도 함께 지웁니다. 메일함의 메일은 남습니다. */
export async function deleteUser(env, id) {
const r = await env.DB.prepare('DELETE FROM mail_users WHERE id = ?').bind(id).run(); if (!r.meta.changes) throw new Error('not_found');
await env.DB.prepare('DELETE FROM sessions WHERE user_id = ?').bind(id).run();
await env.DB.prepare('DELETE FROM settings WHERE key IN (?,?,?)').bind(initialKey(id), pendingKey(id), 'prefs:' + id).run(); // prefs(전달·자동 응답 등)도 함께 지움(0.9.3)
await clearUserQuota(env, id);
}
/**
* 수신을 받아들일 메일함 주소 목록: 기본 메일함 + 모든 계정(정지 포함)의 메일함.
* 예전에는 활성 계정만 넣어, 계정을 정지하면 그 주소로 오는 메일이 'other_mailbox' 로 버려졌고(동기화 창을 벗어나면 복구 불가) 정지를 풀어도 돌아오지 않았습니다(0.8.24).
* 정지는 로그인을 막는 것이지 메일함을 없애는 것이 아니므로, 메일은 계속 저장하고 관리자는 그 메일함을 볼 수 있습니다.
*/
export async function knownMailboxes(env) {
const rows = (await env.DB.prepare("SELECT DISTINCT mailbox FROM mail_users").all()).results;
// webmaker 연동(1.0.8): 웹메이커 계정의 메일함(아이디@도메인)도 받는 주소입니다. 파일을 읽지 못하면 빈 목록(마지막 스냅샷이 있으면 그것).
const external = isWebmaker(env) ? await webmakerMailboxes(env) : [];
return [...new Set([mailboxOf(env), ...rows.map((r) => r.mailbox), ...external])];
}
/**
* 외부(웹메이커) 계정 목록(1.0.8) — 관리자 페이지에 읽기 전용 행으로 보여 주는 용도. 해시는 없습니다.
* @returns {Promise<{ok:boolean, error?:string, site?:object, accounts:object[]}>} webmaker 모드가 아니면 ok:true 에 빈 목록
*/
export async function externalUsers(env) { return isWebmaker(env) ? webmakerAccounts(env) : { ok: true, accounts: [] }; }
// ------------------------------------------------------------------ 사용자별 설정(prefs)
// settings 테이블에 prefs:<사용자 id> 로 저장합니다. www 관리자(id 'www:…')와 게이트웨이 계정 모두 같은 방식입니다.
// remoteImages: 메일을 열 때 외부 이미지를 바로 표시할지
// pop / imap: 이 계정으로 POP3 / IMAP 서버에 접속할 수 있는지(0.8.4~). 끄면 메일 프로그램 로그인을 거부합니다.
// forward: 메일 전달(0.8.16~). enabled 면 이 메일함으로 받은 메일을 전부 to(주소 목록, 최대 5개)로 전달합니다. 원본은 메일함에 남습니다.
// autoReply: 자동 응답(0.8.16~). enabled 면 받은 메일에 message 로 자동 회신합니다. noLimit 이면 기한 없이, 아니면 start~end(ISO 시각) 동안만.
// subject 가 비어 있으면 "Re: 원래 제목" 으로 보냅니다. 실제 판단은 automation.mjs 의 autoReplyActive() 가 합니다.
export const DEFAULT_PREFS = {
remoteImages: false, pop: true, imap: true,
forward: { enabled: false, to: '' },
autoReply: { enabled: false, noLimit: true, start: '', end: '', subject: '', message: '' },
};
// 전달 주소 수·자동 응답 글자 수 상한. 전달은 받은 메일마다 발송이 생기므로 주소를 적게 두고, 메시지는 화면 입력 상자 기준으로 넉넉히 둡니다.
export const FORWARD_MAX = 5, AUTOREPLY_MESSAGE_MAX = 5000, AUTOREPLY_SUBJECT_MAX = 200;
/** 사용자(id 만 있어도 됨)의 설정을 기본값과 합쳐 돌려줍니다. 중첩 설정(forward·autoReply)도 키마다 기본값을 채웁니다. */
export async function readPrefs(env, user) {
const row = await env.DB.prepare('SELECT value FROM settings WHERE key=?').bind('prefs:' + user.id).first();
let stored = {}; try { stored = row ? JSON.parse(row.value) : {}; } catch { stored = {}; }
if (!stored || typeof stored !== 'object') stored = {};
return {
...DEFAULT_PREFS, ...stored,
forward: { ...DEFAULT_PREFS.forward, ...(stored.forward && typeof stored.forward === 'object' ? stored.forward : {}) },
autoReply: { ...DEFAULT_PREFS.autoReply, ...(stored.autoReply && typeof stored.autoReply === 'object' ? stored.autoReply : {}) },
};
}
/** ISO 시각이면 정규화한 ISO 문자열, 비어 있으면 '', 해석할 수 없으면 null. */
const isoOrEmpty = (v) => { if (v === undefined || v === null || v === '') return ''; const ms = Date.parse(String(v)); return Number.isFinite(ms) ? new Date(ms).toISOString() : null; };
/**
* 설정 중 아는 키만 골라 저장합니다. 불리언 설정은 불리언으로 바꾸고, forward·autoReply 는 검사한 뒤 정리된 값으로 저장합니다.
* 검사 실패는 예외로 알립니다(API 가 422 로 돌려줌):
* forward_invalid — 전달을 켰는데 주소가 없거나 형식이 틀리거나 5개를 넘거나 자기 메일함 주소를 넣음
* autoreply_message — 자동 응답을 켰는데 메시지가 비어 있음
* autoreply_period — 기한 제한을 두었는데 종료 시각이 없거나, 시각 형식이 틀리거나, 종료가 시작보다 앞섬
* 돌려주는 값은 저장 후의 전체 설정입니다.
* @param {object} user { id, mailbox? } — mailbox 가 있으면 전달 주소에 자기 메일함을 넣지 못하게 막습니다(전달 고리 방지).
*/
export async function writePrefs(env, user, patch) {
const current = await readPrefs(env, user);
const next = { ...current };
for (const key of ['remoteImages', 'pop', 'imap']) if (patch[key] !== undefined) next[key] = !!patch[key];
if (patch.forward && typeof patch.forward === 'object') {
const f = patch.forward, enabled = f.enabled !== undefined ? !!f.enabled : current.forward.enabled;
const to = f.to !== undefined ? addresses(clean(f.to, 1000)) : addresses(current.forward.to);
if (enabled && (!to.length || to.length > FORWARD_MAX || to.some((a) => !isEmail(a)) || (user.mailbox && to.includes(String(user.mailbox).toLowerCase())))) throw new Error('forward_invalid');
if (!enabled && to.some((a) => !isEmail(a))) throw new Error('forward_invalid');
next.forward = { enabled, to: to.join(', ') };
}
if (patch.autoReply && typeof patch.autoReply === 'object') {
const a = patch.autoReply, base = current.autoReply;
const enabled = a.enabled !== undefined ? !!a.enabled : base.enabled, noLimit = a.noLimit !== undefined ? !!a.noLimit : base.noLimit;
const start = a.start !== undefined ? isoOrEmpty(a.start) : base.start, end = a.end !== undefined ? isoOrEmpty(a.end) : base.end;
const subject = a.subject !== undefined ? clean(a.subject, AUTOREPLY_SUBJECT_MAX) : base.subject;
const message = a.message !== undefined ? String(a.message ?? '').replace(/\r\n?/g, '\n').trim().slice(0, AUTOREPLY_MESSAGE_MAX) : base.message;
if (start === null || end === null) throw new Error('autoreply_period');
if (enabled && !message) throw new Error('autoreply_message');
// 기한 제한이 있으면 종료 시각은 꼭 있어야 하고(시작은 비우면 "지금부터"), 종료가 시작보다 앞서면 기간이 성립하지 않습니다.
if (enabled && !noLimit && (!end || (start && Date.parse(end) <= Date.parse(start)))) throw new Error('autoreply_period');
next.autoReply = { enabled, noLimit, start, end, subject, message };
}
await env.DB.prepare('INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at').bind('prefs:' + user.id, JSON.stringify(next), now()).run();
return next;
}
/**
* 메일함 주소로 그 메일함을 쓰는 사용자(설정의 주인)를 찾습니다. 받은 메일을 자동 처리(전달·자동 응답)할 때 메일함의 설정을 읽는 데 씁니다.
* 기본 메일함(MAILBOX)은 www 관리자(mailboxOwner), 그 밖의 주소는 활성 게이트웨이 계정입니다. 주인이 없으면 null.
* 독립 실행(standalone)에서는 기본 메일함도 게이트웨이 계정(로그인 ID 가 메일함 앞부분인 계정)이 주인입니다.
*/
export async function mailboxUser(env, mailbox) {
const box = String(mailbox || '').trim().toLowerCase();
if (!box) return null;
// webmaker 연동(1.0.8): 웹메이커 계정의 메일함이면 그 계정(설정 prefs 는 'wm:…' id 로 저장됨).
if (isWebmaker(env)) { const wm = await webmakerByMailbox(env, box); if (wm) return wm; }
// 기본 메일함은 www 관리자 몫입니다. 독립 실행(www DB 없음)이면 아래의 게이트웨이 계정 조회로 넘어갑니다.
if (box === mailboxOf(env) && !standalone(env)) { try { const row = mailboxOwner(env); return row ? wwwPrincipal(row, env) : null; } catch { return null; } }
const local = await env.DB.prepare("SELECT * FROM mail_users WHERE mailbox = ? AND status = 'active' ORDER BY created_at LIMIT 1").bind(box).first();
return local ? localPrincipal(local) : null;
}
/**
* 메일 프로그램(POP3/IMAP) 서버가 로그인 성공 직후 부르는 검사. 계정 설정에서 그 서비스를 꺼 두었으면 false.
* @param {'pop'|'imap'} service
*/
export async function serviceAllowed(env, user, service) { return (await readPrefs(env, user))[service] !== false; }
// ------------------------------------------------------------------ 세션
/**
* 세션을 만들고 쿠키에 넣을 토큰을 돌려줍니다. DB 에는 토큰의 SHA-256 만 저장합니다.
* @param {'mail'|'admin'} [scope] 세션 범위. 'admin' 은 관리자 페이지(/admin) 전용이며 관리자 역할 계정만 만들 수 있습니다.
*/
export async function createSession(env, user, request, { scope = 'mail' } = {}) {
if (!SCOPES.includes(scope)) throw new Error('bad_request');
if (scope === 'admin' && user.role !== 'admin') throw new Error('forbidden');
const token = secret(32);
await env.DB.prepare('INSERT INTO sessions (id,user_id,login,expires_at,created_at,ip,ua,scope) VALUES (?,?,?,?,?,?,?,?)')
.bind(await sha256(token), user.id, user.login, plus(SESSION_DAYS * 86400000), now(), request.headers.get('CF-Connecting-IP') || request.headers.get('X-Forwarded-For') || '', (request.headers.get('User-Agent') || '').slice(0, 300), scope).run();
return token;
}
/**
* Set-Cookie 값. scope 에 따라 쿠키 이름이 다릅니다(mg_session / mg_admin). clear 면 즉시 만료시킵니다.
* path(1.0.8): 경로 접두사(/mail) 아래의 웹 메일이면 Path 를 그 접두사로 한정합니다(같은 호스트의 사이트 쪽 요청에 세션 쿠키가 실리지 않게). 보통은 '/'.
*/
export function sessionCookie(token, { clear = false, scope = 'mail', path = '' } = {}) {
return [`${cookieName(scope)}=${clear ? '' : token}`, `Path=${path || '/'}`, 'HttpOnly', 'Secure', 'SameSite=Lax', clear ? 'Max-Age=0' : `Max-Age=${SESSION_DAYS * 86400}`].join('; ');
}
function readCookie(request, name) { for (const part of (request.headers.get('Cookie') || '').split(';')) { const [k, ...v] = part.trim().split('='); if (k === name) return v.join('='); } return ''; }
/**
* 요청의 세션 쿠키로 사용자를 찾습니다. 범위(scope)가 맞는 세션만 인정합니다.
* 관리자 범위에서는 지금도 관리자 역할인지 다시 확인합니다(세션이 살아 있는 동안 역할이 바뀌었을 수 있음).
*/
export async function currentUser(env, request, { scope = 'mail' } = {}) {
const token = readCookie(request, cookieName(scope)); if (!token) return null;
const hash = await sha256(token);
const row = await env.DB.prepare('SELECT * FROM sessions WHERE id = ? AND scope = ?').bind(hash, scope).first(); if (!row) return null;
let principal = null;
// 사용자 조회가 일시적으로 실패하면(배포 중 www DB 교체, SQLITE_BUSY) 세션을 지우지 않고 이번 요청만 비로그인으로 처리합니다.
// 예전에는 이런 예외에도 세션을 지워 관리자가 영문 없이 로그아웃됐습니다. 만료·계정 없음·역할 불일치일 때만 지웁니다.
if (row.expires_at >= now()) { try { principal = await principalById(env, row.user_id); } catch { return null; } }
if (principal && scope === 'admin' && principal.role !== 'admin') principal = null;
if (!principal) { await env.DB.prepare('DELETE FROM sessions WHERE id = ?').bind(hash).run(); return null; }
return { ...principal, expires_at: row.expires_at, scope };
}
export async function destroySession(env, request, { scope = 'mail' } = {}) { const token = readCookie(request, cookieName(scope)); if (token) await env.DB.prepare('DELETE FROM sessions WHERE id = ?').bind(await sha256(token)).run(); }
export async function requireUser(env, request, { scope = 'mail' } = {}) { const user = await currentUser(env, request, { scope }); return user || json({ ok: false, error: 'unauthenticated' }, 401); }
export async function audit(env, actor, action, target, detail) {
await env.DB.prepare('INSERT INTO audit_log (id,actor,action,target,detail,created_at) VALUES (?,?,?,?,?,?)').bind(uid(), actor || null, action, target || null, detail ? JSON.stringify(detail).slice(0, 2000) : null, now()).run();
}