메일 서비스(사이트 대표·독립)·메일함·사용량·보안·전환·보존과 AriMail 도입 수정(KST 월 경계, 발신 키 오류, 도메인별 보장량, 100% 제한, 발송 큐·속도, 운영 상태, SSO) (§15~§18·§0.4)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
a014e3cd05
commit
a1cde81332
@@ -23,6 +23,9 @@ import { safeEqual as timingEqual, address as bareAddress, clean as cleanStr, ui
|
||||
import { knownMailboxes as mailboxList } from './auth.mjs';
|
||||
import { sanitizeHtml } from './sanitize.mjs';
|
||||
import { verifySvix } from './webhook.mjs';
|
||||
import { submit as submitRate, rateSnapshot, queueList, abuseList, reviewAbuse, recoverQueue } from './sendqueue.mjs'; // [Ariweb Suite 변경]
|
||||
import { mailboxUser } from './auth.mjs';
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import { setFlag, mailboxOf, ingestReceived, startSync, isSyncing, saveDraft, sendDraft, addAttachment, removeAttachment, copyAttachments } from './store.mjs';
|
||||
import { autoActionsOf, autoReplyActive } from './automation.mjs';
|
||||
import { readLargeFileSettings, writeLargeFileSettings, systemLargeFiles, listLargeFiles, removeLargeFile, largeFileStats } from './files.mjs';
|
||||
@@ -82,6 +85,16 @@ export async function handle(env, request, params) {
|
||||
if (path[0] === 'webhooks' && path[1] === 'resend' && method === 'POST') return await webhook(env, request);
|
||||
// 내장 Haraka 수신 전달(1.0.0): 로컬 Haraka 플러그인이 토큰으로 인증해 부릅니다(수신 방식이 haraka 일 때만 열림).
|
||||
if (path[0] === 'inbound' && path[1] === 'haraka') return await harakaInbound(env, request, path, method);
|
||||
// [Ariweb Suite 변경] 포털 SSO(§16.3): GET /api/auth/sso?ticket=<payload>.<sig> — 포털이 ARIWEB_SSO_SECRET 으로 서명한 티켓(메일함·만료·nonce)을 검증해
|
||||
// 그 메일함 주인의 웹 메일 세션을 만들고 웹 메일 첫 화면으로 보냅니다. 비밀번호를 포털에 알려 주지 않아도 "현재 로그인 상태로 안전하게 연결" 됩니다.
|
||||
if (path[0] === 'auth' && path[1] === 'sso' && method === 'GET') {
|
||||
const v = verifySsoTicket(env, new URL(request.url).searchParams.get('ticket') || '');
|
||||
if (!v.ok) { await audit(env, null, 'sso_denied', null, { ip, reason: v.reason }); return bad(v.reason, 401); }
|
||||
const user = await mailboxUser(env, v.mailbox);
|
||||
if (!user || user.status === 'suspended') { await audit(env, null, 'sso_denied', v.mailbox, { ip, reason: 'no_mailbox' }); return bad('mailbox_not_found', 404); }
|
||||
const token = await createSession(env, user, request, { scope: 'mail' }); await audit(env, user.login, 'login', null, { ip, source: user.source, via: 'ariweb-sso' });
|
||||
return new Response(null, { status: 302, headers: { Location: (prefix || '') + '/', 'Set-Cookie': sessionCookie(token, { scope: 'mail', path: prefix }), 'Cache-Control': 'no-store' } });
|
||||
}
|
||||
if (path[0] === 'login' && method === 'POST') {
|
||||
const body = await request.json().catch(() => null); if (!body) return bad('bad_request');
|
||||
// 로그인 제한(IP 당·계정 당 15분 10회)은 실패만 셉니다. 예전에는 성공도 세어 공유 IP(사무실) 에서 10명이 로그인하면 나머지가 막혔습니다.
|
||||
@@ -124,6 +137,9 @@ export async function handle(env, request, params) {
|
||||
if (path[0] === 'admin') {
|
||||
if (sessionScope !== 'admin') return bad('admin_login_required', 403);
|
||||
if (path[1] === 'status' && method === 'GET') return await adminStatus(env);
|
||||
// [Ariweb Suite 변경] 발송 속도·제출 창·대기열·악용 의심 표(§17.2, 운영자 확인용). 메일 내용은 없습니다.
|
||||
if (path[1] === 'rates' && method === 'GET') return json({ ok: true, rates: rateSnapshot(), queue: await queueList(env), abuse: await abuseList(env) });
|
||||
if (path[1] === 'abuse' && path[2] && method === 'PATCH') { await reviewAbuse(env, path[2], user.login); await audit(env, user.login, 'abuse_reviewed', path[2]); return json({ ok: true }); }
|
||||
if (path[1] === 'sync' && method === 'POST') return json({ ok: true, ...startSync(env, 'manual') });
|
||||
// 비밀번호 정책(0.8.24~): GET 은 현재 정책, PATCH 는 { minLength?, mixedCase?, digit?, special? } 를 저장합니다. 기존 비밀번호에는 소급하지 않습니다.
|
||||
if (path[1] === 'policy' && method === 'GET') return json({ ok: true, policy: await readPasswordPolicy(env) });
|
||||
@@ -293,6 +309,8 @@ export async function handle(env, request, params) {
|
||||
if (method === 'POST' && action === 'send') {
|
||||
// body.largeFiles({ days, downloads }, 0.9.3): 초안에 대용량 파일이 있을 때의 발송 조건. 없으면 sendDraft 가 large_file_terms 로 거부합니다.
|
||||
const b = await request.json().catch(() => ({}));
|
||||
// [Ariweb Suite 변경] 웹 발송도 제출 속도 제한(계정·IP 초당 10건)을 받습니다.
|
||||
{ const th = await submitRate(env, ['account:' + mailbox, ...(ip ? ['ip:' + ip] : [])]); if (th.throttled) return bad('mail_submission_throttled', 429, { until: th.until, rate: th.rate }); }
|
||||
await sendDraft(env, id, { largeFiles: b && typeof b === 'object' ? b.largeFiles : null }); await audit(env, user.login, 'mail_send', id); if (mail.headers_json && JSON.parse(mail.headers_json)['In-Reply-To']) { const parent = await env.DB.prepare('SELECT id FROM messages WHERE message_id=? AND mailbox=?').bind(JSON.parse(mail.headers_json)['In-Reply-To'], mailbox).first(); if (parent) await setFlag(env, parent.id, '\\Answered', true); } return json({ ok: true }); }
|
||||
if (method === 'POST' && action === 'attachments') {
|
||||
// JSON { from, ids }: 다른 메일(전달하는 원본)의 첨부를 이 초안으로 복사합니다(0.8.25~, 웹 "전달" 이 첨부를 함께 보내기 위해). 같은 메일함의 메일만 원본이 될 수 있습니다.
|
||||
@@ -324,10 +342,10 @@ export async function handle(env, request, params) {
|
||||
}
|
||||
return bad('not_found', 404);
|
||||
} catch (e) {
|
||||
const status = e.message === 'not_found' ? 404 : e.message === 'bad_request' ? 400 : ['mail_move_invalid', 'mail_sending', 'mail_locked', 'mail_trash_first', 'login_taken', 'cannot_delete_self', 'cannot_modify_self', 'account_limit_reached'].includes(e.message) ? 409 : e.message === 'forbidden' ? 403 : ['signup_disabled', 'large_files_disabled'].includes(e.message) ? 404 : e.message === 'file_too_large' ? 413 : 422;
|
||||
const status = e.message === 'mail_domain_frozen' ? 423 : e.message === 'not_found' ? 404 : e.message === 'bad_request' ? 400 : ['mail_move_invalid', 'mail_sending', 'mail_locked', 'mail_trash_first', 'login_taken', 'cannot_delete_self', 'cannot_modify_self', 'account_limit_reached'].includes(e.message) ? 409 : e.message === 'forbidden' ? 403 : ['signup_disabled', 'large_files_disabled'].includes(e.message) ? 404 : e.message === 'file_too_large' ? 413 : 422;
|
||||
// weak_password 에는 어긋난 항목(problems)과 정책(policy)을 붙여 화면이 "6자 이상, 숫자 포함" 같은 구체적 안내를 만들 수 있게 합니다.
|
||||
// 제한 오류(too_many_recipients·attachment_too_large·quota_exceeded·storage_full·monthly_limit_exceeded·limit_exceeds_system·account_limit_reached)에는 적용된 상한(limit)과 항목(field)을 붙입니다.
|
||||
return bad(e.message || 'server_error', status, { ...(e.providerMessage ? { detail: e.providerMessage } : {}), ...(e.providerStatus ? { providerStatus: e.providerStatus } : {}), ...(e.problems ? { problems: e.problems, policy: e.policy } : {}), ...(e.limit !== undefined ? { limit: e.limit } : {}), ...(e.field ? { field: e.field } : {}) });
|
||||
return bad(e.message || 'server_error', status, { ...(e.code && e.code !== e.message ? { code: e.code } : {}), ...(e.resetAt ? { resetAt: e.resetAt } : {}), ...(e.providerMessage ? { detail: e.providerMessage } : {}), ...(e.providerStatus ? { providerStatus: e.providerStatus } : {}), ...(e.problems ? { problems: e.problems, policy: e.policy } : {}), ...(e.limit !== undefined ? { limit: e.limit } : {}), ...(e.field ? { field: e.field } : {}) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,3 +495,22 @@ async function adminStatus(env) {
|
||||
stats: await mailStats(env, await knownMailboxes(env), await listUsers(env)), largeFiles: await largeFileStats(env),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* [Ariweb Suite 변경] 포털 SSO 티켓 검증. 티켓 = base64url(JSON{ mailbox, exp(ms), nonce }) + '.' + base64url(HMAC-SHA256(ARIWEB_SSO_SECRET, payload)).
|
||||
* 만료(exp)는 5분 이내, nonce 는 한 번만(메모리, 만료까지 보관). 비밀이 설정되지 않은 도메인은 거부합니다.
|
||||
*/
|
||||
const usedNonces = new Map();
|
||||
export function verifySsoTicket(env, ticket) {
|
||||
const secret = String(env.ARIWEB_SSO_SECRET || env.CONFIG?.ARIWEB_SSO_SECRET || ''); if (!secret) return { ok: false, reason: 'sso_not_configured' };
|
||||
const [payload, sig] = String(ticket || '').split('.'); if (!payload || !sig) return { ok: false, reason: 'sso_bad_ticket' };
|
||||
const expect = createHmac('sha256', secret).update(payload).digest('base64url');
|
||||
const a = Buffer.from(expect), b = Buffer.from(sig); if (a.length !== b.length || !timingSafeEqual(a, b)) return { ok: false, reason: 'sso_bad_signature' };
|
||||
let d; try { d = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); } catch { return { ok: false, reason: 'sso_bad_ticket' }; }
|
||||
const t = Date.now(); if (!d || typeof d.mailbox !== 'string' || !Number.isFinite(d.exp) || d.exp < t || d.exp > t + 10 * 60 * 1000 || typeof d.nonce !== 'string') return { ok: false, reason: 'sso_expired' };
|
||||
for (const [k, exp] of usedNonces) if (exp < t) usedNonces.delete(k);
|
||||
if (usedNonces.has(d.nonce)) return { ok: false, reason: 'sso_replayed' };
|
||||
usedNonces.set(d.nonce, d.exp);
|
||||
const mailbox = d.mailbox.trim().toLowerCase(); if (mailbox.split('@')[1] !== mailDomain(env)) return { ok: false, reason: 'sso_wrong_domain' };
|
||||
return { ok: true, mailbox };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user