포털 인증·계정·사용자 그룹·대시보드·알림 센터·감사 화면·도움말·발송기·스케줄러 (§3·§4·§5·§24)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zzabisagent-lab
2026-10-26 11:26:28 +00:00
co-authored by Claude Fable 5.1
parent 0f11424224
commit c90ac27596
21 changed files with 1005 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
{
"auth": {
"login": { "title": "로그인", "email": "이메일", "password": "비밀번호", "submit": "로그인", "failed": "이메일 또는 비밀번호가 올바르지 않습니다", "locked": "로그인 시도가 너무 많습니다. {until} 이후에 다시 시도하세요", "noAccount": "계정이 없으신가요?", "forgot": "비밀번호를 잊으셨나요?", "operatorNote": "운영자는 별도의 운영자 콘솔(hal.ariweb.net)에서 로그인합니다. 이 화면은 고객 전용입니다.", "mfaTitle": "인증 코드 입력", "mfaHint": "인증 앱의 6자리 코드 또는 복구 코드를 입력하세요", "mfaExpired": "인증 단계가 만료되었습니다. 처음부터 다시 로그인하세요" },
"signup": { "title": "회원가입", "displayName": "표시 이름", "submit": "가입하기", "passwordHint": "8자 이상. 다른 서비스와 같은 비밀번호는 피하세요", "terms": "이용약관에 동의합니다 (필수)", "privacy": "개인정보 처리방침에 동의합니다 (필수)", "marketing": "소식·혜택 안내 수신에 동의합니다 (선택)", "dupe": "이 이메일로는 가입을 진행할 수 없습니다. 이미 계정이 있다면 로그인하거나 비밀번호 재설정을 이용하세요", "emailSent": "확인 메일을 보냈습니다. 받은 편지함의 링크를 눌러 이메일을 확인하세요", "consentRequired": "필수 약관에 동의해야 가입할 수 있습니다", "policyNote": "가격·SLA·환불·개인정보·백업·AUP·정지 및 해지 정책은 승인된 문구만 표시됩니다. 미확정 정책은 '확정 전' 으로 표시됩니다" },
"verify": { "title": "이메일 확인", "ok": "이메일이 확인되었습니다", "invalid": "확인 링크가 유효하지 않거나 만료되었습니다. 다시 보내기를 눌러 새 링크를 받으세요", "used": "이미 사용한 링크입니다", "resend": "확인 메일 다시 보내기", "banner": "이메일 확인 전에는 유료 상품 구매와 운영 서비스 생성을 할 수 없습니다" },
"reset": { "title": "비밀번호 재설정", "request": "재설정 링크 보내기", "requested": "계정이 있다면 재설정 안내 메일을 보냈습니다. 받은 편지함을 확인하세요", "newPassword": "새 비밀번호", "submit": "비밀번호 바꾸기", "done": "비밀번호를 바꿨습니다. 다른 기기의 로그인은 모두 종료되었습니다", "expired": "재설정 링크가 만료되었습니다 (유효 시간 1시간)", "used": "이미 사용한 재설정 링크입니다", "state": "링크 상태" },
"password": { "tooShort": "비밀번호는 8자 이상이어야 합니다", "tooLong": "비밀번호는 128자 이하여야 합니다", "same": "새 비밀번호가 현재 비밀번호와 같습니다", "wrongCurrent": "현재 비밀번호가 올바르지 않습니다" },
"mfa": { "title": "다중 인증(MFA)", "enabled": "사용 중", "disabled": "사용 안 함", "setup": "인증 앱 등록", "manualKey": "수동 입력 키", "enable": "코드 확인 후 켜기", "disable": "끄기", "disableNeeds": "끄려면 비밀번호와 현재 코드가 필요합니다", "badCode": "인증 코드가 올바르지 않습니다", "recovery": "복구 코드", "recoveryOnce": "복구 코드는 지금 한 번만 표시됩니다. 안전한 곳에 보관하세요. 각 코드는 한 번만 쓸 수 있습니다", "regenerate": "복구 코드 다시 만들기", "level": "현재 보안 수준", "levelMfa": "비밀번호 + 인증 앱", "levelPw": "비밀번호만 (인증 앱 등록을 권장합니다)" },
"sessions": { "title": "로그인 기기", "current": "현재 세션", "revoke": "종료", "revokeOthers": "다른 모든 세션 종료", "lastSeen": "마지막 사용", "created": "로그인 시각", "device": "기기" },
"account": { "title": "내 계정", "profile": "프로필", "security": "보안", "changePassword": "비밀번호 변경", "currentPassword": "현재 비밀번호", "saved": "저장했습니다" }
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"dashboard": {
"title": "대시보드", "sites": "운영 사이트", "sitesOf": "구매 {purchased}개 중", "reserved": "예약됨", "inUse": "사용 중", "drafts": "초안·제작 중", "health": "사이트 상태", "healthOk": "정상", "healthWarn": "경고", "healthBad": "장애", "deploy": "배포 방식", "managed": "관리형", "external": "외부", "domains": "도메인", "domainsAction": "조치 필요", "mail": "메일 서비스", "mailboxes": "메일함", "backup": "백업 옵션 사이트", "jobs": "진행 중 작업", "cost": "이번 달 예상 비용", "awaitingPayment": "결제 대기 금액",
"attention": "주의 항목", "recent": "최근 활동", "auto": "자동", "user": "사용자", "noAttention": "지금 조치할 항목이 없습니다", "noActivity": "최근 활동이 없습니다"
},
"notifications": { "title": "알림", "all": "전체", "unread": "읽지 않음", "markRead": "읽음으로 표시", "mandatory": "필수", "count": "{n}회 반복", "resolved": "해결됨", "categories": { "billing": "결제와 주문", "site": "사이트와 배포", "domain": "도메인과 인증서", "mail": "메일", "usage": "사용량", "backup": "백업과 복원", "relocation": "서버 이전", "security": "보안", "incident": "장애와 유지보수", "membership": "구성원", "ops": "운영" } },
"activity": { "title": "활동 기록", "who": "누가", "when": "언제", "what": "무엇을", "target": "대상", "result": "결과", "auto": "자동 작업", "manual": "사용자 작업", "immutable": "삭제 불가 기록" },
"help": { "title": "도움말" }
}
+10
View File
@@ -0,0 +1,10 @@
{
"groups": {
"title": "사용자 그룹 설정", "name": "그룹 이름", "contact": "연락처", "billingContact": "결제 담당자", "techContact": "기술 담당자", "members": "구성원", "invites": "초대 대기", "invite": "이메일로 초대", "resend": "재발송", "cancelInvite": "초대 취소", "expired": "만료됨", "role": "역할", "alias": "표시 별칭", "aliasHint": "화면 표시용 별칭입니다. 로그인과 권한의 기준이 아닙니다",
"roles": { "owner": "소유자", "admin": "관리자", "billing": "결제 담당", "member": "구성원", "viewer": "조회 전용" },
"suspend": "정지", "unsuspend": "정지 해제", "remove": "제거", "leave": "그룹 탈퇴", "transferOwner": "소유권 이전", "suspendImpact": "정지하면 이 구성원의 로그인 세션과 아직 실행되지 않은 작업만 중지됩니다. 그룹의 상품·사이트·도메인과 다른 구성원의 작업은 유지됩니다",
"ownerCannotLeave": "소유자는 먼저 소유권을 다른 구성원에게 이전해야 탈퇴할 수 있습니다", "lastOwner": "소유자는 그룹마다 한 명이며 제거할 수 없습니다", "switched": "사용자 그룹을 바꿨습니다",
"sitePerms": { "title": "사이트별 권한", "view": "조회", "edit": "콘텐츠 편집", "deploy": "배포", "domain_mail": "도메인과 메일 설정", "billing": "결제와 해지", "admin": "전체 관리" },
"inviteAccepted": "그룹에 참여했습니다", "inviteInvalid": "초대 링크가 유효하지 않거나 만료되었습니다", "inviteNeedsLogin": "초대를 수락하려면 초대받은 이메일로 로그인하거나 가입하세요"
}
}
+12
View File
@@ -0,0 +1,12 @@
/**
* portal/auditui/api.mjs — 활동·감사 기록 화면(§24.2). 현재 그룹의 기록만, 비밀값 없음(lib/audit 가 기록 시 redact).
* 필터: 행동(action 접두어)·대상 종류·기간. 삭제 불가 기록(immutable)과 자동 작업을 표시한다.
*/
import { requireGroup } from '../auth/guard.mjs';
import { listAudit } from '../../lib/audit.mjs';
import { t } from '../../lib/i18n.mjs';
export async function register(app, deps) {
app.get('/activity', (ctx) => { requireGroup(ctx); return ctx.html(deps.shell(ctx, { title: t('activity.title'), nav: null, crumbs: [{ label: t('activity.title') }], body: `<div class="card"><form id="af" class="actions" onsubmit="return false"><input name="action" placeholder="행동(예: deploy, member)" style="max-width:200px"><input name="subjectType" placeholder="대상 종류(site, user…)" style="max-width:180px"><input name="since" type="date"><input name="until" type="date"><button class="btn">조회</button></form></div><div id="alist">${deps.statePanel('loading')}</div>
<script>(()=>{const f=document.getElementById('af'),el=document.getElementById('alist');async function load(){const q=new URLSearchParams(Object.fromEntries([...new FormData(f)].filter(([k,v])=>v)));try{const d=await aw.api('GET','/api/audit?'+q);if(!d.items.length)return aw.state(el,'empty');el.innerHTML='<table class="r"><thead><tr><th>언제</th><th>누가</th><th>무엇을</th><th>대상</th><th>결과</th></tr></thead><tbody>'+d.items.map(a=>'<tr><td data-l="언제">'+aw.esc(aw.fmt(a.at))+'</td><td data-l="누가">'+(a.automatic?'<span class="badge">⚙ 자동</span>':'<span class="badge">👤 '+aw.esc(a.actor)+'</span>')+'</td><td data-l="무엇을">'+aw.esc(a.action)+(a.immutable?' <span class="badge" title="삭제 불가 기록">🔒</span>':'')+(a.detail?'<div class="sub mono">'+aw.esc(JSON.stringify(a.detail)).slice(0,300)+'</div>':'')+'</td><td data-l="대상">'+aw.esc((a.subjectType||'')+' '+(a.subjectId||''))+'</td><td data-l="결과">'+(a.outcome==='ok'?'<span class="badge ok">✔ 성공</span>':'<span class="badge failed">✘ '+aw.esc(a.outcome)+'</span>')+'</td></tr>').join('')+'</tbody></table>'}catch(e){aw.state(el,e.status===403?'forbidden':'failed',e.message,{retry:load})}}f.onsubmit=()=>{load();return false};load()})();</script>` })); });
app.get('/api/audit', (ctx) => { requireGroup(ctx); const items = listAudit(deps.store, { groupId: ctx.group.id, action: ctx.query.action || undefined, subjectType: ctx.query.subjectType || undefined, since: ctx.query.since ? ctx.query.since + 'T00:00:00.000Z' : undefined, until: ctx.query.until ? ctx.query.until + 'T23:59:59.999Z' : undefined, limit: Number(ctx.query.limit || 200) }).map((a) => ({ id: a.id, at: a.at, actor: a.actor_label || a.actor_type, actorType: a.actor_type, automatic: !!a.automatic || a.actor_type === 'system', action: a.action, outcome: a.outcome, subjectType: a.subject_type, subjectId: a.subject_id, immutable: !!a.immutable, detail: a.detail })); return { ok: true, items }; });
}
+204
View File
@@ -0,0 +1,204 @@
/**
* portal/auth/api.mjs — 회원가입·로그인·세션·이메일 확인·비밀번호 재설정·MFA·내 계정 (§3, §37) 과 인증 미들웨어.
*
* 등록 순서상 가장 먼저 실행되므로 여기서 공유 도구도 만든다.
* deps.mailer portal/mailer.mjs (없을 때만) deps.scheduler portal/scheduler.mjs 등록부
* deps.shell(ctx, o) 현재 사용자·그룹·그룹 목록·읽지 않은 알림 수·CSRF 를 채워 deps.page() 를 부르는 화면 틀 — 모든 영역이 이것으로 HTML 을 낸다
* deps.currentGroup(ctx) → ctx.group, deps.sendMail({to, subject, text, html})
* 미들웨어(middleware)
* 1) aw_session 쿠키 → lib/session(kind customer) → users 행(status active) → ctx.user/ctx.session
* 2) 세션 data.groupId(없으면 첫 활성 구성원 그룹) → ctx.group/ctx.membership, ctx.sitePerms(siteId,userId)
* 3) 세션이 있는 /api 변경 요청은 X-CSRF 검사(guard.requireCsrf). 세션 없는 /api 변경(가입·로그인)은 그대로 통과.
* 4) HTML 화면(GET, /api·/ui·/internal 등이 아닌 경로)은 로그인 전이면 /login?next= 로 보낸다(공개 화면 제외).
* 비밀번호·토큰 원문은 어디에도 기록하지 않는다. 이메일 존재 여부는 가입·로그인·재설정 응답에서 드러내지 않는다.
*/
import { createSession, resolveSession, revokeSession, revokeOtherSessions, listSessions, updateSessionData, markReauth, cookieName, csrfOk } from '../../lib/session.mjs';
import { cookie, HttpError } from '../../lib/http.mjs';
import { hashPassword, verifyPassword, randomToken, sha256, newId, safeEqual } from '../../lib/crypto.mjs';
import { nowIso, parseJson, toJson } from '../../lib/db.mjs';
import { audit } from '../../lib/audit.mjs';
import { notify, unreadCount, setMailer } from '../../lib/notify.mjs';
import { t } from '../../lib/i18n.mjs';
import { requireUser, requireCsrf } from './guard.mjs';
import * as totp from './totp.mjs';
import * as pages from './pages.mjs';
import { create as createMailer } from '../mailer.mjs';
import { create as createScheduler } from '../scheduler.mjs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const PUBLIC_HTML = /^\/(login|signup|verify-email|reset-password(\/.*)?|mfa|help|status|invite\/.*|public(\/.*)?|sso(\/.*)?|dl(\/.*)?)?\/?$/;
const SKIP_HTML = /^\/(api|ui|internal|sso|webhooks?|ops|\.well-known|dl)(\/|$)/;
const LOCK_AFTER = 5; const LOCK_MS = 15 * 60 * 1000;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/** 비밀번호 규칙(§3). 정책 문구는 lang 에서 */
export function checkPassword(pw) { const s = String(pw || ''); if (s.length < 8) throw new HttpError(400, 'password_too_short', t('auth.password.tooShort')); if (s.length > 128) throw new HttpError(400, 'password_too_long', t('auth.password.tooLong')); }
const normEmail = (e) => String(e || '').trim().toLowerCase();
/** 화면에 내려보낼 사용자 공개 정보(해시·MFA 비밀 없음) */
export function publicUser(u) { if (!u) return null; const mfa = parseJson(u.mfa_json, null); return { id: u.id, email: u.email, display_name: u.display_name, email_verified_at: u.email_verified_at, mfa_enabled: !!(mfa && mfa.enabledAt), created_at: u.created_at, last_login_at: u.last_login_at, is_operator: !!u.is_operator }; }
export async function register(app, deps) {
const { store } = deps;
if (!deps.mailer) deps.mailer = createMailer(deps);
createScheduler(deps);
deps.sendMail = (m) => deps.mailer.send(m);
deps.publicUser = publicUser;
// 필수 알림의 외부 이메일: 그룹의 결제 담당자(없으면 소유자) 주소로
setMailer(async ({ groupId, userId, title, body, link }) => {
let to = null;
if (userId) to = store.get('SELECT email FROM users WHERE id=?', userId)?.email;
if (!to && groupId) { const g = store.get('SELECT * FROM groups WHERE id=?', groupId); if (g) to = store.get('SELECT email FROM users WHERE id=?', g.billing_user_id || g.owner_user_id)?.email || g.contact_email; }
if (!to) return;
await deps.mailer.send({ to, subject: `[Ariweb] ${title}`, text: `${body || ''}\n\n${link ? deps.config.publicUrl + link : ''}` });
});
/** 화면 틀: 현재 사용자·그룹·그룹 목록·알림 수·CSRF 포함 */
deps.shell = (ctx, o) => deps.page({ user: ctx.user ? publicUser(ctx.user) : null, group: ctx.group, groups: ctx.user ? store.all('SELECT g.id, g.name, g.kind FROM groups g JOIN memberships m ON m.group_id=g.id WHERE m.user_id=? AND m.status=\'active\' AND g.status=\'active\' ORDER BY g.kind DESC, g.created_at', ctx.user.id) : [], unread: ctx.group ? unreadCount(store, ctx.group.id) : 0, csrf: ctx.session?.csrf || '', ...o });
deps.currentGroup = (ctx) => ctx.group;
// ---------------------------------------------------------------- 미들웨어
app.use(async (ctx) => {
const token = ctx.cookies[cookieName('customer')];
const s = token ? resolveSession(store, token, 'customer') : null;
if (s) {
const u = store.get('SELECT * FROM users WHERE id=?', s.user_id);
if (u && u.status === 'active') {
ctx.user = u; ctx.session = s;
let gid = (s.data || {}).groupId; let m = gid ? store.get('SELECT * FROM memberships WHERE group_id=? AND user_id=? AND status=\'active\'', gid, u.id) : null;
if (!m) { m = store.get('SELECT m.* FROM memberships m JOIN groups g ON g.id=m.group_id WHERE m.user_id=? AND m.status=\'active\' AND g.status=\'active\' ORDER BY g.kind DESC, m.joined_at LIMIT 1', u.id); if (m) { gid = m.group_id; updateSessionData(store, s.id, { groupId: gid }); } }
if (m) { ctx.group = store.get('SELECT * FROM groups WHERE id=? AND status=\'active\'', gid); ctx.membership = ctx.group ? m : null; }
ctx.sitePerms = (siteId, userId) => { const r = store.get('SELECT perms_json FROM site_permissions WHERE site_id=? AND user_id=?', siteId, userId); return r ? parseJson(r.perms_json, []) : []; };
}
}
if (ctx.session) requireCsrf(ctx, csrfOk);
// 세션이 없는데 변경 요청이 왔고 로그인 폼(form POST /logout 등)이면 허용. 로그인 화면 리디렉션은 HTML GET 만
if (!ctx.user && ctx.method === 'GET' && !SKIP_HTML.test(ctx.path) && !PUBLIC_HTML.test(ctx.path) && String(ctx.headers.accept || '').includes('text/html')) return ctx.redirect('/login?next=' + encodeURIComponent(ctx.path + (ctx.url.search || '')));
});
// ---------------------------------------------------------------- 공통 도우미
const secure = (ctx) => ctx.secure || deps.config.publicUrl.startsWith('https');
function issueSession(ctx, user, { reauth = true } = {}) {
const s = createSession(store, { userId: user.id, kind: 'customer', ip: ctx.ip, ua: ctx.headers['user-agent'], reauth });
ctx.setCookie(cookie(cookieName('customer'), s.token, { maxAge: 30 * 86400, secure: secure(ctx) }));
store.run('UPDATE users SET last_login_at=?, failed_logins=0, lock_until=NULL WHERE id=?', nowIso(), user.id);
return s;
}
function makeToken(userId, email, kind, ttlMs, data = null) { const raw = randomToken(32); store.run('INSERT INTO email_tokens(id, user_id, email, kind, token_hash, created_at, expires_at, data_json) VALUES(?,?,?,?,?,?,?,?)', newId('t_'), userId, email, kind, sha256(raw), nowIso(), new Date(Date.now() + ttlMs).toISOString(), toJson(data)); return raw; }
/** 토큰 상태: valid | expired | used | invalid */
function tokenState(raw, kind) { const r = raw ? store.get('SELECT * FROM email_tokens WHERE token_hash=? AND kind=?', sha256(raw), kind) : null; if (!r) return { state: 'invalid' }; if (r.used_at) return { state: 'used', row: r }; if (Date.parse(r.expires_at) < Date.now()) return { state: 'expired', row: r }; return { state: 'valid', row: r }; }
function useToken(row) { store.run('UPDATE email_tokens SET used_at=? WHERE id=? AND used_at IS NULL', nowIso(), row.id); }
async function sendVerification(user) { const raw = makeToken(user.id, user.email, 'verify', 24 * 3600 * 1000); const link = `${deps.config.publicUrl}/verify-email?token=${raw}`; await deps.mailer.send({ to: user.email, subject: '[Ariweb] 이메일 주소를 확인해 주세요', text: `아래 링크를 눌러 이메일을 확인하세요(24시간 유효).\n${link}\n\n본인이 가입하지 않았다면 이 메일을 무시하세요.` }).catch((e) => deps.log(`[auth] 확인 메일 실패: ${e.message}`)); }
const attemptKey = (email, ip) => `${email}|${ip}`;
function lockState(email, ip) { const r = store.get('SELECT * FROM login_attempts WHERE key=?', attemptKey(email, ip)); if (r && r.lock_until && Date.parse(r.lock_until) > Date.now()) return r.lock_until; return null; }
function recordFailure(email, ip) { const k = attemptKey(email, ip); const r = store.get('SELECT * FROM login_attempts WHERE key=?', k); const failures = (r ? r.failures : 0) + 1; const lock = failures >= LOCK_AFTER ? new Date(Date.now() + LOCK_MS).toISOString() : null; store.run('INSERT INTO login_attempts(key, failures, lock_until, updated_at) VALUES(?,?,?,?) ON CONFLICT(key) DO UPDATE SET failures=excluded.failures, lock_until=excluded.lock_until, updated_at=excluded.updated_at', k, failures, lock, nowIso()); return lock; }
function clearFailures(email, ip) { store.run('DELETE FROM login_attempts WHERE key=?', attemptKey(email, ip)); }
const fmtKst = (iso) => new Date(new Date(iso).getTime() + 9 * 3600000).toISOString().slice(11, 16) + ' (KST)';
/** 개인 그룹 만들기(가입 시) */
function createPersonalGroup(user) { const gid = newId('g_'); const now = nowIso(); store.run('INSERT INTO groups(id, name, kind, owner_user_id, contact_email, billing_user_id, tech_user_id, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?)', gid, user.display_name, 'personal', user.id, user.email, user.id, user.id, now, now); store.run('INSERT INTO memberships(group_id, user_id, role, alias, joined_at) VALUES(?,?,?,?,?)', gid, user.id, 'owner', 'user-01', now); return gid; }
deps.createPersonalGroup = createPersonalGroup;
// ---------------------------------------------------------------- 화면
app.static('/ui/auth', join(HERE, 'public'));
app.get('/login', (ctx) => ctx.user ? ctx.redirect(ctx.query.next && ctx.query.next.startsWith('/') ? ctx.query.next : '/') : ctx.html(pages.loginPage((o) => deps.shell(ctx, o), { next: ctx.query.next && ctx.query.next.startsWith('/') ? ctx.query.next : '/', notice: ctx.query.notice === 'reset' ? t('auth.reset.done') : ctx.query.notice === 'invite' ? t('groups.inviteNeedsLogin') : '' })));
app.get('/signup', (ctx) => ctx.user ? ctx.redirect('/') : ctx.html(pages.signupPage((o) => deps.shell(ctx, o))));
app.get('/verify-email', (ctx) => { const { state, row } = tokenState(ctx.query.token, 'verify'); if (state === 'valid') { useToken(row); store.run('UPDATE users SET email_verified_at=COALESCE(email_verified_at, ?), updated_at=? WHERE id=?', nowIso(), nowIso(), row.user_id); audit(store, { actorType: 'user', actorId: row.user_id, action: 'auth.email_verified', ip: ctx.ip }); } return ctx.html(pages.verifyPage((o) => deps.shell(ctx, o), { state: state === 'valid' ? 'ok' : state, user: ctx.user })); });
app.get('/reset-password', (ctx) => ctx.html(pages.resetRequestPage((o) => deps.shell(ctx, o))));
app.get('/reset-password/:token', (ctx) => ctx.html(pages.resetFormPage((o) => deps.shell(ctx, o), { token: ctx.params.token, state: tokenState(ctx.params.token, 'reset').state })));
app.post('/logout', async (ctx) => { const b = await ctx.body(); if (ctx.session && csrfOk(ctx.session, b.csrf)) revokeSession(store, ctx.session.id, ctx.user.id); ctx.setCookie(cookie(cookieName('customer'), '', { maxAge: 0, secure: secure(ctx) })); return ctx.redirect('/login'); });
app.get('/account', (ctx) => { requireUser(ctx); return ctx.html(pages.accountPage((o) => deps.shell(ctx, o), { user: ctx.user })); });
app.get('/account/security', (ctx) => { requireUser(ctx); const mfa = parseJson(ctx.user.mfa_json, null); const rec = parseJson(ctx.user.recovery_json, []); return ctx.html(pages.securityPage((o) => deps.shell(ctx, o), { mfaEnabled: !!(mfa && mfa.enabledAt), mfaEnabledAt: mfa && mfa.enabledAt ? mfa.enabledAt.slice(0, 10) : null, recoveryLeft: rec.filter((c) => !c.usedAt).length })); });
app.get('/account/sessions', (ctx) => { requireUser(ctx); return ctx.html(pages.sessionsPage((o) => deps.shell(ctx, o), { statePanel: deps.statePanel })); });
// ---------------------------------------------------------------- API: 가입·확인
app.get('/api/auth/csrf', (ctx) => ({ ok: true, csrf: ctx.session ? ctx.session.csrf : null }));
app.get('/api/auth/me', (ctx) => ({ ok: true, user: publicUser(ctx.user), group: ctx.group ? { id: ctx.group.id, name: ctx.group.name, kind: ctx.group.kind } : null, role: ctx.membership?.role || null }));
app.post('/api/auth/signup', async (ctx) => {
const b = await ctx.body(); const email = normEmail(b.email); const name = String(b.displayName || '').trim(); const consents = b.consents || {};
if (!EMAIL_RE.test(email)) throw new HttpError(400, 'bad_email', '올바른 이메일 주소를 입력하세요'); if (!name || name.length > 80) throw new HttpError(400, 'bad_name', '표시 이름을 입력하세요(80자 이내)'); checkPassword(b.password);
if (!consents.terms || !consents.privacy) throw new HttpError(400, 'consent_required', t('auth.signup.consentRequired'));
const now = nowIso();
if (store.get('SELECT id FROM users WHERE email=?', email)) { audit(store, { actorType: 'system', action: 'auth.signup_duplicate', outcome: 'denied', detail: { emailDomain: email.split('@')[1] }, ip: ctx.ip }); throw new HttpError(409, 'signup_unavailable', t('auth.signup.dupe')); }
const { salt, hash } = hashPassword(b.password); const id = newId('u_');
store.run('INSERT INTO users(id, email, display_name, pass_hash, pass_salt, consents_json, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?)', id, email, name, hash, salt, toJson({ terms: now, privacy: now, marketing: consents.marketing ? now : null, ip: ctx.ip }), now, now);
const user = store.get('SELECT * FROM users WHERE id=?', id); createPersonalGroup(user);
audit(store, { actorType: 'user', actorId: id, action: 'auth.signup', ip: ctx.ip });
await sendVerification(user); issueSession(ctx, user);
return { ok: true, user: publicUser(user), message: t('auth.signup.emailSent'), next: '/' };
});
app.post('/api/auth/resend-verification', async (ctx) => { requireUser(ctx); if (ctx.user.email_verified_at) return { ok: true, already: true }; const last = store.get('SELECT created_at FROM email_tokens WHERE user_id=? AND kind=\'verify\' ORDER BY created_at DESC LIMIT 1', ctx.user.id); if (last && Date.now() - Date.parse(last.created_at) < 60000) throw new HttpError(429, 'too_soon', '1분 뒤에 다시 시도하세요'); await sendVerification(ctx.user); return { ok: true, message: t('auth.signup.emailSent') }; });
app.post('/api/auth/verify-email', async (ctx) => { const b = await ctx.body(); const { state, row } = tokenState(b.token, 'verify'); if (state !== 'valid') throw new HttpError(400, 'token_' + state, state === 'used' ? t('auth.verify.used') : t('auth.verify.invalid')); useToken(row); store.run('UPDATE users SET email_verified_at=COALESCE(email_verified_at, ?), updated_at=? WHERE id=?', nowIso(), nowIso(), row.user_id); audit(store, { actorType: 'user', actorId: row.user_id, action: 'auth.email_verified', ip: ctx.ip }); return { ok: true }; });
// ---------------------------------------------------------------- API: 로그인·MFA·로그아웃
app.post('/api/auth/login', async (ctx) => {
const b = await ctx.body(); const email = normEmail(b.email); const password = String(b.password || '');
const until = lockState(email, ctx.ip); if (until) throw new HttpError(429, 'locked', t('auth.login.locked', { until: fmtKst(until) }), { lockUntil: until });
const u = store.get('SELECT * FROM users WHERE email=?', email);
const okPw = u && u.status === 'active' && verifyPassword(password, u.pass_salt, u.pass_hash);
if (!okPw) { const lock = recordFailure(email, ctx.ip); audit(store, { actorType: 'system', action: 'auth.login_failed', outcome: 'denied', detail: { emailDomain: email.split('@')[1] || '', locked: !!lock }, ip: ctx.ip }); if (lock) throw new HttpError(429, 'locked', t('auth.login.locked', { until: fmtKst(lock) }), { lockUntil: lock }); throw new HttpError(401, 'login_failed', t('auth.login.failed')); }
clearFailures(email, ctx.ip);
const mfa = parseJson(u.mfa_json, null);
if (mfa && mfa.enabledAt) { const pre = makeToken(u.id, u.email, 'mfa_preauth', 5 * 60 * 1000, { ip: ctx.ip }); return { ok: true, mfaRequired: true, preauth: pre }; }
issueSession(ctx, u); audit(store, { actorType: 'user', actorId: u.id, action: 'auth.login', ip: ctx.ip });
return { ok: true, user: publicUser(u), next: b.next && String(b.next).startsWith('/') ? b.next : '/' };
});
app.post('/api/auth/mfa', async (ctx) => {
const b = await ctx.body(); const { state, row } = tokenState(b.preauth, 'mfa_preauth');
if (state !== 'valid') throw new HttpError(400, 'preauth_' + state, t('auth.login.mfaExpired'));
const u = store.get('SELECT * FROM users WHERE id=?', row.user_id); const mfa = parseJson(u.mfa_json, {}); const code = String(b.code || '').trim();
let how = null;
const counter = totp.verifyCode(mfa.secret, code, { lastCounter: mfa.lastCounter ?? null });
if (counter != null) { how = 'totp'; mfa.lastCounter = counter; store.run('UPDATE users SET mfa_json=? WHERE id=?', toJson(mfa), u.id); }
else { const rec = parseJson(u.recovery_json, []); const h = sha256(code.toLowerCase().replace(/\s/g, '')); const hit = rec.find((c) => !c.usedAt && safeEqual(c.hash, h)); if (hit) { hit.usedAt = nowIso(); store.run('UPDATE users SET recovery_json=? WHERE id=?', toJson(rec), u.id); how = 'recovery'; } }
if (!how) { const lock = recordFailure(u.email, ctx.ip); audit(store, { actorType: 'user', actorId: u.id, action: 'auth.mfa_failed', outcome: 'denied', ip: ctx.ip }); if (lock) { useToken(row); throw new HttpError(429, 'locked', t('auth.login.locked', { until: fmtKst(lock) })); } throw new HttpError(401, 'mfa_failed', t('auth.mfa.badCode')); }
useToken(row); clearFailures(u.email, ctx.ip); issueSession(ctx, u); audit(store, { actorType: 'user', actorId: u.id, action: 'auth.login', detail: { mfa: how }, ip: ctx.ip });
const left = how === 'recovery' ? parseJson(u.recovery_json, []).filter((c) => !c.usedAt).length - 1 : null;
if (how === 'recovery') notify(store, { userId: u.id, category: 'security', severity: 'warning', mandatory: true, title: '복구 코드로 로그인했습니다', body: `남은 복구 코드 ${left}개. 본인이 아니라면 비밀번호를 바꾸고 복구 코드를 다시 만드세요.`, link: '/account/security' });
return { ok: true, user: publicUser(u), next: '/', recoveryLeft: left };
});
app.post('/api/auth/logout', (ctx) => { if (ctx.session) revokeSession(store, ctx.session.id, ctx.user.id); ctx.setCookie(cookie(cookieName('customer'), '', { maxAge: 0, secure: secure(ctx) })); return { ok: true }; });
// ---------------------------------------------------------------- API: 비밀번호 재설정
app.post('/api/auth/request-reset', async (ctx) => {
const b = await ctx.body(); const email = normEmail(b.email); const u = EMAIL_RE.test(email) ? store.get('SELECT * FROM users WHERE email=? AND status=\'active\'', email) : null;
if (u) { const recent = store.get('SELECT created_at FROM email_tokens WHERE user_id=? AND kind=\'reset\' ORDER BY created_at DESC LIMIT 1', u.id); if (!recent || Date.now() - Date.parse(recent.created_at) > 60000) { const raw = makeToken(u.id, u.email, 'reset', 3600 * 1000); await deps.mailer.send({ to: u.email, subject: '[Ariweb] 비밀번호 재설정', text: `아래 링크에서 새 비밀번호를 정하세요(1시간 유효, 1회용).\n${deps.config.publicUrl}/reset-password/${raw}\n\n요청하지 않았다면 이 메일을 무시하세요.` }).catch((e) => deps.log(`[auth] 재설정 메일 실패: ${e.message}`)); audit(store, { actorType: 'user', actorId: u.id, action: 'auth.reset_requested', ip: ctx.ip }); } }
return { ok: true, message: t('auth.reset.requested') }; // 존재 여부와 무관하게 같은 응답
});
app.get('/api/auth/reset/:token', (ctx) => ({ ok: true, state: tokenState(ctx.params.token, 'reset').state }));
app.post('/api/auth/reset', async (ctx) => {
const b = await ctx.body(); const { state, row } = tokenState(b.token, 'reset'); if (state !== 'valid') throw new HttpError(400, 'token_' + state, state === 'used' ? t('auth.reset.used') : t('auth.reset.expired')); checkPassword(b.password);
const { salt, hash } = hashPassword(b.password); useToken(row);
store.run('UPDATE users SET pass_hash=?, pass_salt=?, session_gen=session_gen+1, failed_logins=0, lock_until=NULL, updated_at=? WHERE id=?', hash, salt, nowIso(), row.user_id);
revokeOtherSessions(store, row.user_id, null, 'customer'); store.run('DELETE FROM login_attempts WHERE key LIKE ?', row.email + '|%');
audit(store, { actorType: 'user', actorId: row.user_id, action: 'auth.password_reset', ip: ctx.ip }); notify(store, { userId: row.user_id, category: 'security', severity: 'warning', mandatory: true, title: '비밀번호가 재설정되었습니다', body: '본인이 아니라면 즉시 고객 지원에 문의하세요.', link: '/account/security' });
return { ok: true, message: t('auth.reset.done') };
});
// ---------------------------------------------------------------- API: 내 계정
app.patch('/api/account/profile', async (ctx) => { requireUser(ctx); const b = await ctx.body(); const name = String(b.displayName || '').trim(); if (!name || name.length > 80) throw new HttpError(400, 'bad_name', '표시 이름을 입력하세요(80자 이내)'); store.run('UPDATE users SET display_name=?, updated_at=? WHERE id=?', name, nowIso(), ctx.user.id); audit(store, { actorType: 'user', actorId: ctx.user.id, action: 'account.profile', ip: ctx.ip }); return { ok: true, message: t('auth.account.saved') }; });
app.post('/api/account/password', async (ctx) => {
requireUser(ctx); const b = await ctx.body(); if (!verifyPassword(String(b.current || ''), ctx.user.pass_salt, ctx.user.pass_hash)) throw new HttpError(400, 'wrong_password', t('auth.password.wrongCurrent')); checkPassword(b.next); if (String(b.current) === String(b.next)) throw new HttpError(400, 'same_password', t('auth.password.same'));
const { salt, hash } = hashPassword(b.next); store.run('UPDATE users SET pass_hash=?, pass_salt=?, session_gen=session_gen+1, updated_at=? WHERE id=?', hash, salt, nowIso(), ctx.user.id);
revokeOtherSessions(store, ctx.user.id, ctx.session.id, 'customer'); markReauth(store, ctx.session.id); audit(store, { actorType: 'user', actorId: ctx.user.id, action: 'account.password_changed', ip: ctx.ip });
return { ok: true, message: t('auth.reset.done') };
});
app.post('/api/account/reauth', async (ctx) => { requireUser(ctx); const b = await ctx.body(); if (!verifyPassword(String(b.password || ''), ctx.user.pass_salt, ctx.user.pass_hash)) throw new HttpError(400, 'wrong_password', t('auth.password.wrongCurrent')); markReauth(store, ctx.session.id); return { ok: true }; });
app.get('/api/account/sessions', (ctx) => { requireUser(ctx); return { ok: true, current: ctx.session.id, sessions: listSessions(store, ctx.user.id, 'customer') }; });
app.delete('/api/account/sessions/:id', (ctx) => { requireUser(ctx); const n = revokeSession(store, ctx.params.id, ctx.user.id); audit(store, { actorType: 'user', actorId: ctx.user.id, action: 'account.session_revoked', detail: { sessionId: ctx.params.id }, ip: ctx.ip }); return { ok: true, revoked: n }; });
app.post('/api/account/sessions/revoke-others', (ctx) => { requireUser(ctx); const n = revokeOtherSessions(store, ctx.user.id, ctx.session.id, 'customer'); audit(store, { actorType: 'user', actorId: ctx.user.id, action: 'account.sessions_revoked_others', detail: { count: n }, ip: ctx.ip }); return { ok: true, revoked: n }; });
// MFA (§3.3): setup → 비밀 임시 저장(pending) → enable(코드 확인) → 복구 코드 1회 표시
app.post('/api/account/mfa/setup', (ctx) => { requireUser(ctx); const mfa = parseJson(ctx.user.mfa_json, null); if (mfa && mfa.enabledAt) throw new HttpError(400, 'mfa_already', t('auth.mfa.enabled')); const secret = totp.generateSecret(); store.run('UPDATE users SET mfa_json=? WHERE id=?', toJson({ secret, pendingAt: nowIso() }), ctx.user.id); return { ok: true, secret: totp.prettySecret(secret), otpauth: totp.otpauthUri(secret, ctx.user.email) }; });
app.post('/api/account/mfa/enable', async (ctx) => {
requireUser(ctx); const b = await ctx.body(); const mfa = parseJson(ctx.user.mfa_json, null); if (!mfa || !mfa.secret || mfa.enabledAt) throw new HttpError(400, 'mfa_not_pending', '먼저 인증 앱 등록을 시작하세요');
const counter = totp.verifyCode(mfa.secret, b.code); if (counter == null) throw new HttpError(400, 'mfa_bad_code', t('auth.mfa.badCode'));
const codes = totp.generateRecoveryCodes(); store.run('UPDATE users SET mfa_json=?, recovery_json=?, updated_at=? WHERE id=?', toJson({ secret: mfa.secret, enabledAt: nowIso(), lastCounter: counter }), toJson(codes.map((c) => ({ hash: sha256(c), usedAt: null }))), nowIso(), ctx.user.id);
audit(store, { actorType: 'user', actorId: ctx.user.id, action: 'account.mfa_enabled', ip: ctx.ip }); return { ok: true, recoveryCodes: codes, message: t('auth.mfa.recoveryOnce') };
});
app.post('/api/account/mfa/disable', async (ctx) => {
requireUser(ctx); const b = await ctx.body(); const mfa = parseJson(ctx.user.mfa_json, null); if (!mfa || !mfa.enabledAt) throw new HttpError(400, 'mfa_off', t('auth.mfa.disabled'));
if (!verifyPassword(String(b.password || ''), ctx.user.pass_salt, ctx.user.pass_hash)) throw new HttpError(400, 'wrong_password', t('auth.password.wrongCurrent')); if (totp.verifyCode(mfa.secret, b.code, { lastCounter: mfa.lastCounter ?? null }) == null) throw new HttpError(400, 'mfa_bad_code', t('auth.mfa.badCode'));
store.run('UPDATE users SET mfa_json=NULL, recovery_json=NULL, updated_at=? WHERE id=?', nowIso(), ctx.user.id); audit(store, { actorType: 'user', actorId: ctx.user.id, action: 'account.mfa_disabled', ip: ctx.ip }); notify(store, { userId: ctx.user.id, category: 'security', severity: 'warning', mandatory: true, title: '다중 인증이 해제되었습니다', body: '본인이 아니라면 즉시 비밀번호를 바꾸세요.', link: '/account/security' }); return { ok: true };
});
app.post('/api/account/mfa/recovery-codes', async (ctx) => { requireUser(ctx); const b = await ctx.body(); const mfa = parseJson(ctx.user.mfa_json, null); if (!mfa || !mfa.enabledAt) throw new HttpError(400, 'mfa_off', t('auth.mfa.disabled')); if (!verifyPassword(String(b.password || ''), ctx.user.pass_salt, ctx.user.pass_hash)) throw new HttpError(400, 'wrong_password', t('auth.password.wrongCurrent')); const codes = totp.generateRecoveryCodes(); store.run('UPDATE users SET recovery_json=?, updated_at=? WHERE id=?', toJson(codes.map((c) => ({ hash: sha256(c), usedAt: null }))), nowIso(), ctx.user.id); audit(store, { actorType: 'user', actorId: ctx.user.id, action: 'account.recovery_regenerated', ip: ctx.ip }); return { ok: true, recoveryCodes: codes, message: t('auth.mfa.recoveryOnce') }; });
}
+10
View File
@@ -0,0 +1,10 @@
/**
* portal/auth/model.mjs — 인증 영역 세부 표.
* login_attempts : 이메일+IP 별 실패 횟수와 잠금 해제 시각(§3.2 반복 실패 제한)
* consents 는 users.consents_json, MFA 는 users.mfa_json({ secret, enabledAt, lastCounter }), 복구 코드는 users.recovery_json([{hash, usedAt}]) 에 둔다.
* 이메일 확인·비밀번호 재설정·MFA 사전 인증 토큰은 core 의 email_tokens(kind: verify | reset | mfa_preauth) 를 쓴다.
*/
export const MIGRATIONS = [{ id: 'auth_0001', sql: `
CREATE TABLE IF NOT EXISTS login_attempts (
key TEXT PRIMARY KEY, failures INTEGER NOT NULL DEFAULT 0, lock_until TEXT, updated_at TEXT NOT NULL
);` }];
+61
View File
@@ -0,0 +1,61 @@
/**
* portal/auth/pages.mjs — 인증·계정 화면 HTML(서버 렌더). 데이터·동작은 /ui/auth/auth.js 가 /api/auth·/api/account 를 불러 처리한다.
* 로그인 전 화면(로그인·가입·확인·재설정)은 공통 틀의 비로그인 모드로, 로그인 뒤 화면(내 계정·보안·기기)은 왼쪽 메뉴가 있는 모드로 그린다.
*/
import { esc } from '../../lib/http.mjs';
import { t } from '../../lib/i18n.mjs';
const box = (title, inner, foot = '') => `<div class="card" style="max-width:460px;margin:24px auto"><h2>${esc(title)}</h2>${inner}${foot ? `<p class="sub" style="margin-top:12px">${foot}</p>` : ''}</div>`;
const f = (id, label, type = 'text', extra = '', hint = '') => `<div class="f"><label for="${id}">${esc(label)}</label><input id="${id}" name="${id}" type="${type}" ${extra}>${hint ? `<span class="hint">${esc(hint)}</span>` : ''}<span class="err" data-err="${id}"></span></div>`;
export function loginPage(shell, { next = '/', notice = '' } = {}) {
return shell({ title: t('auth.login.title'), nav: null, scripts: ['/ui/auth/auth.js'], body: box(t('auth.login.title'), `
${notice ? `<div class="alert info">${esc(notice)}</div>` : ''}
<form id="login" data-next="${esc(next)}">${f('email', t('auth.login.email'), 'email', 'autocomplete="username" required')}${f('password', t('auth.login.password'), 'password', 'autocomplete="current-password" required')}
<p class="err" id="login-err" role="alert"></p><button class="btn primary" type="submit">${esc(t('auth.login.submit'))}</button></form>
<form id="mfa" hidden><p>${esc(t('auth.login.mfaHint'))}</p>${f('code', t('auth.login.mfaTitle'), 'text', 'inputmode="numeric" autocomplete="one-time-code" required')}<p class="err" id="mfa-err" role="alert"></p><button class="btn primary" type="submit">${esc(t('common.confirm'))}</button></form>`,
`<a href="/signup">${esc(t('auth.login.noAccount'))} ${esc(t('auth.signup.title'))}</a> · <a href="/reset-password">${esc(t('auth.login.forgot'))}</a><br><span class="muted">${esc(t('auth.login.operatorNote'))}</span>`) });
}
export function signupPage(shell) {
return shell({ title: t('auth.signup.title'), nav: null, scripts: ['/ui/auth/auth.js'], body: box(t('auth.signup.title'), `
<form id="signup">${f('email', t('auth.login.email'), 'email', 'autocomplete="username" required')}${f('displayName', t('auth.signup.displayName'), 'text', 'maxlength="80" required')}${f('password', t('auth.login.password'), 'password', 'autocomplete="new-password" minlength="8" required', t('auth.signup.passwordHint'))}
<div class="f"><label><input type="checkbox" name="terms" style="width:auto"> ${esc(t('auth.signup.terms'))} <a href="/help#terms">보기</a></label><label><input type="checkbox" name="privacy" style="width:auto"> ${esc(t('auth.signup.privacy'))} <a href="/help#privacy">보기</a></label><label><input type="checkbox" name="marketing" style="width:auto"> ${esc(t('auth.signup.marketing'))}</label></div>
<p class="hint">${esc(t('auth.signup.policyNote'))}</p><p class="err" id="signup-err" role="alert"></p><button class="btn primary" type="submit">${esc(t('auth.signup.submit'))}</button></form>
<div id="signup-done" hidden class="alert info"></div>`, `<a href="/login">${esc(t('auth.login.title')) }</a>`) });
}
export function verifyPage(shell, { state, user }) {
const msg = { ok: t('auth.verify.ok'), invalid: t('auth.verify.invalid'), used: t('auth.verify.used') }[state] || t('auth.verify.invalid');
return shell({ title: t('auth.verify.title'), nav: null, user, scripts: ['/ui/auth/auth.js'], body: box(t('auth.verify.title'), `<div class="alert ${state === 'ok' ? 'info' : 'warning'}">${esc(msg)}</div>${state === 'ok' ? `<a class="btn primary" href="/">${esc(t('nav.dashboard'))}</a>` : `<button class="btn" data-action="resend-verify">${esc(t('auth.verify.resend'))}</button> <a class="btn" href="/login">${esc(t('auth.login.title'))}</a>`}`) });
}
export function resetRequestPage(shell) {
return shell({ title: t('auth.reset.title'), nav: null, scripts: ['/ui/auth/auth.js'], body: box(t('auth.reset.title'), `<form id="reset-request">${f('email', t('auth.login.email'), 'email', 'required')}<p class="err" id="rr-err"></p><button class="btn primary" type="submit">${esc(t('auth.reset.request'))}</button></form><div id="rr-done" class="alert info" hidden></div>`, `<a href="/login">${esc(t('auth.login.title'))}</a>`) });
}
export function resetFormPage(shell, { token, state }) {
const body = state === 'valid' ? `<form id="reset" data-token="${esc(token)}">${f('password', t('auth.reset.newPassword'), 'password', 'autocomplete="new-password" minlength="8" required', t('auth.signup.passwordHint'))}<p class="err" id="reset-err"></p><button class="btn primary" type="submit">${esc(t('auth.reset.submit'))}</button></form><div id="reset-done" class="alert info" hidden></div>`
: `<div class="alert warning"><b>${esc(t('auth.reset.state'))}:</b> ${esc(state === 'used' ? t('auth.reset.used') : t('auth.reset.expired'))}</div><a class="btn" href="/reset-password">${esc(t('auth.reset.request'))}</a>`;
return shell({ title: t('auth.reset.title'), nav: null, scripts: ['/ui/auth/auth.js'], body: box(t('auth.reset.title'), body) });
}
/** 내 계정(프로필·비밀번호·이메일 확인 상태) */
export function accountPage(shell, o) {
const u = o.user;
return shell({ ...o, title: t('auth.account.title'), nav: null, crumbs: [{ label: t('auth.account.title') }], scripts: ['/ui/auth/auth.js'], body: `
<div class="tabs"><a class="on" href="/account">${esc(t('auth.account.profile'))}</a><a href="/account/security">${esc(t('auth.account.security'))}</a><a href="/account/sessions">${esc(t('auth.sessions.title'))}</a></div>
${!u.email_verified_at ? `<div class="alert warning">${esc(t('auth.verify.banner'))} <button class="btn" data-action="resend-verify">${esc(t('auth.verify.resend'))}</button></div>` : ''}
<div class="card"><h2>${esc(t('auth.account.profile'))}</h2><dl class="kv"><dt>${esc(t('auth.login.email'))}</dt><dd>${esc(u.email)} ${u.email_verified_at ? '<span class="badge ok">✔ 확인됨</span>' : '<span class="badge warning">! 확인 전</span>'}</dd></dl>
<form id="profile" data-dirty-guard>${f('displayName', t('auth.signup.displayName'), 'text', `value="${esc(u.display_name)}" maxlength="80" required`)}<button class="btn primary" type="submit">${esc(t('common.save'))}</button></form></div>
<div class="card"><h2>${esc(t('auth.account.changePassword'))}</h2><form id="password">${f('current', t('auth.account.currentPassword'), 'password', 'autocomplete="current-password" required')}${f('next', t('auth.reset.newPassword'), 'password', 'autocomplete="new-password" minlength="8" required')}<p class="err" id="pw-err"></p><button class="btn primary" type="submit">${esc(t('auth.account.changePassword'))}</button></form><p class="hint">비밀번호를 바꾸면 다른 기기의 로그인이 모두 종료됩니다.</p></div>` });
}
export function securityPage(shell, o) {
const mfaOn = !!o.mfaEnabled;
return shell({ ...o, title: t('auth.mfa.title'), nav: null, crumbs: [{ label: t('auth.account.title'), href: '/account' }, { label: t('auth.account.security') }], scripts: ['/ui/auth/auth.js'], body: `
<div class="tabs"><a href="/account">${esc(t('auth.account.profile'))}</a><a class="on" href="/account/security">${esc(t('auth.account.security'))}</a><a href="/account/sessions">${esc(t('auth.sessions.title'))}</a></div>
<div class="card"><h2>${esc(t('auth.mfa.title'))}</h2><dl class="kv"><dt>${esc(t('auth.mfa.level'))}</dt><dd>${mfaOn ? `<span class="badge ok">✔ ${esc(t('auth.mfa.levelMfa'))}</span>` : `<span class="badge warning">! ${esc(t('auth.mfa.levelPw'))}</span>`}</dd><dt>${esc(t('auth.mfa.title'))}</dt><dd>${esc(mfaOn ? t('auth.mfa.enabled') : t('auth.mfa.disabled'))}${o.mfaEnabledAt ? ` <span class="sub">(${esc(o.mfaEnabledAt)})</span>` : ''}</dd>${mfaOn ? `<dt>${esc(t('auth.mfa.recovery'))}</dt><dd>남은 복구 코드 ${o.recoveryLeft}개</dd>` : ''}</dl>
<div id="mfa-area" data-enabled="${mfaOn ? 1 : 0}">${mfaOn ? `<form id="mfa-disable">${f('password', t('auth.login.password'), 'password', 'autocomplete="current-password" required')}${f('code', t('auth.login.mfaTitle'), 'text', 'inputmode="numeric" required')}<p class="hint">${esc(t('auth.mfa.disableNeeds'))}</p><p class="err" id="mfad-err"></p><button class="btn danger" type="submit">${esc(t('auth.mfa.disable'))}</button></form>
<form id="mfa-regen" style="margin-top:12px">${f('password2', t('auth.login.password'), 'password', 'autocomplete="current-password" required')}<button class="btn" type="submit">${esc(t('auth.mfa.regenerate'))}</button></form><div id="recovery-out"></div>`
: `<button class="btn primary" data-action="mfa-setup">${esc(t('auth.mfa.setup'))}</button><div id="mfa-setup"></div>`}</div></div>` });
}
export function sessionsPage(shell, o) {
return shell({ ...o, title: t('auth.sessions.title'), nav: null, crumbs: [{ label: t('auth.account.title'), href: '/account' }, { label: t('auth.sessions.title') }], scripts: ['/ui/auth/auth.js'], body: `
<div class="tabs"><a href="/account">${esc(t('auth.account.profile'))}</a><a href="/account/security">${esc(t('auth.account.security'))}</a><a class="on" href="/account/sessions">${esc(t('auth.sessions.title'))}</a></div>
<div class="card"><div class="ph-row"><h2>${esc(t('auth.sessions.title'))}</h2><button class="btn danger" data-action="revoke-others">${esc(t('auth.sessions.revokeOthers'))}</button></div><div id="sessions">${o.statePanel('loading')}</div></div>` });
}
+28
View File
@@ -0,0 +1,28 @@
/* portal/auth/public/auth.js — 로그인·가입·확인·재설정·내 계정·보안·기기 화면 동작. 서버 응답 message 를 그대로 보이고 오류는 입력 아래에 표시한다. */
(() => {
const $ = (s, r = document) => r.querySelector(s); const val = (form, n) => (form.elements[n] ? form.elements[n].value : '');
const showErr = (id, e) => { const el = $(id); if (el) el.textContent = e.message || String(e); };
const login = $('#login'); const mfa = $('#mfa'); let preauth = null;
if (login) login.onsubmit = async (e) => { e.preventDefault(); showErr('#login-err', ''); try { const r = await aw.api('POST', '/api/auth/login', { email: val(login, 'email'), password: val(login, 'password'), next: login.dataset.next }); if (r.mfaRequired) { preauth = r.preauth; login.hidden = true; mfa.hidden = false; mfa.elements.code.focus(); } else location.href = r.next || '/'; } catch (err) { showErr('#login-err', err); if (err.detail && err.detail.lockUntil) login.querySelector('button').disabled = true; } };
if (mfa) mfa.onsubmit = async (e) => { e.preventDefault(); showErr('#mfa-err', ''); try { const r = await aw.api('POST', '/api/auth/mfa', { preauth, code: val(mfa, 'code') }); if (r.recoveryLeft != null) alert(`복구 코드로 로그인했습니다. 남은 복구 코드 ${r.recoveryLeft}개`); location.href = r.next || '/'; } catch (err) { showErr('#mfa-err', err); if (/preauth_/.test(err.code)) { mfa.hidden = true; login.hidden = false; showErr('#login-err', err); } } };
const signup = $('#signup');
if (signup) signup.onsubmit = async (e) => { e.preventDefault(); showErr('#signup-err', ''); try { const r = await aw.api('POST', '/api/auth/signup', { email: val(signup, 'email'), displayName: val(signup, 'displayName'), password: val(signup, 'password'), consents: { terms: signup.elements.terms.checked, privacy: signup.elements.privacy.checked, marketing: signup.elements.marketing.checked } }); const d = $('#signup-done'); d.textContent = r.message; d.hidden = false; signup.hidden = true; setTimeout(() => { location.href = r.next || '/'; }, 1500); } catch (err) { showErr('#signup-err', err); } };
document.addEventListener('click', async (e) => { if (e.target.matches('[data-action=resend-verify]')) { try { const r = await aw.api('POST', '/api/auth/resend-verification'); aw.toast(r.message || '보냈습니다'); } catch (err) { aw.toast(err.message); } } });
const rr = $('#reset-request');
if (rr) rr.onsubmit = async (e) => { e.preventDefault(); try { const r = await aw.api('POST', '/api/auth/request-reset', { email: val(rr, 'email') }); const d = $('#rr-done'); d.textContent = r.message; d.hidden = false; rr.hidden = true; } catch (err) { showErr('#rr-err', err); } };
const reset = $('#reset');
if (reset) reset.onsubmit = async (e) => { e.preventDefault(); try { const r = await aw.api('POST', '/api/auth/reset', { token: reset.dataset.token, password: val(reset, 'password') }); const d = $('#reset-done'); d.textContent = r.message; d.hidden = false; reset.hidden = true; setTimeout(() => { location.href = '/login?notice=reset'; }, 1500); } catch (err) { showErr('#reset-err', err); } };
const profile = $('#profile');
if (profile) profile.onsubmit = async (e) => { e.preventDefault(); try { const r = await aw.api('PATCH', '/api/account/profile', { displayName: val(profile, 'displayName') }); aw.markClean(); aw.toast(r.message); } catch (err) { aw.toast(err.message); } };
const pw = $('#password');
if (pw) pw.onsubmit = async (e) => { e.preventDefault(); showErr('#pw-err', ''); try { const r = await aw.api('POST', '/api/account/password', { current: val(pw, 'current'), next: val(pw, 'next') }); pw.reset(); aw.toast(r.message); } catch (err) { showErr('#pw-err', err); } };
const codesHtml = (r) => `<div class="alert warning"><b>${aw.esc(r.message)}</b><ul class="mono">${r.recoveryCodes.map((c) => `<li>${aw.esc(c)}</li>`).join('')}</ul><button class="btn copy" data-action="copy" data-copy="${aw.esc(r.recoveryCodes.join('\n'))}">복사</button></div>`;
document.addEventListener('click', async (e) => { if (!e.target.matches('[data-action=mfa-setup]')) return; try { const r = await aw.api('POST', '/api/account/mfa/setup'); $('#mfa-setup').innerHTML = `<p>인증 앱에서 아래 주소를 등록하거나 수동 입력 키를 넣으세요.</p><p class="mono" style="word-break:break-all">${aw.esc(r.otpauth)}</p><p><b>수동 입력 키:</b> <span class="mono copy" data-action="copy" data-copy="${aw.esc(r.secret.replace(/ /g, ''))}">${aw.esc(r.secret)}</span></p><form id="mfa-enable"><div class="f"><label for="mcode">인증 앱의 6자리 코드</label><input id="mcode" name="code" inputmode="numeric" required></div><p class="err" id="mfae-err"></p><button class="btn primary">코드 확인 후 켜기</button></form><div id="mfa-codes"></div>`; $('#mfa-enable').onsubmit = async (ev) => { ev.preventDefault(); try { const rr2 = await aw.api('POST', '/api/account/mfa/enable', { code: $('#mcode').value }); $('#mfa-enable').hidden = true; $('#mfa-codes').innerHTML = codesHtml(rr2) + '<a class="btn" href="/account/security">완료</a>'; } catch (err) { showErr('#mfae-err', err); } }; } catch (err) { aw.toast(err.message); } });
const md = $('#mfa-disable');
if (md) md.onsubmit = async (e) => { e.preventDefault(); try { await aw.api('POST', '/api/account/mfa/disable', { password: val(md, 'password'), code: val(md, 'code') }); location.reload(); } catch (err) { showErr('#mfad-err', err); } };
const mr = $('#mfa-regen');
if (mr) mr.onsubmit = async (e) => { e.preventDefault(); try { const r = await aw.api('POST', '/api/account/mfa/recovery-codes', { password: val(mr, 'password2') }); $('#recovery-out').innerHTML = codesHtml(r); mr.reset(); } catch (err) { aw.toast(err.message); } };
const sess = $('#sessions');
async function loadSessions() { try { const r = await aw.api('GET', '/api/account/sessions'); if (!r.sessions.length) return aw.state(sess, 'empty'); sess.innerHTML = `<table class="r"><thead><tr><th>기기</th><th>IP</th><th>로그인 시각</th><th>마지막 사용</th><th></th></tr></thead><tbody>${r.sessions.map((s) => `<tr><td data-l="기기">${aw.esc(s.label || '기기')}${s.id === r.current ? ' <span class="badge ok">● 현재 세션</span>' : ''}</td><td data-l="IP">${aw.esc(s.ip || '')}</td><td data-l="로그인 시각">${aw.esc(aw.fmt(s.created_at))}</td><td data-l="마지막 사용">${aw.esc(aw.fmt(s.last_seen_at))}</td><td class="right">${s.id === r.current ? '' : `<button class="btn danger" data-rev="${aw.esc(s.id)}">종료</button>`}</td></tr>`).join('')}</tbody></table>`; sess.querySelectorAll('[data-rev]').forEach((b) => { b.onclick = async () => { await aw.api('DELETE', '/api/account/sessions/' + b.dataset.rev); loadSessions(); }; }); } catch (err) { aw.state(sess, err.status === 403 ? 'forbidden' : 'failed', err.message, { retry: loadSessions }); } }
if (sess) { loadSessions(); document.addEventListener('click', async (e) => { if (e.target.matches('[data-action=revoke-others]')) { if (await aw.confirmDanger({ title: '다른 모든 세션 종료', target: '이 기기를 제외한 모든 로그인', impacts: ['다른 기기에서는 다시 로그인해야 합니다'] })) { await aw.api('POST', '/api/account/sessions/revoke-others'); loadSessions(); } } }); }
})();
+55
View File
@@ -0,0 +1,55 @@
/**
* portal/auth/totp.mjs — TOTP(RFC 6238) 다중 인증 구현. 외부 의존성 없이 node:crypto 만 쓴다.
*
* - 비밀: 20바이트 무작위 → base32(RFC 4648, 패딩 없음). 인증 앱에 수동 입력할 수 있게 4자 단위로 끊어 보여 준다.
* - 코드: HMAC-SHA1(비밀, 30초 카운터) 동적 절단 → 6자리. 검증은 ±1 구간(시계 차이 허용)과 상수 시간 비교.
* - 재사용 방지: 마지막으로 성공한 카운터(lastCounter)를 저장해 같은 코드로 두 번 로그인하지 못하게 한다.
*/
import { randomBytes, createHmac, timingSafeEqual } from 'node:crypto';
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
/** 바이트 → base32 문자열(패딩 없음) */
export function base32Encode(buf) {
let bits = 0, value = 0, out = '';
for (const b of buf) { value = (value << 8) | b; bits += 8; while (bits >= 5) { out += ALPHABET[(value >>> (bits - 5)) & 31]; bits -= 5; } }
if (bits > 0) out += ALPHABET[(value << (5 - bits)) & 31];
return out;
}
/** base32 문자열 → 바이트(공백·소문자 허용) */
export function base32Decode(str) {
const s = String(str || '').toUpperCase().replace(/[^A-Z2-7]/g, ''); let bits = 0, value = 0; const out = [];
for (const c of s) { value = (value << 5) | ALPHABET.indexOf(c); bits += 5; if (bits >= 8) { out.push((value >>> (bits - 8)) & 255); bits -= 8; } }
return Buffer.from(out);
}
/** 새 비밀(base32) */
export const generateSecret = () => base32Encode(randomBytes(20));
/** 수동 입력용 표시: 4자씩 띄어쓰기 */
export const prettySecret = (secret) => String(secret).replace(/(.{4})/g, '$1 ').trim();
/** 인증 앱 등록 URI(QR 코드 생성은 화면에서 이 문자열로) */
export const otpauthUri = (secret, account, issuer = 'Ariweb Suite') => `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(account)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
/** 카운터 값으로 코드 계산 */
export function codeAt(secret, counter) {
const msg = Buffer.alloc(8); msg.writeBigUInt64BE(BigInt(counter));
const h = createHmac('sha1', base32Decode(secret)).update(msg).digest();
const off = h[h.length - 1] & 0xf; const n = ((h[off] & 0x7f) << 24) | (h[off + 1] << 16) | (h[off + 2] << 8) | h[off + 3];
return String(n % 1_000_000).padStart(6, '0');
}
export const counterAt = (ms = Date.now()) => Math.floor(ms / 1000 / 30);
/** 현재 코드(테스트용) */
export const currentCode = (secret, ms = Date.now()) => codeAt(secret, counterAt(ms));
/**
* 코드 검증. 성공하면 맞은 카운터를 돌려주고(호출자가 lastCounter 로 저장), 실패하면 null.
* @param {number|null} lastCounter 마지막 성공 카운터 — 같거나 작은 카운터는 재사용으로 보고 거부
*/
export function verifyCode(secret, code, { ms = Date.now(), window = 1, lastCounter = null } = {}) {
const c = String(code || '').replace(/\s/g, ''); if (!/^\d{6}$/.test(c)) return null;
const base = counterAt(ms);
for (let d = -window; d <= window; d++) {
const k = base + d; if (lastCounter != null && k <= lastCounter) continue;
const a = Buffer.from(codeAt(secret, k)); const b = Buffer.from(c);
if (a.length === b.length && timingSafeEqual(a, b)) return k;
}
return null;
}
/** 복구 코드 10개(xxxx-xxxx 형태, 한 번만 표시) */
export function generateRecoveryCodes(n = 10) { return Array.from({ length: n }, () => { const h = randomBytes(5).toString('hex'); return `${h.slice(0, 5)}-${h.slice(5)}`; }); }
+59
View File
@@ -0,0 +1,59 @@
/**
* portal/dashboard/api.mjs — 고객 대시보드(§5): 요약·주의 항목·최근 활동.
*
* 모든 수치는 현재 그룹(ctx.group) 기준으로 중심 표(sites·domains·mail_services·mailboxes·backup_options·orders·jobs)와 lib/quota 에서 계산한다.
* 운영 사이트 수(§5.1): quota 'sites' 의 배정(사용 중)·예약(예약됨) 과 구매 총량. 초안·제작 중은 포함하지 않는다.
* 주의 항목(§5.2)은 중요도 순(필수·critical 알림 → 결제·승인 대기 → 도메인·인증서 → 사용량 → 배포 실패 → 메일 → 백업 → 이전 승인 → 정지·보존 만료 → 장애)이며 각 항목은 해결 화면으로 연결한다.
* 최근 활동은 감사 기록(audit_log)에서 가져오고 automatic 으로 사용자 작업과 자동 작업을 구분한다.
*/
import { requireGroup } from '../auth/guard.mjs';
import { figures, warnLevel, summary as quotaSummary } from '../../lib/quota.mjs';
import { listNotifications } from '../../lib/notify.mjs';
import { listAudit } from '../../lib/audit.mjs';
import { label } from '../../lib/status.mjs';
import { t } from '../../lib/i18n.mjs';
import { nowIso, parseJson } from '../../lib/db.mjs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const JOB_KIND_LABEL = { order: '주문', deploy: '배포', restore: '복원', relocation: '이전', backup: '백업', transfer: '소유권 이전', mail: '메일', domain: '도메인' };
export async function register(app, deps) {
const { store } = deps;
app.static('/ui/dashboard', join(HERE, 'public'));
app.get('/', (ctx) => { if (!ctx.user) return ctx.redirect('/login'); if (!ctx.group) return ctx.html(deps.shell(ctx, { title: t('dashboard.title'), nav: 'dashboard', body: deps.statePanel('forbidden', t('error.groupRequired')) })); return ctx.html(deps.shell(ctx, { title: t('dashboard.title'), nav: 'dashboard', crumbs: [{ label: t('dashboard.title') }], actions: [{ label: '사이트 만들기', href: '/sites/new', kind: 'primary' }], scripts: ['/ui/dashboard/dashboard.js'], body: `${!ctx.user.email_verified_at ? `<div class="alert warning" data-alert="verify">${t('auth.verify.banner')} <a href="/account">확인 메일 다시 보내기</a></div>` : ''}<div id="dash">${deps.statePanel('loading')}</div>` })); });
app.get('/api/dashboard', (ctx) => {
requireGroup(ctx); const gid = ctx.group.id; const now = nowIso();
const count = (sql, ...p) => store.get(sql, ...p)?.n || 0;
const sitesQ = figures(store, gid, 'sites');
const live = count('SELECT COUNT(*) AS n FROM sites WHERE group_id=? AND lifecycle IN (\'live\',\'needs_domain\',\'paused\') AND deleted_at IS NULL', gid);
const summaryCards = {
sites: { live, purchased: sitesQ.purchased, inUse: sitesQ.allocated, reserved: sitesQ.reserved, unallocated: sitesQ.unallocated },
drafts: count('SELECT COUNT(*) AS n FROM sites WHERE group_id=? AND lifecycle IN (\'draft\',\'building\',\'deploying\') AND deleted_at IS NULL', gid),
health: { ok: count('SELECT COUNT(*) AS n FROM sites WHERE group_id=? AND lifecycle=\'live\' AND health=\'ok\'', gid), warn: count('SELECT COUNT(*) AS n FROM sites WHERE group_id=? AND lifecycle=\'live\' AND health IN (\'degraded\',\'stale\',\'unknown\')', gid), bad: count('SELECT COUNT(*) AS n FROM sites WHERE group_id=? AND lifecycle=\'live\' AND health IN (\'partial_outage\',\'down\')', gid) },
deploy: { managed: count('SELECT COUNT(*) AS n FROM sites WHERE group_id=? AND deploy_mode=\'managed\' AND deleted_at IS NULL', gid), external: count('SELECT COUNT(*) AS n FROM sites WHERE group_id=? AND deploy_mode=\'external\' AND deleted_at IS NULL', gid) },
domains: { total: count('SELECT COUNT(*) AS n FROM domains WHERE group_id=?', gid), action: count('SELECT COUNT(*) AS n FROM domains WHERE group_id=? AND (ownership_state<>\'verified\' OR dns_state<>\'ok\' OR cert_state IN (\'failed\',\'expiring\',\'expired\'))', gid) },
mail: { services: count('SELECT COUNT(*) AS n FROM mail_services WHERE group_id=? AND deleted_at IS NULL', gid), mailboxes: count('SELECT COUNT(*) AS n FROM mailboxes WHERE group_id=? AND deleted_at IS NULL', gid) },
backup: count('SELECT COUNT(*) AS n FROM backup_options WHERE group_id=? AND status=\'active\'', gid),
jobs: Object.entries(deps.jobs.list({ groupId: gid, status: ['queued', 'running', 'awaiting_external', 'verifying', 'needs_user', 'awaiting_approval', 'awaiting_payment', 'reverting'], limit: 500 }).reduce((acc, j) => { const k = (j.kind || '').split('.')[0]; acc[k] = (acc[k] || 0) + 1; return acc; }, {})).map(([k, n]) => ({ kind: k, label: JOB_KIND_LABEL[k] || k, n })),
cost: { estimated: store.get('SELECT COALESCE(SUM(total),0) AS n FROM orders WHERE group_id=? AND status IN (\'approved\') AND strftime(\'%Y-%m\', approved_at)=strftime(\'%Y-%m\', ?)', gid, now)?.n || 0, awaiting: store.get('SELECT COALESCE(SUM(total),0) AS n FROM orders WHERE group_id=? AND status IN (\'awaiting_payment\',\'awaiting_approval\')', gid)?.n || 0, currency: 'KRW', note: '승인된 주문 합계 기준 추정값입니다' },
};
// 주의 항목(중요도 순)
const attention = [];
for (const n of listNotifications(store, { groupId: gid, limit: 50 }).filter((x) => x.state === 'open' && (x.mandatory || x.severity === 'critical'))) attention.push({ severity: 'critical', text: n.title, href: n.link || '/notifications', kind: 'notification' });
for (const o of store.all('SELECT id, order_no, status, total, due_at FROM orders WHERE group_id=? AND status IN (\'awaiting_payment\',\'awaiting_approval\') ORDER BY created_at DESC LIMIT 5', gid)) attention.push({ severity: 'warning', text: `주문 ${o.order_no} ${label('job', o.status)}${o.due_at ? ` (기한 ${o.due_at.slice(0, 10)})` : ''}`, href: `/billing/orders/${o.id}`, kind: 'order' });
for (const d of store.all('SELECT id, name, ownership_state, dns_state, cert_state FROM domains WHERE group_id=? AND (ownership_state<>\'verified\' OR dns_state<>\'ok\' OR cert_state IN (\'failed\',\'expiring\',\'expired\')) LIMIT 5', gid)) attention.push({ severity: d.cert_state === 'failed' ? 'critical' : 'warning', text: `도메인 ${d.name}: ${d.ownership_state !== 'verified' ? '소유 확인 필요' : d.dns_state !== 'ok' ? 'DNS 확인 필요' : '인증서 ' + d.cert_state}`, href: `/domains/${d.id}`, kind: 'domain' });
for (const q of quotaSummary(store, gid)) { if (q.purchased > 0) { const lvl = warnLevel(q.used, q.purchased); if (lvl >= 70) attention.push({ severity: lvl >= 95 ? 'critical' : 'warning', text: `${q.label} 사용량 ${lvl}% 구간${lvl >= 100 ? ' — 제한 적용 중' : ''}`, href: '/usage', kind: 'usage' }); } }
for (const j of deps.jobs.list({ groupId: gid, status: ['failed', 'needs_user'], limit: 10 })) attention.push({ severity: j.status === 'failed' ? 'critical' : 'warning', text: `${j.label || j.kind}: ${label('job', j.status)}${j.user_message ? ' — ' + j.user_message : ''}`, href: j.subject_type === 'site' ? `/sites/${j.subject_id}` : '/notifications', kind: 'job' });
for (const m of store.all('SELECT id, name, status FROM mail_services WHERE group_id=? AND status IN (\'failed\',\'needs_setup\',\'needs_user\') AND deleted_at IS NULL LIMIT 5', gid)) attention.push({ severity: 'warning', text: `메일 ${m.name}: ${label('job', m.status)}`, href: `/mail/${m.id}`, kind: 'mail' });
for (const b of store.all('SELECT b.id, s.name, b.consecutive_failures FROM backup_options b JOIN sites s ON s.id=b.site_id WHERE b.group_id=? AND b.consecutive_failures>0 LIMIT 5', gid)) attention.push({ severity: b.consecutive_failures >= 3 ? 'critical' : 'warning', text: `사이트 ${b.name} 백업 ${b.consecutive_failures}회 연속 실패`, href: `/backup`, kind: 'backup' });
for (const r of store.all('SELECT id, subject_id, status FROM relocations WHERE group_id=? AND status IN (\'awaiting_approval\',\'needs_user\') LIMIT 5', gid)) attention.push({ severity: 'warning', text: `서버 이전 ${label('job', r.status)}`, href: `/sites/${r.subject_id}`, kind: 'relocation' });
for (const s of store.all('SELECT id, name, lifecycle, retention_until, delete_after FROM sites WHERE group_id=? AND lifecycle IN (\'paused\',\'cancel_scheduled\',\'readonly_retention\',\'pending_delete\') LIMIT 5', gid)) attention.push({ severity: s.lifecycle === 'pending_delete' ? 'critical' : 'warning', text: `사이트 ${s.name}: ${label('service', s.lifecycle)}${s.delete_after ? ` (삭제 예정 ${s.delete_after.slice(0, 10)})` : ''}`, href: `/sites/${s.id}`, kind: 'lifecycle' });
for (const n of listNotifications(store, { groupId: gid, category: 'incident', limit: 5 }).filter((x) => x.state === 'open')) attention.push({ severity: 'warning', text: n.title, href: n.link || '/notifications', kind: 'incident' });
const order = { critical: 0, warning: 1, info: 2 }; attention.sort((a, b) => order[a.severity] - order[b.severity]);
const recent = listAudit(store, { groupId: gid, limit: 20 }).filter((a) => !/^auth\./.test(a.action)).map((a) => ({ at: a.at, action: a.action, actor: a.actor_label || a.actor_type, automatic: !!a.automatic || a.actor_type === 'system', outcome: a.outcome, subjectType: a.subject_type, subjectId: a.subject_id }));
return { ok: true, generatedAt: now, summary: summaryCards, attention, recent, verified: !!ctx.user.email_verified_at };
});
}
+27
View File
@@ -0,0 +1,27 @@
/* portal/dashboard/public/dashboard.js — 대시보드 요약 카드·주의 항목·최근 활동 렌더(30초마다 갱신). */
(() => {
const el = document.getElementById('dash'); if (!el) return;
const ACT = { 'deploy.start': '배포 시작', 'deploy.done': '배포 완료', 'order.created': '주문 제출', 'order.approve': '주문 승인', 'member.invited': '구성원 초대', 'member.joined': '구성원 참여', 'member.role': '역할 변경', 'group.settings': '그룹 설정 변경', 'permission.site': '사이트 권한 변경', 'ownership.group_transferred': '그룹 소유권 이전', 'member.suspended': '구성원 정지' };
const fmtKrw = (n) => `${Number(n || 0).toLocaleString('ko-KR')}원`;
async function load() {
try {
const d = await aw.api('GET', '/api/dashboard'); const s = d.summary;
const card = (title, stat, sub) => `<div class="card"><h2>${aw.esc(title)}</h2><div class="stat">${stat}</div>${sub ? `<div class="sub">${sub}</div>` : ''}</div>`;
el.innerHTML = `<div class="cards">
${card('운영 사이트', `${s.sites.inUse} <span class="sub">/ 구매 ${s.sites.purchased}개</span>`, `사용 중 ${s.sites.inUse} · 예약됨 ${s.sites.reserved} · 미배정 ${s.sites.unallocated}`)}
${card('초안·제작 중', s.drafts, '운영 사이트 수를 사용하지 않습니다')}
${card('사이트 상태', `<span class="badge ok">● 정상 ${s.health.ok}</span> <span class="badge warning">! 경고 ${s.health.warn}</span> <span class="badge failed">✘ 장애 ${s.health.bad}</span>`)}
${card('배포 방식', `관리형 ${s.deploy.managed} · 외부 ${s.deploy.external}`)}
${card('도메인', `${s.domains.total}`, s.domains.action ? `<a href="/domains">조치 필요 ${s.domains.action}개</a>` : '조치 필요 없음')}
${card('메일', `서비스 ${s.mail.services} · 메일함 ${s.mail.mailboxes}`)}
${card('백업 옵션 사이트', s.backup)}
${card('진행 중 작업', s.jobs.length ? s.jobs.map((j) => `${aw.esc(j.label)} ${j.n}`).join(' · ') : '0')}
${card('이번 달 예상 비용', fmtKrw(s.cost.estimated), `결제 대기 ${fmtKrw(s.cost.awaiting)} · ${aw.esc(s.cost.note)}`)}
</div>
<div class="card"><h2>주의 항목</h2>${d.attention.length ? d.attention.map((a) => `<a class="alert ${a.severity}" href="${aw.esc(a.href)}">${a.severity === 'critical' ? '⚠' : '!'} ${aw.esc(a.text)}</a>`).join('') : '<div class="aw-state empty"><b>지금 조치할 항목이 없습니다</b></div>'}</div>
<div class="card"><h2>최근 활동</h2>${d.recent.length ? `<table class="r"><thead><tr><th>시각</th><th>내용</th><th>주체</th><th>결과</th></tr></thead><tbody>${d.recent.map((r) => `<tr><td data-l="시각">${aw.esc(aw.fmt(r.at))}</td><td data-l="내용">${aw.esc(ACT[r.action] || r.action)}${r.subjectType ? ` <span class="sub">${aw.esc(r.subjectType)} ${aw.esc(r.subjectId || '')}</span>` : ''}</td><td data-l="주체">${r.automatic ? '<span class="badge">⚙ 자동</span>' : `<span class="badge">👤 ${aw.esc(r.actor)}</span>`}</td><td data-l="결과">${r.outcome === 'ok' ? '<span class="badge ok">✔ 성공</span>' : `<span class="badge failed">✘ ${aw.esc(r.outcome)}</span>`}</td></tr>`).join('')}</tbody></table>` : '<div class="aw-state empty"><b>최근 활동이 없습니다</b></div>'}</div>
<p class="sub">마지막 갱신 ${aw.esc(aw.fmt(d.generatedAt))}</p>`;
} catch (err) { aw.state(el, err.status === 403 ? 'forbidden' : err.code === 'service_unreachable' ? 'stale' : 'failed', err.message, { retry: load }); }
}
load(); aw.poll(load, 30000);
})();
+112
View File
@@ -0,0 +1,112 @@
/**
* portal/groups/api.mjs — 사용자 그룹(§4): 전환·설정·구성원·초대·역할·정지·탈퇴·소유권 이전·사이트별 권한.
*
* 규칙
* - 그룹 전환은 세션 데이터(groupId)만 바꾼다. 모든 영역은 ctx.group 기준으로 조회하므로 다른 그룹 정보가 섞이지 않는다(§4.1).
* - 소유자(owner)는 그룹마다 한 명. 제거·정지·탈퇴 불가, 소유권 이전으로만 바뀐다. 이전 뒤 이전 소유자는 admin 이 된다.
* - 구성원 정지(§4.2): 그 사용자의 고객 세션과 이 그룹에서 그가 만든 미실행 작업만 취소한다. 영향 범위를 먼저 보여 주고(impact) 확인 뒤 적용한다.
* - 별칭 user-NN 은 표시용(§4.2). 권한·로그인 기준이 아니다.
* - 권한·소유권 변경은 감사 기록(immutable)을 남긴다(§4.3·§24.2).
*/
import { HttpError, esc } from '../../lib/http.mjs';
import { randomToken, sha256, newId } from '../../lib/crypto.mjs';
import { nowIso, parseJson, toJson } from '../../lib/db.mjs';
import { audit } from '../../lib/audit.mjs';
import { notify } from '../../lib/notify.mjs';
import { revokeOtherSessions, updateSessionData } from '../../lib/session.mjs';
import { t } from '../../lib/i18n.mjs';
import { requireUser, requireGroup, requireRole, ROLES, SITE_PERMS } from '../auth/guard.mjs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const INVITE_DAYS = 7;
export async function register(app, deps) {
const { store } = deps;
app.static('/ui/groups', join(HERE, 'public'));
const pubMember = (m, u) => ({ userId: m.user_id, email: u?.email || '', displayName: u?.display_name || '', role: m.role, alias: m.alias, status: m.status, joinedAt: m.joined_at, suspendedAt: m.suspended_at });
const members = (gid) => store.all('SELECT m.*, u.email, u.display_name FROM memberships m JOIN users u ON u.id=m.user_id WHERE m.group_id=? ORDER BY m.joined_at', gid).map((r) => pubMember(r, r));
const nextAlias = (gid) => { const used = new Set(store.all('SELECT alias FROM memberships WHERE group_id=?', gid).map((r) => r.alias)); for (let i = 1; i < 1000; i++) { const a = `user-${String(i).padStart(2, '0')}`; if (!used.has(a)) return a; } return `user-${Date.now()}`; };
const groupView = (g) => ({ id: g.id, name: g.name, kind: g.kind, ownerUserId: g.owner_user_id, contactEmail: g.contact_email, contactPhone: g.contact_phone, billingUserId: g.billing_user_id, techUserId: g.tech_user_id, status: g.status, createdAt: g.created_at });
const act = (ctx, action, detail, subjectId = null, immutable = false) => audit(store, { actorType: 'user', actorId: ctx.user.id, actorLabel: ctx.user.email, groupId: ctx.group.id, subjectType: subjectId ? 'user' : 'group', subjectId: subjectId || ctx.group.id, action, detail, immutable, ip: ctx.ip });
// ---------------------------------------------------------------- 화면
app.get('/group', (ctx) => { requireGroup(ctx); return ctx.html(deps.shell(ctx, { title: t('groups.title'), nav: 'group', crumbs: [{ label: t('groups.title') }], scripts: ['/ui/groups/groups.js'], body: `<div id="group-root" data-role="${esc(ctx.membership.role)}" data-me="${esc(ctx.user.id)}">${deps.statePanel('loading')}</div>` })); });
app.get('/invite/:token', (ctx) => {
const inv = store.get('SELECT i.*, g.name AS group_name FROM invites i JOIN groups g ON g.id=i.group_id WHERE i.token_hash=?', sha256(ctx.params.token));
const state = !inv ? 'invalid' : inv.accepted_at ? 'used' : inv.cancelled_at ? 'cancelled' : Date.parse(inv.expires_at) < Date.now() ? 'expired' : 'valid';
const body = state !== 'valid' ? `<div class="alert warning">${esc(t('groups.inviteInvalid'))}</div>` : !ctx.user ? `<div class="alert info">${esc(t('groups.inviteNeedsLogin'))} (${esc(inv.email)})</div><a class="btn primary" href="/login?next=${encodeURIComponent('/invite/' + ctx.params.token)}">${esc(t('auth.login.title'))}</a> <a class="btn" href="/signup">${esc(t('auth.signup.title'))}</a>`
: `<p><b>${esc(inv.group_name)}</b> 그룹의 <b>${esc(t('groups.roles.' + inv.role))}</b> 역할로 초대받았습니다.</p><button class="btn primary" data-action="accept-invite" data-token="${esc(ctx.params.token)}">참여하기</button><p class="err" id="inv-err"></p>`;
return ctx.html(deps.shell(ctx, { title: '그룹 초대', nav: null, scripts: ['/ui/groups/groups.js'], body: `<div class="card" style="max-width:520px;margin:24px auto"><h2>그룹 초대</h2>${body}</div>` }));
});
// ---------------------------------------------------------------- API
app.get('/api/groups', (ctx) => { requireUser(ctx); return { ok: true, current: ctx.group ? ctx.group.id : null, groups: store.all('SELECT g.*, m.role, m.alias FROM groups g JOIN memberships m ON m.group_id=g.id WHERE m.user_id=? AND m.status=\'active\' AND g.status=\'active\' ORDER BY g.kind DESC, g.created_at', ctx.user.id).map((g) => ({ ...groupView(g), role: g.role, alias: g.alias })) }; });
app.post('/api/groups/switch', async (ctx) => { requireUser(ctx); const b = await ctx.body(); const m = store.get('SELECT m.* FROM memberships m JOIN groups g ON g.id=m.group_id WHERE m.group_id=? AND m.user_id=? AND m.status=\'active\' AND g.status=\'active\'', b.groupId, ctx.user.id); if (!m) throw new HttpError(403, 'not_member', '이 그룹의 활성 구성원이 아닙니다'); updateSessionData(store, ctx.session.id, { groupId: b.groupId }); return { ok: true, groupId: b.groupId, message: t('groups.switched') }; });
app.post('/api/groups', async (ctx) => { requireUser(ctx, { verified: true }); const b = await ctx.body(); const name = String(b.name || '').trim(); if (!name || name.length > 80) throw new HttpError(400, 'bad_name', '그룹 이름을 입력하세요(80자 이내)'); const gid = newId('g_'); const now = nowIso(); store.run('INSERT INTO groups(id, name, kind, owner_user_id, contact_email, billing_user_id, tech_user_id, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?)', gid, name, 'team', ctx.user.id, ctx.user.email, ctx.user.id, ctx.user.id, now, now); store.run('INSERT INTO memberships(group_id, user_id, role, alias, joined_at) VALUES(?,?,?,?,?)', gid, ctx.user.id, 'owner', 'user-01', now); audit(store, { actorType: 'user', actorId: ctx.user.id, groupId: gid, subjectType: 'group', subjectId: gid, action: 'group.created', ip: ctx.ip }); updateSessionData(store, ctx.session.id, { groupId: gid }); return { ok: true, groupId: gid }; });
app.get('/api/group', (ctx) => { requireGroup(ctx); const g = ctx.group; const invites = ['owner', 'admin'].includes(ctx.membership.role) ? store.all('SELECT id, email, role, created_at, expires_at, accepted_at, cancelled_at, resent_at, resend_count FROM invites WHERE group_id=? AND accepted_at IS NULL AND cancelled_at IS NULL ORDER BY created_at DESC', g.id).map((i) => ({ ...i, expired: Date.parse(i.expires_at) < Date.now() })) : []; return { ok: true, group: groupView(g), me: { userId: ctx.user.id, role: ctx.membership.role, alias: ctx.membership.alias }, members: members(g.id), invites, roles: ROLES, sitePerms: SITE_PERMS }; });
app.patch('/api/group', async (ctx) => {
requireRole(ctx, ['owner', 'admin']); const b = await ctx.body(); const g = ctx.group; const patch = {};
if (b.name !== undefined) { const n = String(b.name).trim(); if (!n || n.length > 80) throw new HttpError(400, 'bad_name', '그룹 이름은 1~80자'); patch.name = n; }
if (b.contactEmail !== undefined) { if (b.contactEmail && !EMAIL_RE.test(b.contactEmail)) throw new HttpError(400, 'bad_email', '올바른 이메일'); patch.contact_email = b.contactEmail || null; }
if (b.contactPhone !== undefined) patch.contact_phone = String(b.contactPhone || '').slice(0, 40) || null;
for (const [k, col] of [['billingUserId', 'billing_user_id'], ['techUserId', 'tech_user_id']]) if (b[k] !== undefined) { if (b[k] && !store.get('SELECT 1 FROM memberships WHERE group_id=? AND user_id=? AND status=\'active\'', g.id, b[k])) throw new HttpError(400, 'not_member', '그룹 구성원만 담당자로 지정할 수 있습니다'); patch[col] = b[k] || null; }
if (!Object.keys(patch).length) return { ok: true };
store.run(`UPDATE groups SET ${Object.keys(patch).map((k) => k + '=?').join(', ')}, updated_at=? WHERE id=?`, ...Object.values(patch), nowIso(), g.id); act(ctx, 'group.settings', { changed: Object.keys(patch) }); return { ok: true, message: t('auth.account.saved') };
});
// 초대
app.post('/api/group/invites', async (ctx) => {
requireRole(ctx, ['owner', 'admin']); const b = await ctx.body(); const email = String(b.email || '').trim().toLowerCase(); const role = ROLES.includes(b.role) && b.role !== 'owner' ? b.role : 'member';
if (!EMAIL_RE.test(email)) throw new HttpError(400, 'bad_email', '올바른 이메일 주소를 입력하세요');
const ex = store.get('SELECT m.user_id FROM memberships m JOIN users u ON u.id=m.user_id WHERE m.group_id=? AND u.email=?', ctx.group.id, email); if (ex) throw new HttpError(409, 'already_member', '이미 그룹 구성원입니다');
const open = store.get('SELECT id FROM invites WHERE group_id=? AND email=? AND accepted_at IS NULL AND cancelled_at IS NULL AND expires_at>?', ctx.group.id, email, nowIso()); if (open) throw new HttpError(409, 'invite_pending', '이미 초대 대기 중입니다. 재발송을 이용하세요');
const raw = randomToken(24); const id = newId('i_'); store.run('INSERT INTO invites(id, group_id, email, role, token_hash, invited_by, created_at, expires_at) VALUES(?,?,?,?,?,?,?,?)', id, ctx.group.id, email, role, sha256(raw), ctx.user.id, nowIso(), new Date(Date.now() + INVITE_DAYS * 86400000).toISOString());
await sendInvite(email, raw, ctx.group, role); act(ctx, 'member.invited', { email, role }); return { ok: true, inviteId: id };
});
async function sendInvite(email, raw, group, role) { await deps.mailer.send({ to: email, subject: `[Ariweb] ${group.name} 그룹 초대`, text: `${group.name} 그룹에 ${t('groups.roles.' + role)} 역할로 초대되었습니다. 아래 링크에서 참여하세요(${INVITE_DAYS}일 유효).\n${deps.config.publicUrl}/invite/${raw}` }).catch((e) => deps.log(`[groups] 초대 메일 실패: ${e.message}`)); }
app.post('/api/group/invites/:id/resend', async (ctx) => { requireRole(ctx, ['owner', 'admin']); const inv = store.get('SELECT * FROM invites WHERE id=? AND group_id=? AND accepted_at IS NULL AND cancelled_at IS NULL', ctx.params.id, ctx.group.id); if (!inv) throw new HttpError(404, 'not_found', t('error.notFound')); if (inv.resent_at && Date.now() - Date.parse(inv.resent_at) < 60000) throw new HttpError(429, 'too_soon', '1분 뒤에 다시 보낼 수 있습니다'); const raw = randomToken(24); store.run('UPDATE invites SET token_hash=?, expires_at=?, resent_at=?, resend_count=resend_count+1 WHERE id=?', sha256(raw), new Date(Date.now() + INVITE_DAYS * 86400000).toISOString(), nowIso(), inv.id); await sendInvite(inv.email, raw, ctx.group, inv.role); act(ctx, 'member.invite_resent', { email: inv.email }); return { ok: true }; });
app.delete('/api/group/invites/:id', (ctx) => { requireRole(ctx, ['owner', 'admin']); const n = store.run('UPDATE invites SET cancelled_at=? WHERE id=? AND group_id=? AND accepted_at IS NULL AND cancelled_at IS NULL', nowIso(), ctx.params.id, ctx.group.id).changes; if (n) act(ctx, 'member.invite_cancelled', { inviteId: ctx.params.id }); return { ok: true, cancelled: n }; });
app.post('/api/invites/accept', async (ctx) => {
requireUser(ctx); const b = await ctx.body(); const inv = store.get('SELECT * FROM invites WHERE token_hash=?', sha256(String(b.token || '')));
if (!inv || inv.accepted_at || inv.cancelled_at || Date.parse(inv.expires_at) < Date.now()) throw new HttpError(400, 'invite_invalid', t('groups.inviteInvalid'));
if (inv.email !== ctx.user.email) throw new HttpError(403, 'invite_email_mismatch', `초대받은 이메일(${inv.email})로 로그인해야 합니다`);
const g = store.get('SELECT * FROM groups WHERE id=? AND status=\'active\'', inv.group_id); if (!g) throw new HttpError(400, 'invite_invalid', t('groups.inviteInvalid'));
store.tx(() => { if (!store.get('SELECT 1 FROM memberships WHERE group_id=? AND user_id=?', g.id, ctx.user.id)) store.run('INSERT INTO memberships(group_id, user_id, role, alias, joined_at) VALUES(?,?,?,?,?)', g.id, ctx.user.id, inv.role, nextAlias(g.id), nowIso()); store.run('UPDATE invites SET accepted_at=? WHERE id=?', nowIso(), inv.id); });
audit(store, { actorType: 'user', actorId: ctx.user.id, groupId: g.id, subjectType: 'user', subjectId: ctx.user.id, action: 'member.joined', detail: { role: inv.role }, ip: ctx.ip }); notify(store, { groupId: g.id, category: 'membership', title: `${ctx.user.display_name} 님이 그룹에 참여했습니다`, link: '/group' });
updateSessionData(store, ctx.session.id, { groupId: g.id }); return { ok: true, groupId: g.id, message: t('groups.inviteAccepted') };
});
// 구성원
const target = (ctx) => { const m = store.get('SELECT * FROM memberships WHERE group_id=? AND user_id=?', ctx.group.id, ctx.params.userId); if (!m) throw new HttpError(404, 'not_found', t('error.notFound')); return m; };
app.patch('/api/group/members/:userId', async (ctx) => {
requireGroup(ctx); const b = await ctx.body(); const m = target(ctx); const self = m.user_id === ctx.user.id;
if (b.alias !== undefined) { if (!self) requireRole(ctx, ['owner', 'admin']); const a = String(b.alias).trim(); if (!/^[a-zA-Z0-9가-힣._-]{2,24}$/.test(a)) throw new HttpError(400, 'bad_alias', '별칭은 2~24자(영문·숫자·한글·._-)'); if (store.get('SELECT 1 FROM memberships WHERE group_id=? AND alias=? AND user_id<>?', ctx.group.id, a, m.user_id)) throw new HttpError(409, 'alias_taken', '이미 쓰는 별칭입니다'); store.run('UPDATE memberships SET alias=? WHERE group_id=? AND user_id=?', a, ctx.group.id, m.user_id); act(ctx, 'member.alias', { alias: a }, m.user_id); }
if (b.role !== undefined) { requireRole(ctx, ['owner', 'admin']); if (m.role === 'owner' || b.role === 'owner') throw new HttpError(400, 'owner_role', t('groups.lastOwner') + ' 소유권 이전을 이용하세요'); if (!ROLES.includes(b.role)) throw new HttpError(400, 'bad_role', '알 수 없는 역할'); if (ctx.membership.role === 'admin' && b.role === 'admin' && self) throw new HttpError(400, 'bad_role', '자기 역할은 바꿀 수 없습니다'); store.run('UPDATE memberships SET role=? WHERE group_id=? AND user_id=?', b.role, ctx.group.id, m.user_id); act(ctx, 'member.role', { from: m.role, to: b.role }, m.user_id, true); }
return { ok: true };
});
/** 정지 영향 범위(§4.2): 세션 수·미실행 작업 수 */
const impactOf = (gid, uid) => ({ sessions: store.get('SELECT COUNT(*) AS n FROM sessions WHERE user_id=? AND kind=\'customer\' AND revoked_at IS NULL AND expires_at>?', uid, nowIso()).n, pendingJobs: deps.jobs.list({ groupId: gid, status: ['queued', 'awaiting_external', 'needs_user', 'paused'], limit: 1000 }).filter((j) => j.created_by === uid).map((j) => ({ id: j.id, kind: j.kind, label: j.label, status: j.status })), untouched: ['그룹의 상품·사이트·도메인·메일·백업', '다른 구성원의 작업과 세션', '이미 실행 중인 작업'] });
app.get('/api/group/members/:userId/impact', (ctx) => { requireRole(ctx, ['owner', 'admin']); const m = target(ctx); return { ok: true, impact: impactOf(ctx.group.id, m.user_id) }; });
app.post('/api/group/members/:userId/suspend', (ctx) => {
requireRole(ctx, ['owner', 'admin']); const m = target(ctx); if (m.role === 'owner') throw new HttpError(400, 'owner', t('groups.lastOwner')); if (m.user_id === ctx.user.id) throw new HttpError(400, 'self', '자기 자신은 정지할 수 없습니다'); if (m.status === 'suspended') return { ok: true, already: true };
const impact = impactOf(ctx.group.id, m.user_id);
store.run('UPDATE memberships SET status=\'suspended\', suspended_at=?, suspended_by=? WHERE group_id=? AND user_id=?', nowIso(), ctx.user.id, ctx.group.id, m.user_id);
// 다른 그룹 활동은 유지해야 하므로 세션은 "이 그룹을 보고 있는" 세션만 끊고 작업은 이 그룹에서 그가 만든 미실행 작업만 취소한다
const n = store.run('UPDATE sessions SET revoked_at=? WHERE user_id=? AND kind=\'customer\' AND revoked_at IS NULL AND data_json LIKE ?', nowIso(), m.user_id, `%"groupId":"${ctx.group.id}"%`).changes;
for (const j of impact.pendingJobs) { try { deps.jobs.cancel(j.id, ctx.user.id); } catch (_) {} }
act(ctx, 'member.suspended', { sessionsRevoked: n, jobsCancelled: impact.pendingJobs.map((j) => j.id) }, m.user_id, true); return { ok: true, impact, sessionsRevoked: n };
});
app.post('/api/group/members/:userId/unsuspend', (ctx) => { requireRole(ctx, ['owner', 'admin']); const m = target(ctx); store.run('UPDATE memberships SET status=\'active\', suspended_at=NULL, suspended_by=NULL WHERE group_id=? AND user_id=?', ctx.group.id, m.user_id); act(ctx, 'member.unsuspended', null, m.user_id, true); return { ok: true }; });
app.delete('/api/group/members/:userId', (ctx) => { requireRole(ctx, ['owner', 'admin']); const m = target(ctx); if (m.role === 'owner') throw new HttpError(400, 'owner', t('groups.lastOwner')); if (m.user_id === ctx.user.id) throw new HttpError(400, 'self', '자기 자신은 제거할 수 없습니다. 그룹 탈퇴를 이용하세요'); store.run('DELETE FROM memberships WHERE group_id=? AND user_id=?', ctx.group.id, m.user_id); store.run('DELETE FROM site_permissions WHERE user_id=? AND site_id IN (SELECT id FROM sites WHERE group_id=?)', m.user_id, ctx.group.id); store.run('UPDATE sessions SET revoked_at=? WHERE user_id=? AND kind=\'customer\' AND revoked_at IS NULL AND data_json LIKE ?', nowIso(), m.user_id, `%"groupId":"${ctx.group.id}"%`); act(ctx, 'member.removed', { role: m.role }, m.user_id, true); return { ok: true }; });
app.post('/api/group/leave', (ctx) => { requireGroup(ctx); if (ctx.membership.role === 'owner') throw new HttpError(400, 'owner_cannot_leave', t('groups.ownerCannotLeave')); if (ctx.group.kind === 'personal') throw new HttpError(400, 'personal', '개인 그룹은 탈퇴할 수 없습니다'); store.run('DELETE FROM memberships WHERE group_id=? AND user_id=?', ctx.group.id, ctx.user.id); act(ctx, 'member.left', null, ctx.user.id, true); updateSessionData(store, ctx.session.id, { groupId: null }); return { ok: true }; });
app.post('/api/group/transfer-ownership', async (ctx) => {
requireRole(ctx, ['owner']); const b = await ctx.body(); const m = store.get('SELECT * FROM memberships WHERE group_id=? AND user_id=? AND status=\'active\'', ctx.group.id, b.userId); if (!m) throw new HttpError(400, 'not_member', '활성 구성원에게만 이전할 수 있습니다'); if (m.user_id === ctx.user.id) throw new HttpError(400, 'self', '자기 자신에게는 이전할 수 없습니다');
store.tx(() => { store.run('UPDATE memberships SET role=\'admin\' WHERE group_id=? AND user_id=?', ctx.group.id, ctx.user.id); store.run('UPDATE memberships SET role=\'owner\' WHERE group_id=? AND user_id=?', ctx.group.id, m.user_id); store.run('UPDATE groups SET owner_user_id=?, updated_at=? WHERE id=?', m.user_id, nowIso(), ctx.group.id); });
act(ctx, 'ownership.group_transferred', { from: ctx.user.id, to: m.user_id }, m.user_id, true); notify(store, { groupId: ctx.group.id, category: 'membership', severity: 'warning', mandatory: true, title: '그룹 소유권이 이전되었습니다', body: `새 소유자: ${store.get('SELECT email FROM users WHERE id=?', m.user_id)?.email}`, link: '/group' }); return { ok: true };
});
// 사이트별 권한(§4.3)
const siteOf = (ctx) => { const s = store.get('SELECT id, group_id, name FROM sites WHERE id=? AND group_id=?', ctx.params.id, ctx.group.id); if (!s) throw new HttpError(404, 'not_found', t('error.notFound')); return s; };
app.get('/api/sites/:id/permissions', (ctx) => { requireRole(ctx, ['owner', 'admin']); const s = siteOf(ctx); return { ok: true, site: s, perms: SITE_PERMS, members: members(ctx.group.id).map((m) => ({ ...m, perms: ['owner', 'admin'].includes(m.role) ? ['admin'] : parseJson(store.get('SELECT perms_json FROM site_permissions WHERE site_id=? AND user_id=?', s.id, m.userId)?.perms_json, []), implicit: ['owner', 'admin'].includes(m.role) })) }; });
app.put('/api/sites/:id/permissions/:userId', async (ctx) => { requireRole(ctx, ['owner', 'admin']); const s = siteOf(ctx); const b = await ctx.body(); if (!store.get('SELECT 1 FROM memberships WHERE group_id=? AND user_id=?', ctx.group.id, ctx.params.userId)) throw new HttpError(400, 'not_member', '그룹 구성원이 아닙니다'); const perms = [...new Set([].concat(b.perms || []).filter((p) => SITE_PERMS.includes(p)))]; const before = parseJson(store.get('SELECT perms_json FROM site_permissions WHERE site_id=? AND user_id=?', s.id, ctx.params.userId)?.perms_json, []); if (perms.length) store.run('INSERT INTO site_permissions(site_id, user_id, perms_json, granted_by, updated_at) VALUES(?,?,?,?,?) ON CONFLICT(site_id, user_id) DO UPDATE SET perms_json=excluded.perms_json, granted_by=excluded.granted_by, updated_at=excluded.updated_at', s.id, ctx.params.userId, toJson(perms), ctx.user.id, nowIso()); else store.run('DELETE FROM site_permissions WHERE site_id=? AND user_id=?', s.id, ctx.params.userId); audit(store, { actorType: 'user', actorId: ctx.user.id, groupId: ctx.group.id, subjectType: 'site', subjectId: s.id, action: 'permission.site', detail: { userId: ctx.params.userId, before, after: perms }, immutable: true, ip: ctx.ip }); return { ok: true, perms }; });
}
+38
View File
@@ -0,0 +1,38 @@
/* portal/groups/public/groups.js — 사용자 그룹 설정 화면: 그룹 정보·담당자·구성원·초대·정지(영향 확인)·소유권 이전·탈퇴. */
(() => {
const root = document.getElementById('group-root');
document.addEventListener('click', async (e) => { const b = e.target.closest('[data-action=accept-invite]'); if (!b) return; try { const r = await aw.api('POST', '/api/invites/accept', { token: b.dataset.token }); aw.toast(r.message); location.href = '/group'; } catch (err) { document.getElementById('inv-err').textContent = err.message; } });
if (!root) return;
const canAdmin = ['owner', 'admin'].includes(root.dataset.role); const me = root.dataset.me;
async function load() {
try {
const d = await aw.api('GET', '/api/group'); const g = d.group; const R = (r) => ({ owner: '소유자', admin: '관리자', billing: '결제 담당', member: '구성원', viewer: '조회 전용' })[r] || r;
const memberOpts = (sel) => d.members.filter((m) => m.status === 'active').map((m) => `<option value="${aw.esc(m.userId)}" ${sel === m.userId ? 'selected' : ''}>${aw.esc(m.displayName)} (${aw.esc(m.email)})</option>`).join('');
root.innerHTML = `
<div class="card"><h2>그룹 정보</h2><form id="gform" data-dirty-guard><div class="f"><label for="gname">그룹 이름</label><input id="gname" name="name" value="${aw.esc(g.name)}" maxlength="80" ${canAdmin ? '' : 'disabled'}></div><div class="f"><label for="gmail">연락 이메일</label><input id="gmail" name="contactEmail" type="email" value="${aw.esc(g.contactEmail || '')}" ${canAdmin ? '' : 'disabled'}></div><div class="f"><label for="gphone">연락 전화</label><input id="gphone" name="contactPhone" value="${aw.esc(g.contactPhone || '')}" ${canAdmin ? '' : 'disabled'}></div>
<div class="f"><label for="gbill">결제 담당자</label><select id="gbill" name="billingUserId" ${canAdmin ? '' : 'disabled'}>${memberOpts(g.billingUserId)}</select></div><div class="f"><label for="gtech">기술 담당자</label><select id="gtech" name="techUserId" ${canAdmin ? '' : 'disabled'}>${memberOpts(g.techUserId)}</select></div>${canAdmin ? '<button class="btn primary">저장</button>' : '<p class="hint">그룹 정보는 소유자·관리자만 바꿀 수 있습니다</p>'}</form></div>
<div class="card"><div class="ph-row"><h2>구성원 (${d.members.length})</h2>${canAdmin ? '<button class="btn primary" data-act="invite">이메일로 초대</button>' : ''}</div>
<table class="r"><thead><tr><th>이름</th><th>이메일</th><th>별칭</th><th>역할</th><th>상태</th><th></th></tr></thead><tbody>${d.members.map((m) => `<tr><td data-l="이름">${aw.esc(m.displayName)}${m.userId === me ? ' <span class="badge ok">● 나</span>' : ''}</td><td data-l="이메일">${aw.esc(m.email)}</td><td data-l="별칭"><span class="copy" title="별칭은 표시용입니다" data-act="alias" data-uid="${m.userId}">${aw.esc(m.alias || '')} ✎</span></td><td data-l="역할">${canAdmin && m.role !== 'owner' && m.userId !== me ? `<select data-act="role" data-uid="${m.userId}">${d.roles.filter((r) => r !== 'owner').map((r) => `<option value="${r}" ${m.role === r ? 'selected' : ''}>${R(r)}</option>`).join('')}</select>` : R(m.role)}</td><td data-l="상태">${m.status === 'active' ? '<span class="badge ok">● 활성</span>' : '<span class="badge warning">⏸ 정지</span>'}</td><td class="right">${canAdmin && m.role !== 'owner' && m.userId !== me ? (m.status === 'active' ? `<button class="btn" data-act="suspend" data-uid="${m.userId}">정지</button> ` : `<button class="btn" data-act="unsuspend" data-uid="${m.userId}">정지 해제</button> `) + `<button class="btn danger" data-act="remove" data-uid="${m.userId}">제거</button>` : ''}${root.dataset.role === 'owner' && m.userId !== me && m.status === 'active' ? ` <button class="btn" data-act="transfer" data-uid="${m.userId}">소유권 이전</button>` : ''}</td></tr>`).join('')}</tbody></table>
<p class="hint">별칭(user-01 …)은 화면 표시용이며 로그인과 권한의 기준이 아닙니다.</p></div>
${canAdmin ? `<div class="card"><h2>초대 대기 (${d.invites.length})</h2>${d.invites.length ? `<table class="r"><thead><tr><th>이메일</th><th>역할</th><th>만료</th><th></th></tr></thead><tbody>${d.invites.map((i) => `<tr><td data-l="이메일">${aw.esc(i.email)}</td><td data-l="역할">${R(i.role)}</td><td data-l="만료">${i.expired ? '<span class="badge failed">✘ 만료됨</span>' : aw.esc(aw.fmt(i.expires_at))}${i.resend_count ? ` <span class="sub">재발송 ${i.resend_count}회</span>` : ''}</td><td class="right"><button class="btn" data-act="resend" data-id="${i.id}">재발송</button> <button class="btn danger" data-act="cancel-invite" data-id="${i.id}">취소</button></td></tr>`).join('')}</tbody></table>` : '<div class="aw-state empty"><b>비어 있음</b></div>'}</div>` : ''}
${g.kind !== 'personal' && root.dataset.role !== 'owner' ? '<div class="card"><h2>그룹 탈퇴</h2><button class="btn danger" data-act="leave">그룹 탈퇴</button></div>' : ''}`;
const gf = document.getElementById('gform'); gf.onsubmit = async (ev) => { ev.preventDefault(); const f = gf.elements; try { const r = await aw.api('PATCH', '/api/group', { name: f.name.value, contactEmail: f.contactEmail.value, contactPhone: f.contactPhone.value, billingUserId: f.billingUserId.value, techUserId: f.techUserId.value }); aw.markClean(); aw.toast(r.message || '저장했습니다'); } catch (err) { aw.toast(err.message); } };
root.querySelectorAll('select[data-act=role]').forEach((s) => { s.onchange = async () => { try { await aw.api('PATCH', '/api/group/members/' + s.dataset.uid, { role: s.value }); aw.toast('역할을 바꿨습니다'); } catch (err) { aw.toast(err.message); load(); } }; });
} catch (err) { aw.state(root, err.status === 403 ? 'forbidden' : 'failed', err.message, { retry: load }); }
}
root.addEventListener('click', async (e) => {
const b = e.target.closest('[data-act]'); if (!b || b.tagName === 'SELECT') return; const act = b.dataset.act; const uid = b.dataset.uid;
try {
if (act === 'invite') { const m = aw.modal('<h2>이메일로 초대</h2><form id="inv"><div class="f"><label for="iemail">이메일</label><input id="iemail" type="email" required></div><div class="f"><label for="irole">역할</label><select id="irole"><option value="member">구성원</option><option value="admin">관리자</option><option value="billing">결제 담당</option><option value="viewer">조회 전용</option></select></div><p class="err" id="inv-err"></p><button class="btn primary">초대 보내기</button></form>'); m.el.querySelector('#inv').onsubmit = async (ev) => { ev.preventDefault(); try { await aw.api('POST', '/api/group/invites', { email: m.el.querySelector('#iemail').value, role: m.el.querySelector('#irole').value }); m.close(); aw.toast('초대 메일을 보냈습니다'); load(); } catch (err) { m.el.querySelector('#inv-err').textContent = err.message; } }; }
else if (act === 'alias') { const v = prompt('새 별칭(2~24자)'); if (v) { await aw.api('PATCH', '/api/group/members/' + uid, { alias: v }); load(); } }
else if (act === 'suspend') { const r = await aw.api('GET', `/api/group/members/${uid}/impact`); const i = r.impact; if (await aw.confirmDanger({ title: '구성원 정지', target: b.closest('tr').children[1].textContent, impacts: [`이 구성원의 로그인 세션 ${i.sessions}개가 종료됩니다`, `아직 실행되지 않은 작업 ${i.pendingJobs.length}개가 취소됩니다${i.pendingJobs.length ? ': ' + i.pendingJobs.map((j) => j.label).join(', ') : ''}`, ...i.untouched.map((u) => `유지됨: ${u}`)], confirmLabel: '정지' })) { await aw.api('POST', `/api/group/members/${uid}/suspend`); load(); } }
else if (act === 'unsuspend') { await aw.api('POST', `/api/group/members/${uid}/unsuspend`); load(); }
else if (act === 'remove') { if (await aw.confirmDanger({ title: '구성원 제거', target: b.closest('tr').children[1].textContent, impacts: ['이 그룹의 사이트 권한이 모두 제거됩니다', '이 그룹을 보던 로그인 세션이 종료됩니다', '그룹의 상품·사이트·데이터는 유지됩니다'], confirmLabel: '제거' })) { await aw.api('DELETE', '/api/group/members/' + uid); load(); } }
else if (act === 'transfer') { if (await aw.confirmDanger({ title: '그룹 소유권 이전', target: b.closest('tr').children[1].textContent, impacts: ['새 소유자가 결제·해지·구성원 관리의 최종 권한을 갖습니다', '현재 소유자는 관리자 역할이 됩니다', '되돌리려면 새 소유자가 다시 이전해야 합니다'], typeToConfirm: '이전', confirmLabel: '소유권 이전' })) { await aw.api('POST', '/api/group/transfer-ownership', { userId: uid }); location.reload(); } }
else if (act === 'leave') { if (await aw.confirmDanger({ title: '그룹 탈퇴', target: '현재 그룹', impacts: ['이 그룹의 사이트·메일·결제 화면에 더 이상 접근할 수 없습니다'], confirmLabel: '탈퇴' })) { await aw.api('POST', '/api/group/leave'); location.href = '/'; } }
else if (act === 'resend') { await aw.api('POST', `/api/group/invites/${b.dataset.id}/resend`); aw.toast('다시 보냈습니다'); load(); }
else if (act === 'cancel-invite') { await aw.api('DELETE', `/api/group/invites/${b.dataset.id}`); load(); }
} catch (err) { aw.toast(err.message); }
});
load();
})();
+14
View File
@@ -0,0 +1,14 @@
/**
* portal/help/api.mjs — 도움말: 공통 상태 어휘(§2.2)·작업 상태 설명·약관 안내 자리(승인된 문구만 표시 — 미확정은 '확정 전').
*/
import { JOB_LABEL, SERVICE_LABEL, HEALTH_LABEL, DEPLOY_VERIFY_LABEL, ALLOC_LABEL } from '../../lib/status.mjs';
import { t } from '../../lib/i18n.mjs';
import { esc } from '../../lib/http.mjs';
const DESC = { not_ready: '선행 단계가 끝나지 않아 아직 시작할 수 없습니다', needs_setup: '사용자의 입력이나 설정이 필요합니다', awaiting_approval: '운영자 승인을 기다립니다', awaiting_payment: '결제 확인을 기다립니다', queued: '순서를 기다립니다', running: '지금 처리 중입니다', awaiting_external: 'DNS 반영·외부 서비스 응답 등 바깥 작업을 기다립니다', verifying: '실제 결과를 외부에서 확인하는 중입니다', paused: '사용자나 운영자가 잠시 멈췄습니다', done: '실제 결과를 확인하고 끝났습니다', failed: '실패했습니다. 실패 단계와 재시도·되돌리기 방법을 표시합니다', reverting: '이전 상태로 되돌리는 중입니다', reverted: '이전 상태로 되돌렸습니다', needs_user: '사용자의 선택이나 확인이 있어야 계속됩니다', cancelled: '취소되었습니다' };
export async function register(app, deps) {
app.get('/help', (ctx) => ctx.html(deps.shell(ctx, { title: t('help.title'), nav: null, body: `
<div class="card"><h2>작업 상태 용어</h2><p>사이트·배포·도메인·인증서·메일·백업·복원·이전 작업은 모두 같은 상태 용어를 씁니다. 서비스 상태, 작업 진행 상태, 외부 건강 상태는 서로 다른 값이며 같은 화면에서 구분해 표시합니다.</p><table class="r"><thead><tr><th>상태</th><th>뜻</th></tr></thead><tbody>${Object.entries(JOB_LABEL).map(([k, v]) => `<tr><td data-l="상태"><b>${esc(v)}</b></td><td data-l="뜻">${esc(DESC[k] || '')}</td></tr>`).join('')}</tbody></table></div>
<div class="card"><h2>서비스 상태</h2><p>${Object.values(SERVICE_LABEL).map(esc).join(' · ')}</p><h2>외부 건강 상태</h2><p>${Object.values(HEALTH_LABEL).map(esc).join(' · ')}</p><h2>배포 완료 구분</h2><p>${Object.values(DEPLOY_VERIFY_LABEL).map(esc).join(' · ')}</p><h2>배정 상태</h2><p>${Object.values(ALLOC_LABEL).map(esc).join(' · ')}</p></div>
<div class="card" id="terms"><h2>약관과 정책</h2><p>가격·SLA·환불·개인정보·DPA·백업·AUP·정지 및 해지 정책은 운영자가 승인한 문구만 표시합니다. 아직 확정되지 않은 정책은 <b>확정 전</b> 으로 표시되며 확정된 약속처럼 안내하지 않습니다.</p><p class="sub" id="privacy">현재 버전(0.1.1)의 약관·개인정보 처리방침 본문은 운영자 콘솔 › 상품과 정책에서 게시하면 이 자리에 표시됩니다.</p></div>
<div class="card"><h2>문의</h2><p>작업 번호(j_…)와 함께 문의하면 빠르게 확인할 수 있습니다. 비밀번호·인증 키는 절대 문의 내용에 적지 마세요.</p></div>` })));
}
+53
View File
@@ -0,0 +1,53 @@
/**
* portal/mailer.mjs — 포털 발신 메일(이메일 확인·비밀번호 재설정·초대·필수 알림).
*
* 발송 방식은 설정(deps.config.values.mailer: 'smtp' | 'resend' | 'log')과 보관소의 비밀로 정한다.
* smtp : AriMail 제출 포트(기본 127.0.0.1:587 STARTTLS 또는 465)에 로그인해 보낸다. 계정 비밀번호는 vault 'mailer:smtp-pass'.
* resend : Resend 호환 HTTP API(https://api.resend.com/emails). 키는 vault 'mailer:resend-key'.
* log : 보내지 않고 로그에만(개발·검증 환경 기본). 테스트는 deps.mailer 를 포획 객체로 바꿔 끼운다.
* 어떤 방식이든 본문에 비밀번호·토큰 원문 외의 비밀값을 넣지 않으며, 링크의 토큰은 1회용이다.
* 인터페이스: deps.mailer.send({ to, subject, text, html? }) → Promise<{ ok, id?, mode }>
*/
import net from 'node:net';
import tls from 'node:tls';
export function create(deps) {
const cfg = deps.config; const log = deps.log || console.log;
const mode = () => (cfg.values && cfg.values.mailer) || process.env.ARIWEB_MAILER || 'log';
async function send({ to, subject, text, html }) {
const m = mode(); const from = cfg.mailFrom || 'no-reply@ariweb.net';
if (!to || !subject) throw new Error('받는 사람과 제목이 필요합니다');
if (m === 'resend') {
const key = deps.vault.get('mailer:resend-key'); if (!key) throw new Error('메일 발송 키(mailer:resend-key)가 보관소에 없습니다');
const r = await fetch((cfg.values.resendBase || 'https://api.resend.com') + '/emails', { method: 'POST', headers: { Authorization: 'Bearer ' + key, 'Content-Type': 'application/json' }, body: JSON.stringify({ from, to: [].concat(to), subject, text, html }), signal: AbortSignal.timeout(20000) });
const j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(`메일 발송 거부(${r.status}): ${j.message || j.name || ''}`);
return { ok: true, id: j.id, mode: m };
}
if (m === 'smtp') return smtpSend({ host: cfg.values.smtpHost || '127.0.0.1', port: Number(cfg.values.smtpPort || 587), user: cfg.values.smtpUser || from, pass: deps.vault.get('mailer:smtp-pass') || '', secure: String(cfg.values.smtpPort) === '465', from, to: [].concat(to), subject, text, html });
log(`[mailer:log] → ${[].concat(to).join(',')} | ${subject}`);
return { ok: true, mode: 'log' };
}
return { send, mode };
}
/** 작은 SMTP 클라이언트(AUTH PLAIN·STARTTLS). 외부 의존성 없이 제출 서버로 한 통 보낸다. */
function smtpSend({ host, port, user, pass, secure, from, to, subject, text, html }) {
return new Promise((resolve, reject) => {
let sock = secure ? tls.connect({ host, port, servername: host }) : net.connect({ host, port }); let buf = ''; const q = [];
const fail = (e) => { try { sock.destroy(); } catch (_) {} reject(e instanceof Error ? e : new Error(String(e))); };
const wait = () => new Promise((res, rej) => q.push({ res, rej }));
const cmd = async (line, okCodes = [250]) => { if (line != null) sock.write(line + '\r\n'); const r = await wait(); if (!okCodes.includes(Number(r.slice(0, 3)))) throw new Error(`SMTP ${line ? line.split(' ')[0] : '연결'} 실패: ${r.trim().slice(0, 120)}`); return r; };
const onData = (d) => { buf += d.toString('utf8'); const lines = buf.split('\r\n'); buf = lines.pop(); let resp = ''; for (const l of lines) { resp += l + '\r\n'; if (/^\d{3} /.test(l)) { const w = q.shift(); if (w) w.res(resp); resp = ''; } } };
sock.on('data', onData); sock.on('error', fail);
(async () => {
await cmd(null, [220]); await cmd('EHLO ariweb-portal');
if (!secure) { await cmd('STARTTLS', [220]); sock.removeListener('data', onData); sock = tls.connect({ socket: sock, servername: host }); sock.on('data', onData); sock.on('error', fail); await new Promise((r) => sock.once('secureConnect', r)); await cmd('EHLO ariweb-portal'); }
if (pass) await cmd('AUTH PLAIN ' + Buffer.from(`\0${user}\0${pass}`).toString('base64'), [235]);
await cmd(`MAIL FROM:<${from}>`); for (const t of to) await cmd(`RCPT TO:<${t}>`, [250, 251]); await cmd('DATA', [354]);
const boundary = 'aw' + Date.now().toString(36); const enc = (s) => Buffer.from(s, 'utf8').toString('base64').replace(/(.{76})/g, '$1\r\n');
const headers = [`From: ${from}`, `To: ${to.join(', ')}`, `Subject: =?UTF-8?B?${Buffer.from(subject, 'utf8').toString('base64')}?=`, `Date: ${new Date().toUTCString()}`, `Message-ID: <${Date.now()}.${Math.random().toString(36).slice(2)}@ariweb>`, 'MIME-Version: 1.0'];
const body = html ? [`Content-Type: multipart/alternative; boundary="${boundary}"`, '', `--${boundary}`, 'Content-Type: text/plain; charset=utf-8', 'Content-Transfer-Encoding: base64', '', enc(text || ''), `--${boundary}`, 'Content-Type: text/html; charset=utf-8', 'Content-Transfer-Encoding: base64', '', enc(html), `--${boundary}--`].join('\r\n') : ['Content-Type: text/plain; charset=utf-8', 'Content-Transfer-Encoding: base64', '', enc(text || '')].join('\r\n');
const r = await cmd(headers.join('\r\n') + '\r\n' + body + '\r\n.', [250]); sock.write('QUIT\r\n'); sock.end();
resolve({ ok: true, id: (r.match(/queued as (\S+)/i) || [])[1], mode: 'smtp' });
})().catch(fail);
});
}
+19
View File
@@ -0,0 +1,19 @@
/**
* portal/notifications/api.mjs — 알림 센터 화면·API(§24.1). 현재 그룹의 알림(그룹 알림 + 나에게 온 개인 알림)만 보인다.
* 분류 필터·읽지 않음 필터·읽음 처리·사건 반복 횟수·필수 표시·조치 화면 링크.
*/
import { requireGroup } from '../auth/guard.mjs';
import { listNotifications, markRead, unreadCount } from '../../lib/notify.mjs';
import { t } from '../../lib/i18n.mjs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
export const CATEGORIES = ['billing', 'site', 'domain', 'mail', 'usage', 'backup', 'relocation', 'security', 'incident', 'membership'];
export async function register(app, deps) {
const { store } = deps;
app.static('/ui/notifications', join(HERE, 'public'));
app.get('/notifications', (ctx) => { requireGroup(ctx); return ctx.html(deps.shell(ctx, { title: t('notifications.title'), nav: null, crumbs: [{ label: t('notifications.title') }], scripts: ['/ui/notifications/notifications.js'], body: `<div class="tabs" id="ncats"><a class="on" href="#" data-cat="">${t('notifications.all')}</a>${CATEGORIES.map((c) => `<a href="#" data-cat="${c}">${t('notifications.categories.' + c)}</a>`).join('')}<a href="#" data-unread="1">${t('notifications.unread')}</a></div><div id="nlist">${deps.statePanel('loading')}</div>` })); });
app.get('/api/notifications', (ctx) => { requireGroup(ctx); const list = listNotifications(store, { groupId: ctx.group.id, userId: ctx.user.id, category: ctx.query.category || undefined, unreadOnly: ctx.query.unread === '1', limit: Number(ctx.query.limit || 100) }); return { ok: true, unread: unreadCount(store, ctx.group.id), items: list.map((n) => ({ id: n.id, category: n.category, categoryLabel: t('notifications.categories.' + n.category), severity: n.severity, mandatory: !!n.mandatory, title: n.title, body: n.body, link: n.link, actionRequired: n.action_required, state: n.state, count: n.count, firstAt: n.first_at, lastAt: n.last_at, readAt: n.read_at, emailSentAt: n.email_sent_at })) }; });
app.post('/api/notifications/:id/read', (ctx) => { requireGroup(ctx); return { ok: true, changed: markRead(store, ctx.params.id, ctx.group.id) }; });
app.post('/api/notifications/read-all', (ctx) => { requireGroup(ctx); const n = store.run('UPDATE notifications SET read_at=? WHERE (group_id=? OR (group_id IS NULL AND user_id=?)) AND read_at IS NULL', new Date().toISOString(), ctx.group.id, ctx.user.id).changes; return { ok: true, changed: n }; });
}
@@ -0,0 +1,13 @@
/* portal/notifications/public/notifications.js — 알림 센터: 분류·읽지 않음 필터, 읽음 처리, 사건 반복 횟수, 조치 링크. */
(() => {
const list = document.getElementById('nlist'); const tabs = document.getElementById('ncats'); if (!list) return; let cat = ''; let unread = false;
const SEV = { critical: 'failed', warning: 'warning', info: 'info' };
async function load() {
try { const d = await aw.api('GET', `/api/notifications?category=${encodeURIComponent(cat)}&unread=${unread ? 1 : 0}`); if (!d.items.length) return aw.state(list, 'empty');
list.innerHTML = `<div class="actions" style="margin-bottom:8px"><button class="btn" id="read-all">모두 읽음으로 표시</button></div>` + d.items.map((n) => `<div class="card" style="${n.readAt ? 'opacity:.75' : ''}"><div class="ph-row"><div><span class="badge ${SEV[n.severity] || ''}">${n.severity === 'critical' ? '⚠' : n.severity === 'warning' ? '!' : 'ⓘ'} ${aw.esc(n.categoryLabel)}</span> ${n.mandatory ? '<span class="badge failed">필수</span>' : ''} ${n.count > 1 ? `<span class="badge">${n.count}회 반복</span>` : ''} ${n.state === 'resolved' ? '<span class="badge ok">✔ 해결됨</span>' : ''}</div><span class="sub">${aw.esc(aw.fmt(n.lastAt))}</span></div><h2 style="margin:6px 0">${aw.esc(n.title)}</h2>${n.body ? `<p>${aw.esc(n.body)}</p>` : ''}${n.actionRequired ? `<p><b>필요한 조치:</b> ${aw.esc(n.actionRequired)}</p>` : ''}<div class="actions">${n.link ? `<a class="btn primary" href="${aw.esc(n.link)}">조치 화면으로</a>` : ''}${!n.readAt ? `<button class="btn" data-read="${n.id}">읽음</button>` : ''}</div></div>`).join('');
list.querySelectorAll('[data-read]').forEach((b) => { b.onclick = async () => { await aw.api('POST', `/api/notifications/${b.dataset.read}/read`); load(); }; }); document.getElementById('read-all').onclick = async () => { await aw.api('POST', '/api/notifications/read-all'); load(); };
} catch (err) { aw.state(list, err.status === 403 ? 'forbidden' : 'failed', err.message, { retry: load }); }
}
tabs.addEventListener('click', (e) => { const a = e.target.closest('a'); if (!a) return; e.preventDefault(); tabs.querySelectorAll('a').forEach((x) => x.classList.remove('on')); a.classList.add('on'); if (a.dataset.unread) { unread = true; cat = ''; } else { unread = false; cat = a.dataset.cat || ''; } load(); });
load();
})();
+38
View File
@@ -0,0 +1,38 @@
/**
* portal/scheduler.mjs — 포털 주기 작업 등록부.
*
* 각 영역은 register() 안에서 deps.scheduler.every(name, ms, fn) 으로 자기 주기 작업을 등록한다(인증 영역이 가장 먼저 create 로 등록부를 만든다).
* start(deps) 는 index.mjs 가 앱 조립 뒤 한 번 부른다. 작업은 겹쳐 실행되지 않고(이전 실행이 끝나야 다음), 예외는 기록만 하고 다음 주기에 다시 돈다.
* 기본 작업(인증 영역): 세션 정리, 이메일 토큰 정리, 로그인 잠금 정리, 만료 예약 자동 반환(§11.2 — 반환 알림 포함).
*/
import { purgeSessions } from '../lib/session.mjs';
import { expireReservations, RESOURCES } from '../lib/quota.mjs';
import { notify } from '../lib/notify.mjs';
import { nowIso } from '../lib/db.mjs';
import { safeError } from '../lib/redact.mjs';
export function create(deps) {
if (deps.scheduler) return deps.scheduler;
const tasks = new Map(); const timers = []; const log = deps.log || console.log;
const sched = {
/** 주기 작업 등록. fn 은 async 가능. 같은 이름은 덮어쓴다. */
every(name, ms, fn) { tasks.set(name, { ms, fn, running: false, lastRunAt: null, lastError: null, runs: 0 }); return sched; },
/** 즉시 한 번 실행(테스트·운영자 '지금 실행') */
async runNow(name) { const t = tasks.get(name); if (!t) throw new Error(`주기 작업 없음: ${name}`); if (t.running) return { skipped: true }; t.running = true; try { const r = await t.fn(); t.lastRunAt = nowIso(); t.lastError = null; t.runs++; return { ok: true, result: r }; } catch (e) { t.lastError = safeError(e); log(`[scheduler] ${name} 실패: ${t.lastError}`); return { ok: false, error: t.lastError }; } finally { t.running = false; } },
list: () => [...tasks.entries()].map(([name, t]) => ({ name, ms: t.ms, lastRunAt: t.lastRunAt, lastError: t.lastError, runs: t.runs })),
start() { for (const [name, t] of tasks) { const id = setInterval(() => sched.runNow(name), t.ms); if (id.unref) id.unref(); timers.push(id); } return sched; },
stop() { for (const id of timers) clearInterval(id); timers.length = 0; },
};
deps.scheduler = sched;
// 기본 작업
sched.every('sessions.purge', 6 * 3600 * 1000, () => purgeSessions(deps.store, 7));
sched.every('tokens.purge', 3600 * 1000, () => deps.store.run('DELETE FROM email_tokens WHERE expires_at<? OR (used_at IS NOT NULL AND used_at<?)', new Date(Date.now() - 3 * 86400000).toISOString(), new Date(Date.now() - 3 * 86400000).toISOString()).changes);
sched.every('login.unlock', 10 * 60 * 1000, () => deps.store.run('DELETE FROM login_attempts WHERE updated_at<?', new Date(Date.now() - 24 * 3600000).toISOString()).changes);
sched.every('quota.expire', 60 * 1000, () => {
const rows = expireReservations(deps.store);
for (const r of rows) notify(deps.store, { groupId: r.group_id, category: 'usage', severity: 'info', incidentKey: `reservation:${r.id}`, title: `${RESOURCES[r.resource]?.label || r.resource} 예약이 반환되었습니다`, body: '정리되지 않은 작업의 예약 기한이 지나 자동으로 반환했습니다. 필요하면 작업을 다시 시작하세요.', link: '/usage', subjectType: r.subject_type, subjectId: r.subject_id });
return rows.length;
});
return sched;
}
export function start(deps) { return create(deps).start(); }
+100
View File
@@ -0,0 +1,100 @@
/** test/auth.test.mjs — 가입·확인·로그인·잠금·세션·재설정·MFA·CSRF 회귀 테스트(실제 쿠키 흐름). */
import { test, eq, ok, done } from '../lib/test.mjs';
import { makeDeps, appFor, call } from './helpers.mjs';
import { invoke } from '../lib/http.mjs';
import * as totp from '../portal/auth/totp.mjs';
const mails = []; const mailer = { send: async (m) => { mails.push(m); return { ok: true, mode: 'test' }; }, mode: () => 'test' };
const deps = await makeDeps({ mailer });
const app = await appFor(deps, ['auth', 'groups', 'dashboard', 'notifications', 'auditui', 'help']);
const AREAS_OK = true;
/** 로그인 상태의 호출 도우미: 세션 쿠키 + CSRF */
function client(cookie, csrf) { return (method, path, body) => invoke(app, method, path, { body, headers: { cookie: `aw_session=${cookie}`, 'x-csrf': csrf || '', accept: 'application/json' } }); }
async function session(r) { const cookie = r.cookies.aw_session; const c = await invoke(app, 'GET', '/api/auth/csrf', { headers: { cookie: `aw_session=${cookie}` } }); return client(cookie, c.json.csrf); }
const linkToken = (m, kind) => (m.text.match(kind === 'verify' ? /token=([A-Za-z0-9_-]+)/ : /reset-password\/([A-Za-z0-9_-]+)/) || [])[1];
await test('가입 → 확인 메일 → 이메일 확인 → 개인 그룹과 별칭 user-01 → 대시보드 접근', async () => {
let r = await invoke(app, 'POST', '/api/auth/signup', { body: { email: 'alice@example.test', displayName: '앨리스', password: 'pw-example-1', consents: { terms: true, privacy: true } } });
eq(r.status, 200, JSON.stringify(r.json)); ok(r.cookies.aw_session, '가입 즉시 세션'); eq(mails.length, 1); ok(/token=/.test(mails[0].text));
const api = await session(r);
let me = await api('GET', '/api/auth/me'); eq(me.json.user.email_verified_at, null); eq(me.json.group.kind, 'personal'); eq(me.json.role, 'owner');
// 확인 전에는 verified 필수 API 거부(그룹 만들기)
r = await api('POST', '/api/groups', { name: '팀' }); eq(r.status, 403); eq(r.json.error, 'email_unverified');
r = await invoke(app, 'POST', '/api/auth/verify-email', { body: { token: linkToken(mails[0], 'verify') } }); eq(r.status, 200);
r = await invoke(app, 'POST', '/api/auth/verify-email', { body: { token: linkToken(mails[0], 'verify') } }); eq(r.json.error, 'token_used', '한 번만');
me = await api('GET', '/api/auth/me'); ok(me.json.user.email_verified_at);
const g = await api('GET', '/api/group'); eq(g.json.members[0].alias, 'user-01'); eq(g.json.members[0].role, 'owner');
const d = await api('GET', '/api/dashboard'); eq(d.status, 200); eq(d.json.summary.sites.purchased, 0);
});
await test('중복 가입: 존재를 드러내지 않는 일반 안내 / 필수 동의 없으면 거부', async () => {
let r = await invoke(app, 'POST', '/api/auth/signup', { body: { email: 'ALICE@example.test', displayName: 'x', password: 'pw-example-2', consents: { terms: true, privacy: true } } }); eq(r.status, 409); eq(r.json.error, 'signup_unavailable'); ok(!/이미 가입|존재/.test(r.json.message) || /로그인/.test(r.json.message));
r = await invoke(app, 'POST', '/api/auth/signup', { body: { email: 'bob@example.test', displayName: 'x', password: 'pw-example-2', consents: { terms: true } } }); eq(r.json.error, 'consent_required');
});
await test('로그인 실패 메시지 동일 / 5회 실패 → 15분 잠금(해제 시각 표시) / HTML 미로그인 → /login 리디렉션', async () => {
let r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'nobody@example.test', password: 'x' } }); const msg1 = r.json.message;
r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'wrong' } }); eq(r.json.message, msg1, '존재 여부 비노출');
for (let i = 0; i < 4; i++) r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'wrong' } });
eq(r.status, 429); eq(r.json.error, 'locked'); ok(r.json.detail.lockUntil); ok(/KST/.test(r.json.message));
r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-1' } }); eq(r.status, 429, '맞는 비밀번호도 잠금 중에는 거부');
deps.store.run('DELETE FROM login_attempts');
r = await invoke(app, 'GET', '/sites', { headers: { accept: 'text/html' } }); eq(r.status, 302); ok(r.headers.Location.startsWith('/login?next=%2Fsites'));
r = await invoke(app, 'GET', '/api/dashboard'); eq(r.status, 401);
});
await test('로그인 → 기기 목록 → 개별 종료 → 다른 모든 세션 종료 → 비밀번호 변경이 다른 세션 무효화 / CSRF 거부', async () => {
const a = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-1' }, headers: { 'user-agent': 'Mozilla/5.0 (Windows) Chrome/120' } }); eq(a.status, 200);
const b = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-1' } }); const c = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-1' } });
const A = await session(a); const B = await session(b); const C = await session(c);
let r = await A('GET', '/api/account/sessions'); ok(r.json.sessions.length >= 3); ok(r.json.sessions.some((s) => /Windows/.test(s.label)));
const bCur = (await B('GET', '/api/account/sessions')).json.current;
r = await A('DELETE', '/api/account/sessions/' + bCur); eq(r.json.revoked, 1); r = await B('GET', '/api/auth/me'); eq(r.json.user, null, '종료된 세션');
// CSRF: 세션은 있는데 헤더가 틀리면 403
r = await invoke(app, 'POST', '/api/account/sessions/revoke-others', { headers: { cookie: `aw_session=${a.cookies.aw_session}`, 'x-csrf': 'wrong' } }); eq(r.status, 403); eq(r.json.error, 'csrf');
r = await A('POST', '/api/account/sessions/revoke-others'); ok(r.json.revoked >= 1); r = await C('GET', '/api/auth/me'); eq(r.json.user, null);
// 비밀번호 변경 → 다른 세션 종료(이 세션 유지)
const d = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-1' } }); const D = await session(d);
r = await A('POST', '/api/account/password', { current: 'pw-example-1', next: 'pw-example-9' }); eq(r.status, 200); r = await D('GET', '/api/auth/me'); eq(r.json.user, null); r = await A('GET', '/api/auth/me'); ok(r.json.user);
r = await A('POST', '/api/account/password', { current: 'bad', next: 'pw-example-9' }); eq(r.json.error, 'wrong_password');
});
await test('비밀번호 재설정: 요청 응답 동일 · 링크 상태(valid→used) · 재설정 후 모든 세션 종료', async () => {
const n0 = mails.length; let r = await invoke(app, 'POST', '/api/auth/request-reset', { body: { email: 'ghost@example.test' } }); const msg = r.json.message; eq(mails.length, n0, '없는 계정은 메일 없음');
r = await invoke(app, 'POST', '/api/auth/request-reset', { body: { email: 'alice@example.test' } }); eq(r.json.message, msg); eq(mails.length, n0 + 1);
const tok = linkToken(mails[n0], 'reset'); ok(tok); r = await invoke(app, 'GET', '/api/auth/reset/' + tok); eq(r.json.state, 'valid');
const live = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-9' } }); const L = await session(live);
r = await invoke(app, 'POST', '/api/auth/reset', { body: { token: tok, password: 'pw-example-3' } }); eq(r.status, 200);
r = await invoke(app, 'GET', '/api/auth/reset/' + tok); eq(r.json.state, 'used'); r = await invoke(app, 'GET', '/reset-password/' + tok, { headers: { accept: 'text/html' } }); ok(/이미 사용한/.test(r.text));
r = await L('GET', '/api/auth/me'); eq(r.json.user, null, '재설정 뒤 기존 세션 종료');
r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); eq(r.status, 200);
deps.store.run('UPDATE email_tokens SET expires_at=? WHERE token_hash IS NOT NULL AND kind=\'reset\'', new Date(0).toISOString()); r = await invoke(app, 'GET', '/api/auth/reset/' + tok); eq(r.json.state, 'used');
});
await test('MFA: 등록(코드 확인) → 복구 코드 1회 표시 → 로그인 2단계 → 같은 코드 재사용 거부 → 복구 코드 1회용 → 해제', async () => {
const l = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); const A = await session(l);
let r = await A('POST', '/api/account/mfa/enable', { code: '000000' }); eq(r.json.error, 'mfa_not_pending');
r = await A('POST', '/api/account/mfa/setup'); const secret = r.json.secret.replace(/ /g, ''); ok(/^otpauth:\/\/totp\//.test(r.json.otpauth));
r = await A('POST', '/api/account/mfa/enable', { code: '123456' }); eq(r.json.error, 'mfa_bad_code');
r = await A('POST', '/api/account/mfa/enable', { code: totp.currentCode(secret) }); eq(r.status, 200); eq(r.json.recoveryCodes.length, 10); const rc = r.json.recoveryCodes;
ok(!JSON.stringify(deps.store.get('SELECT recovery_json FROM users WHERE email=?', 'alice@example.test')).includes(rc[0]), '복구 코드는 해시만 저장');
// 로그인: 1단계 → preauth → 2단계
r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); eq(r.json.mfaRequired, true); ok(!r.cookies.aw_session); const pre = r.json.preauth;
/** 등록·이전 로그인에 쓴 카운터를 지워 현재 구간 코드를 다시 쓸 수 있게 한다(시간 경계에 따른 흔들림 방지) */
const rewind = () => { const u = deps.store.get('SELECT mfa_json FROM users WHERE email=?', 'alice@example.test'); const m = JSON.parse(u.mfa_json); m.lastCounter = null; deps.store.run('UPDATE users SET mfa_json=? WHERE email=?', JSON.stringify(m), 'alice@example.test'); };
rewind(); const code = totp.currentCode(secret);
r = await invoke(app, 'POST', '/api/auth/mfa', { body: { preauth: pre, code: 'bad' } }); eq(r.status, 401);
r = await invoke(app, 'POST', '/api/auth/mfa', { body: { preauth: pre, code } }); eq(r.status, 200, JSON.stringify(r.json)); ok(r.cookies.aw_session);
r = await invoke(app, 'POST', '/api/auth/mfa', { body: { preauth: pre, code } }); ok(/preauth_/.test(r.json.error), '사전 인증 토큰 1회용');
r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); r = await invoke(app, 'POST', '/api/auth/mfa', { body: { preauth: r.json.preauth, code } }); eq(r.status, 401, '같은 TOTP 코드 재사용 거부');
// 복구 코드
r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); const pre2 = r.json.preauth; r = await invoke(app, 'POST', '/api/auth/mfa', { body: { preauth: pre2, code: rc[0] } }); eq(r.status, 200); eq(r.json.recoveryLeft, 9);
r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); r = await invoke(app, 'POST', '/api/auth/mfa', { body: { preauth: r.json.preauth, code: rc[0] } }); eq(r.status, 401, '복구 코드 1회용');
// 해제: 비밀번호+코드
rewind(); const l2 = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); r = await invoke(app, 'POST', '/api/auth/mfa', { body: { preauth: l2.json.preauth, code: totp.currentCode(secret) } }); eq(r.status, 200, JSON.stringify(r.json)); const B = await session(r);
r = await B('POST', '/api/account/mfa/disable', { password: 'pw-example-3', code: '000000' }); eq(r.json.error, 'mfa_bad_code');
rewind(); r = await B('POST', '/api/account/mfa/disable', { password: 'pw-example-3', code: totp.currentCode(secret) }); eq(r.status, 200, JSON.stringify(r.json));
r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); ok(r.cookies.aw_session, '해제 뒤 바로 로그인');
});
await test('화면 렌더: 로그인·가입·내 계정·보안·기기·도움말 HTML 이 열린다', async () => {
const l = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); const h = { cookie: `aw_session=${l.cookies.aw_session}`, accept: 'text/html' };
for (const p of ['/account', '/account/security', '/account/sessions', '/help', '/notifications', '/activity', '/group', '/']) { const r = await invoke(app, 'GET', p, { headers: h }); eq(r.status, 200, p); ok(/<html lang="ko">/.test(r.text), p); }
for (const p of ['/login', '/signup', '/reset-password']) { const r = await invoke(app, 'GET', p, { headers: { accept: 'text/html' } }); eq(r.status, 200, p); }
const r = await invoke(app, 'GET', '/login', { headers: h }); eq(r.status, 302, '로그인 상태면 /login 은 대시보드로');
});
done();
+57
View File
@@ -0,0 +1,57 @@
/** test/dashboard.test.mjs — 대시보드 요약·주의 항목 순서·최근 활동 / 알림 센터 API / 스케줄러 기본 작업. */
import { test, eq, ok, done } from '../lib/test.mjs';
import { makeDeps, appFor, seedGroup, call } from './helpers.mjs';
import { grantEntitlement, reserve, commit, recordUsage, setAllocation } from '../lib/quota.mjs';
import { notify } from '../lib/notify.mjs';
import { audit } from '../lib/audit.mjs';
import { newId } from '../lib/crypto.mjs';
const deps = await makeDeps({ mailer: { send: async () => ({ ok: true }), mode: () => 'test' } });
const who = seedGroup(deps); const other = seedGroup(deps, { email: 'o@example.test', name: '남' });
deps.jobs.register('deploy.site', { title: '배포', steps: [{ id: 'a', title: 'A' }], async run(job, ctx) { if (job.subject.fail) throw new Error('boom'); ctx.waitExternal('외부 확인'); } });
const app = await appFor(deps, ['auth', 'dashboard', 'notifications'], who);
const g = who.group.id; const now = new Date().toISOString();
await test('요약: 사이트 수(구매·사용 중·예약)·초안·건강·배포 방식·도메인·메일·백업·작업·비용', async () => {
grantEntitlement(deps.store, { groupId: g, resource: 'sites', amount: 3 }); grantEntitlement(deps.store, { groupId: g, resource: 'web_disk', amount: 1000 });
const s1 = newId('s_'); const s2 = newId('s_');
deps.store.run('INSERT INTO sites(id, group_id, name, lifecycle, deploy_mode, health, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?)', s1, g, 'A', 'live', 'managed', 'ok', now, now);
deps.store.run('INSERT INTO sites(id, group_id, name, lifecycle, deploy_mode, health, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?)', s2, g, 'B', 'draft', null, 'unknown', now, now);
deps.store.run('INSERT INTO sites(id, group_id, name, lifecycle, deploy_mode, health, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?)', newId('s_'), other.group.id, 'X', 'live', 'external', 'down', now, now);
commit(deps.store, reserve(deps.store, { groupId: g, resource: 'sites', amount: 1, subjectType: 'site', subjectId: s1 }).id); reserve(deps.store, { groupId: g, resource: 'sites', amount: 1, subjectType: 'site', subjectId: s2 });
deps.store.run('INSERT INTO domains(id, group_id, name, site_id, ownership_state, dns_state, cert_state, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?)', newId('d_'), g, 'a.example.test', s1, 'verified', 'ok', 'ok', now, now);
deps.store.run('INSERT INTO domains(id, group_id, name, site_id, ownership_state, dns_state, cert_state, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?)', newId('d_'), g, 'b.example.test', s1, 'pending', 'pending', 'none', now, now);
deps.store.run('INSERT INTO mail_services(id, group_id, kind, site_id, name, status, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?)', newId('ms_'), g, 'site_bound', s1, 'A 메일', 'done', now, now);
deps.store.run('INSERT INTO backup_options(id, site_id, group_id, status, schedule_json, retention_json, consecutive_failures, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?)', newId('bo_'), s1, g, 'active', '{}', '{}', 3, now, now);
deps.store.run('INSERT INTO orders(id, group_id, order_no, items_json, amount, tax, total, status, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)', newId('o_'), g, 'AW-1', '[]', 10000, 1000, 11000, 'awaiting_payment', now, now);
deps.store.run('INSERT INTO orders(id, group_id, order_no, items_json, amount, tax, total, status, approved_at, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', newId('o_'), g, 'AW-2', '[]', 20000, 2000, 22000, 'approved', now, now, now);
deps.jobs.enqueue({ kind: 'deploy.site', groupId: g, subjectType: 'site', subjectId: s1 }); await deps.jobs.drain();
const r = await call(app, 'GET', '/api/dashboard'); eq(r.status, 200); const s = r.json.summary;
eq([s.sites.purchased, s.sites.inUse, s.sites.reserved, s.sites.unallocated, s.sites.live], [3, 1, 1, 1, 1]); eq(s.drafts, 1); eq(s.health, { ok: 1, warn: 0, bad: 0 }); eq(s.deploy, { managed: 1, external: 0 });
eq(s.domains, { total: 2, action: 1 }); eq(s.mail, { services: 1, mailboxes: 0 }); eq(s.backup, 1); eq(s.jobs, [{ kind: 'deploy', label: '배포', n: 1 }]); eq([s.cost.estimated, s.cost.awaiting], [22000, 11000]);
});
await test('주의 항목: 필수 알림 → 결제 대기 → 도메인 → 사용량 → 실패 작업 → 백업 순, 각각 링크 / 다른 그룹 항목 없음', async () => {
notify(deps.store, { groupId: g, category: 'security', severity: 'critical', mandatory: true, title: '보안 경고', link: '/account/security' });
notify(deps.store, { groupId: other.group.id, category: 'security', severity: 'critical', mandatory: true, title: '남의 경고', link: '/x' });
recordUsage(deps.store, { groupId: g, resource: 'web_disk', subjectType: 'site', subjectId: 'any', used: 960 });
deps.jobs.enqueue({ kind: 'deploy.site', groupId: g, subject: { fail: true }, subjectType: 'site', subjectId: 'sX' }); await deps.jobs.drain();
const r = await call(app, 'GET', '/api/dashboard'); const a = r.json.attention;
eq(a[0].text, '보안 경고'); eq(a[0].href, '/account/security'); ok(!a.some((x) => x.text === '남의 경고'));
const kinds = a.map((x) => x.kind); ok(kinds.indexOf('notification') < kinds.indexOf('order')); ok(a.find((x) => x.kind === 'usage' && /95%/.test(x.text) && x.severity === 'critical')); ok(a.find((x) => x.kind === 'job' && x.severity === 'critical')); ok(a.find((x) => x.kind === 'backup' && /3회/.test(x.text))); ok(a.find((x) => x.kind === 'domain' && /소유 확인/.test(x.text)));
ok(a.every((x) => x.href && x.href.startsWith('/')), '모든 항목에 조치 링크');
});
await test('최근 활동: 사용자/자동 구분, 인증 로그 제외 / 알림 API: 필터·읽음·사건 반복·필수', async () => {
audit(deps.store, { actorType: 'user', actorId: who.user.id, actorLabel: who.user.email, groupId: g, action: 'deploy.start' }); audit(deps.store, { actorType: 'system', groupId: g, action: 'backup.run', automatic: true }); audit(deps.store, { actorType: 'user', actorId: who.user.id, groupId: g, action: 'auth.login' });
let r = await call(app, 'GET', '/api/dashboard'); const rec = r.json.recent; eq(rec.length, 2); eq(rec[0].automatic, true); eq(rec[1].automatic, false);
notify(deps.store, { groupId: g, category: 'usage', incidentKey: 'u1', title: '90%' }); notify(deps.store, { groupId: g, category: 'usage', incidentKey: 'u1', title: '90% 다시' });
r = await call(app, 'GET', '/api/notifications?category=usage'); eq(r.json.items.length, 1); eq(r.json.items[0].count, 2); ok(r.json.unread >= 2);
r = await call(app, 'GET', '/api/notifications'); ok(r.json.items.find((n) => n.mandatory && n.title === '보안 경고')); ok(!r.json.items.find((n) => n.title === '남의 경고'));
const id = r.json.items[0].id; r = await call(app, 'POST', `/api/notifications/${id}/read`); eq(r.json.changed, 1); r = await call(app, 'GET', '/api/notifications?unread=1'); ok(!r.json.items.find((n) => n.id === id));
r = await call(app, 'POST', '/api/notifications/read-all'); r = await call(app, 'GET', '/api/notifications?unread=1'); eq(r.json.items.length, 0);
});
await test('스케줄러: 만료 예약 자동 반환 + 알림, 작업 목록', async () => {
deps.store.run('UPDATE quota_reservations SET expires_at=? WHERE state=\'reserved\'', new Date(0).toISOString());
const r = await deps.scheduler.runNow('quota.expire'); eq(r.result, 1); ok(deps.store.get('SELECT 1 AS x FROM notifications WHERE group_id=? AND incident_key LIKE \'reservation:%\'', g));
ok(deps.scheduler.list().map((t) => t.name).includes('sessions.purge'));
});
done();
+74
View File
@@ -0,0 +1,74 @@
/** test/groups.test.mjs — 사용자 그룹: 전환 격리·초대 수락·역할·정지 영향 범위·소유권 이전·탈퇴·사이트별 권한·직접 접근 403. */
import { test, eq, ok, done } from '../lib/test.mjs';
import { makeDeps, appFor } from './helpers.mjs';
import { invoke } from '../lib/http.mjs';
import { grantEntitlement } from '../lib/quota.mjs';
import { newId } from '../lib/crypto.mjs';
const mails = []; const deps = await makeDeps({ mailer: { send: async (m) => { mails.push(m); return { ok: true }; }, mode: () => 'test' } });
deps.jobs.register('demo.wait', { title: '데모 대기', steps: [{ id: 'a', title: 'A' }], async run(job, ctx) { ctx.waitExternal('대기'); } });
const app = await appFor(deps, ['auth', 'groups', 'dashboard', 'notifications', 'auditui']);
async function signup(email, name) { const r = await invoke(app, 'POST', '/api/auth/signup', { body: { email, displayName: name, password: 'pw-example-1', consents: { terms: true, privacy: true } } }); const tok = (mails.at(-1).text.match(/token=([A-Za-z0-9_-]+)/) || [])[1]; await invoke(app, 'POST', '/api/auth/verify-email', { body: { token: tok } }); return client(r.cookies.aw_session); }
async function client(cookie) { const c = await invoke(app, 'GET', '/api/auth/csrf', { headers: { cookie: `aw_session=${cookie}` } }); const f = (method, path, body, extra = {}) => invoke(app, method, path, { body, headers: { cookie: `aw_session=${cookie}`, 'x-csrf': c.json.csrf, ...extra } }); f.cookie = cookie; return f; }
const uid = (email) => deps.store.get('SELECT id FROM users WHERE email=?', email).id;
const owner = await signup('owner@example.test', '오너'); const bob = await signup('bob@example.test', '밥'); const carol = await signup('carol@example.test', '캐럴');
let teamId;
await test('팀 그룹 만들기 → 초대 → 다른 이메일로는 수락 불가 → 초대받은 사람이 수락 → 별칭 user-02', async () => {
let r = await owner('POST', '/api/groups', { name: '오즈 팀' }); eq(r.status, 200); teamId = r.json.groupId;
r = await owner('GET', '/api/auth/me'); eq(r.json.group.id, teamId, '만들면 그 그룹으로 전환');
r = await owner('POST', '/api/group/invites', { email: 'bob@example.test', role: 'member' }); eq(r.status, 200); const link = mails.at(-1).text.match(/invite\/([A-Za-z0-9_-]+)/)[1];
r = await owner('POST', '/api/group/invites', { email: 'bob@example.test', role: 'member' }); eq(r.json.error, 'invite_pending');
r = await carol('POST', '/api/invites/accept', { token: link }); eq(r.json.error, 'invite_email_mismatch');
r = await bob('POST', '/api/invites/accept', { token: link }); eq(r.status, 200); r = await bob('POST', '/api/invites/accept', { token: link }); eq(r.json.error, 'invite_invalid', '1회용');
r = await bob('GET', '/api/group'); eq(r.json.group.id, teamId); eq(r.json.me.alias, 'user-02'); eq(r.json.me.role, 'member'); eq(r.json.invites.length, 0, '구성원에게는 초대 목록 없음');
});
await test('그룹 전환 격리: 같은 사용자가 두 그룹을 오가도 대시보드·알림·사이트가 섞이지 않는다', async () => {
const personal = deps.store.get('SELECT g.id FROM groups g JOIN memberships m ON m.group_id=g.id WHERE m.user_id=? AND g.kind=\'personal\'', uid('bob@example.test')).id;
const now = new Date().toISOString();
deps.store.run('INSERT INTO sites(id, group_id, name, lifecycle, created_at, updated_at) VALUES(?,?,?,?,?,?)', newId('s_'), teamId, '팀 사이트', 'draft', now, now);
deps.store.run('INSERT INTO sites(id, group_id, name, lifecycle, created_at, updated_at) VALUES(?,?,?,?,?,?)', newId('s_'), personal, '개인 사이트', 'draft', now, now); deps.store.run('INSERT INTO sites(id, group_id, name, lifecycle, created_at, updated_at) VALUES(?,?,?,?,?,?)', newId('s_'), personal, '개인 사이트2', 'draft', now, now);
grantEntitlement(deps.store, { groupId: teamId, resource: 'sites', amount: 3 });
let r = await bob('GET', '/api/dashboard'); eq(r.json.summary.drafts, 1); eq(r.json.summary.sites.purchased, 3);
r = await bob('POST', '/api/groups/switch', { groupId: personal }); eq(r.status, 200);
r = await bob('GET', '/api/dashboard'); eq(r.json.summary.drafts, 2); eq(r.json.summary.sites.purchased, 0);
r = await bob('POST', '/api/groups/switch', { groupId: 'g_nope' }); eq(r.status, 403);
r = await carol('POST', '/api/groups/switch', { groupId: teamId }); eq(r.status, 403, '구성원 아님');
await bob('POST', '/api/groups/switch', { groupId: teamId });
});
await test('역할 없는 직접 API 접근 → 403 + 필요한 역할 / 역할 변경은 감사 기록(삭제 불가)', async () => {
let r = await bob('PATCH', '/api/group', { name: 'x' }); eq(r.status, 403); eq(r.json.error, 'forbidden'); eq(r.json.detail.requiredRole, ['owner', 'admin']); eq(r.json.detail.yourRole, 'member');
r = await bob('POST', '/api/group/invites', { email: 'dave@example.test' }); eq(r.status, 403);
r = await owner('PATCH', '/api/group/members/' + uid('bob@example.test'), { role: 'admin' }); eq(r.status, 200);
r = await owner('GET', '/api/audit?action=member.role'); eq(r.json.items[0].immutable, true); eq(r.json.items[0].detail.to, 'admin');
r = await bob('PATCH', '/api/group', { name: '오즈 팀 2' }); eq(r.status, 200);
r = await owner('PATCH', '/api/group/members/' + uid('owner@example.test'), { role: 'member' }); eq(r.json.error, 'owner_role');
});
await test('구성원 정지: 그 사람의 세션과 미실행 작업만 취소, 다른 구성원 작업·그룹 자산은 유지', async () => {
const bobId = uid('bob@example.test'); const ownerId = uid('owner@example.test');
const jb = deps.jobs.enqueue({ kind: 'demo.wait', groupId: teamId, createdBy: bobId }).job; const jo = deps.jobs.enqueue({ kind: 'demo.wait', groupId: teamId, createdBy: ownerId }).job; const jbOther = deps.jobs.enqueue({ kind: 'demo.wait', groupId: 'g_other', createdBy: bobId }).job;
await deps.jobs.drain(); eq(deps.jobs.get(jb.id).status, 'awaiting_external');
let r = await owner('GET', `/api/group/members/${bobId}/impact`); eq(r.json.impact.pendingJobs.length, 1); ok(r.json.impact.sessions >= 1);
r = await owner('POST', `/api/group/members/${bobId}/suspend`); eq(r.status, 200); ok(r.json.sessionsRevoked >= 1);
eq(deps.jobs.get(jb.id).status, 'cancelled'); eq(deps.jobs.get(jo.id).status, 'awaiting_external', '다른 구성원 작업 유지'); eq(deps.jobs.get(jbOther.id).status, 'awaiting_external', '다른 그룹 작업 유지');
eq(deps.store.get('SELECT COUNT(*) AS n FROM sites WHERE group_id=?', teamId).n, 1, '그룹 자산 유지');
r = await bob('GET', '/api/auth/me'); eq(r.json.user, null, '정지된 구성원의 그룹 세션 종료');
r = await owner('POST', `/api/group/members/${bobId}/unsuspend`); eq(r.status, 200);
});
await test('사이트별 권한 설정·조회, 소유권 이전, 탈퇴 규칙', async () => {
const bobId = uid('bob@example.test'); const site = deps.store.get('SELECT id FROM sites WHERE group_id=?', teamId);
let r = await owner('PATCH', '/api/group/members/' + bobId, { role: 'member' }); eq(r.status, 200, '관리자는 암묵적 전체 권한이므로 구성원으로 되돌려 시험');
r = await owner('PUT', `/api/sites/${site.id}/permissions/${bobId}`, { perms: ['view', 'deploy', 'bogus'] }); eq(r.json.perms, ['view', 'deploy']);
r = await owner('GET', `/api/sites/${site.id}/permissions`); const row = r.json.members.find((m) => m.userId === bobId); eq(row.perms, ['view', 'deploy']);
r = await owner('GET', `/api/sites/s_nope/permissions`); eq(r.status, 404);
r = await owner('GET', '/api/audit?action=permission.site'); eq(r.json.items[0].immutable, true);
// 탈퇴·이전
r = await owner('POST', '/api/group/leave'); eq(r.json.error, 'owner_cannot_leave');
r = await owner('POST', '/api/group/transfer-ownership', { userId: bobId }); eq(r.status, 200);
r = await owner('GET', '/api/group'); eq(r.json.me.role, 'admin'); eq(r.json.group.ownerUserId, bobId);
r = await owner('POST', '/api/group/leave'); eq(r.status, 200); r = await owner('GET', '/api/auth/me'); ok(r.json.group.id !== teamId, '탈퇴 뒤 다른 그룹으로');
// 삭제 불가 기록·알림
ok(deps.store.get('SELECT 1 AS x FROM audit_log WHERE action=\'ownership.group_transferred\' AND immutable=1'));
ok(deps.store.get('SELECT 1 AS x FROM notifications WHERE group_id=? AND mandatory=1', teamId));
});
done();