diff --git a/lang/ko/auth.json b/lang/ko/auth.json new file mode 100644 index 0000000..f38079f --- /dev/null +++ b/lang/ko/auth.json @@ -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": "저장했습니다" } + } +} diff --git a/lang/ko/dashboard.json b/lang/ko/dashboard.json new file mode 100644 index 0000000..b1060cc --- /dev/null +++ b/lang/ko/dashboard.json @@ -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": "도움말" } +} diff --git a/lang/ko/groups.json b/lang/ko/groups.json new file mode 100644 index 0000000..f4b1114 --- /dev/null +++ b/lang/ko/groups.json @@ -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": "초대를 수락하려면 초대받은 이메일로 로그인하거나 가입하세요" + } +} diff --git a/portal/auditui/api.mjs b/portal/auditui/api.mjs new file mode 100644 index 0000000..d4b5a53 --- /dev/null +++ b/portal/auditui/api.mjs @@ -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: `
${deps.statePanel('loading')}
+` })); }); + 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 }; }); +} diff --git a/portal/auth/api.mjs b/portal/auth/api.mjs new file mode 100644 index 0000000..32e9fe4 --- /dev/null +++ b/portal/auth/api.mjs @@ -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') }; }); +} diff --git a/portal/auth/model.mjs b/portal/auth/model.mjs new file mode 100644 index 0000000..be2c5e5 --- /dev/null +++ b/portal/auth/model.mjs @@ -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 +);` }]; diff --git a/portal/auth/pages.mjs b/portal/auth/pages.mjs new file mode 100644 index 0000000..ef8d416 --- /dev/null +++ b/portal/auth/pages.mjs @@ -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 = '') => `

${esc(title)}

${inner}${foot ? `

${foot}

` : ''}
`; +const f = (id, label, type = 'text', extra = '', hint = '') => `
${hint ? `${esc(hint)}` : ''}
`; + +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 ? `
${esc(notice)}
` : ''} +
${f('email', t('auth.login.email'), 'email', 'autocomplete="username" required')}${f('password', t('auth.login.password'), 'password', 'autocomplete="current-password" required')} +
+`, + `${esc(t('auth.login.noAccount'))} ${esc(t('auth.signup.title'))} · ${esc(t('auth.login.forgot'))}
${esc(t('auth.login.operatorNote'))}`) }); +} +export function signupPage(shell) { + return shell({ title: t('auth.signup.title'), nav: null, scripts: ['/ui/auth/auth.js'], body: box(t('auth.signup.title'), ` +
${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'))} +
+

${esc(t('auth.signup.policyNote'))}

+`, `${esc(t('auth.login.title')) }`) }); +} +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'), `
${esc(msg)}
${state === 'ok' ? `${esc(t('nav.dashboard'))}` : ` ${esc(t('auth.login.title'))}`}`) }); +} +export function resetRequestPage(shell) { + return shell({ title: t('auth.reset.title'), nav: null, scripts: ['/ui/auth/auth.js'], body: box(t('auth.reset.title'), `
${f('email', t('auth.login.email'), 'email', 'required')}

`, `${esc(t('auth.login.title'))}`) }); +} +export function resetFormPage(shell, { token, state }) { + const body = state === 'valid' ? `
${f('password', t('auth.reset.newPassword'), 'password', 'autocomplete="new-password" minlength="8" required', t('auth.signup.passwordHint'))}

` + : `
${esc(t('auth.reset.state'))}: ${esc(state === 'used' ? t('auth.reset.used') : t('auth.reset.expired'))}
${esc(t('auth.reset.request'))}`; + 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: ` +
${esc(t('auth.account.profile'))}${esc(t('auth.account.security'))}${esc(t('auth.sessions.title'))}
+${!u.email_verified_at ? `
${esc(t('auth.verify.banner'))}
` : ''} +

${esc(t('auth.account.profile'))}

${esc(t('auth.login.email'))}
${esc(u.email)} ${u.email_verified_at ? '✔ 확인됨' : '! 확인 전'}
+
${f('displayName', t('auth.signup.displayName'), 'text', `value="${esc(u.display_name)}" maxlength="80" required`)}
+

${esc(t('auth.account.changePassword'))}

${f('current', t('auth.account.currentPassword'), 'password', 'autocomplete="current-password" required')}${f('next', t('auth.reset.newPassword'), 'password', 'autocomplete="new-password" minlength="8" required')}

비밀번호를 바꾸면 다른 기기의 로그인이 모두 종료됩니다.

` }); +} +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: ` +
${esc(t('auth.account.profile'))}${esc(t('auth.account.security'))}${esc(t('auth.sessions.title'))}
+

${esc(t('auth.mfa.title'))}

${esc(t('auth.mfa.level'))}
${mfaOn ? `✔ ${esc(t('auth.mfa.levelMfa'))}` : `! ${esc(t('auth.mfa.levelPw'))}`}
${esc(t('auth.mfa.title'))}
${esc(mfaOn ? t('auth.mfa.enabled') : t('auth.mfa.disabled'))}${o.mfaEnabledAt ? ` (${esc(o.mfaEnabledAt)})` : ''}
${mfaOn ? `
${esc(t('auth.mfa.recovery'))}
남은 복구 코드 ${o.recoveryLeft}개
` : ''}
+
${mfaOn ? `
${f('password', t('auth.login.password'), 'password', 'autocomplete="current-password" required')}${f('code', t('auth.login.mfaTitle'), 'text', 'inputmode="numeric" required')}

${esc(t('auth.mfa.disableNeeds'))}

+
${f('password2', t('auth.login.password'), 'password', 'autocomplete="current-password" required')}
` + : `
`}
` }); +} +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: ` +
${esc(t('auth.account.profile'))}${esc(t('auth.account.security'))}${esc(t('auth.sessions.title'))}
+

${esc(t('auth.sessions.title'))}

${o.statePanel('loading')}
` }); +} diff --git a/portal/auth/public/auth.js b/portal/auth/public/auth.js new file mode 100644 index 0000000..940f973 --- /dev/null +++ b/portal/auth/public/auth.js @@ -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) => `
${aw.esc(r.message)}
`; + 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 = `

인증 앱에서 아래 주소를 등록하거나 수동 입력 키를 넣으세요.

${aw.esc(r.otpauth)}

수동 입력 키: ${aw.esc(r.secret)}

`; $('#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) + '완료'; } 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 = `${r.sessions.map((s) => ``).join('')}
기기IP로그인 시각마지막 사용
${aw.esc(s.label || '기기')}${s.id === r.current ? ' ● 현재 세션' : ''}${aw.esc(s.ip || '')}${aw.esc(aw.fmt(s.created_at))}${aw.esc(aw.fmt(s.last_seen_at))}${s.id === r.current ? '' : ``}
`; 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(); } } }); } +})(); diff --git a/portal/auth/totp.mjs b/portal/auth/totp.mjs new file mode 100644 index 0000000..0471e0b --- /dev/null +++ b/portal/auth/totp.mjs @@ -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)}`; }); } diff --git a/portal/dashboard/api.mjs b/portal/dashboard/api.mjs new file mode 100644 index 0000000..cfc8484 --- /dev/null +++ b/portal/dashboard/api.mjs @@ -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 ? `
${t('auth.verify.banner')} 확인 메일 다시 보내기
` : ''}
${deps.statePanel('loading')}
` })); }); + + 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 }; + }); +} diff --git a/portal/dashboard/public/dashboard.js b/portal/dashboard/public/dashboard.js new file mode 100644 index 0000000..a76cb47 --- /dev/null +++ b/portal/dashboard/public/dashboard.js @@ -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) => `

${aw.esc(title)}

${stat}
${sub ? `
${sub}
` : ''}
`; + el.innerHTML = `
+${card('운영 사이트', `${s.sites.inUse} / 구매 ${s.sites.purchased}개`, `사용 중 ${s.sites.inUse} · 예약됨 ${s.sites.reserved} · 미배정 ${s.sites.unallocated}`)} +${card('초안·제작 중', s.drafts, '운영 사이트 수를 사용하지 않습니다')} +${card('사이트 상태', `● 정상 ${s.health.ok} ! 경고 ${s.health.warn} ✘ 장애 ${s.health.bad}`)} +${card('배포 방식', `관리형 ${s.deploy.managed} · 외부 ${s.deploy.external}`)} +${card('도메인', `${s.domains.total}`, s.domains.action ? `조치 필요 ${s.domains.action}개` : '조치 필요 없음')} +${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)}`)} +
+

주의 항목

${d.attention.length ? d.attention.map((a) => `${a.severity === 'critical' ? '⚠' : '!'} ${aw.esc(a.text)}`).join('') : '
지금 조치할 항목이 없습니다
'}
+

최근 활동

${d.recent.length ? `${d.recent.map((r) => ``).join('')}
시각내용주체결과
${aw.esc(aw.fmt(r.at))}${aw.esc(ACT[r.action] || r.action)}${r.subjectType ? ` ${aw.esc(r.subjectType)} ${aw.esc(r.subjectId || '')}` : ''}${r.automatic ? '⚙ 자동' : `👤 ${aw.esc(r.actor)}`}${r.outcome === 'ok' ? '✔ 성공' : `✘ ${aw.esc(r.outcome)}`}
` : '
최근 활동이 없습니다
'}
+

마지막 갱신 ${aw.esc(aw.fmt(d.generatedAt))}

`; + } catch (err) { aw.state(el, err.status === 403 ? 'forbidden' : err.code === 'service_unreachable' ? 'stale' : 'failed', err.message, { retry: load }); } + } + load(); aw.poll(load, 30000); +})(); diff --git a/portal/groups/api.mjs b/portal/groups/api.mjs new file mode 100644 index 0000000..5b76f74 --- /dev/null +++ b/portal/groups/api.mjs @@ -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: `
${deps.statePanel('loading')}
` })); }); + 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' ? `
${esc(t('groups.inviteInvalid'))}
` : !ctx.user ? `
${esc(t('groups.inviteNeedsLogin'))} (${esc(inv.email)})
${esc(t('auth.login.title'))} ${esc(t('auth.signup.title'))}` + : `

${esc(inv.group_name)} 그룹의 ${esc(t('groups.roles.' + inv.role))} 역할로 초대받았습니다.

`; + return ctx.html(deps.shell(ctx, { title: '그룹 초대', nav: null, scripts: ['/ui/groups/groups.js'], body: `

그룹 초대

${body}
` })); + }); + + // ---------------------------------------------------------------- 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 }; }); +} diff --git a/portal/groups/public/groups.js b/portal/groups/public/groups.js new file mode 100644 index 0000000..e0fdd78 --- /dev/null +++ b/portal/groups/public/groups.js @@ -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) => ``).join(''); + root.innerHTML = ` +

그룹 정보

+
${canAdmin ? '' : '

그룹 정보는 소유자·관리자만 바꿀 수 있습니다

'}
+

구성원 (${d.members.length})

${canAdmin ? '' : ''}
+${d.members.map((m) => ``).join('')}
이름이메일별칭역할상태
${aw.esc(m.displayName)}${m.userId === me ? ' ● 나' : ''}${aw.esc(m.email)}${aw.esc(m.alias || '')} ✎${canAdmin && m.role !== 'owner' && m.userId !== me ? `` : R(m.role)}${m.status === 'active' ? '● 활성' : '⏸ 정지'}${canAdmin && m.role !== 'owner' && m.userId !== me ? (m.status === 'active' ? ` ` : ` `) + `` : ''}${root.dataset.role === 'owner' && m.userId !== me && m.status === 'active' ? ` ` : ''}
+

별칭(user-01 …)은 화면 표시용이며 로그인과 권한의 기준이 아닙니다.

+${canAdmin ? `

초대 대기 (${d.invites.length})

${d.invites.length ? `${d.invites.map((i) => ``).join('')}
이메일역할만료
${aw.esc(i.email)}${R(i.role)}${i.expired ? '✘ 만료됨' : aw.esc(aw.fmt(i.expires_at))}${i.resend_count ? ` 재발송 ${i.resend_count}회` : ''}
` : '
비어 있음
'}
` : ''} +${g.kind !== 'personal' && root.dataset.role !== 'owner' ? '

그룹 탈퇴

' : ''}`; + 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('

이메일로 초대

'); 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(); +})(); diff --git a/portal/help/api.mjs b/portal/help/api.mjs new file mode 100644 index 0000000..889ba2a --- /dev/null +++ b/portal/help/api.mjs @@ -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: ` +

작업 상태 용어

사이트·배포·도메인·인증서·메일·백업·복원·이전 작업은 모두 같은 상태 용어를 씁니다. 서비스 상태, 작업 진행 상태, 외부 건강 상태는 서로 다른 값이며 같은 화면에서 구분해 표시합니다.

${Object.entries(JOB_LABEL).map(([k, v]) => ``).join('')}
상태뜻
${esc(v)}${esc(DESC[k] || '')}
+

서비스 상태

${Object.values(SERVICE_LABEL).map(esc).join(' · ')}

외부 건강 상태

${Object.values(HEALTH_LABEL).map(esc).join(' · ')}

배포 완료 구분

${Object.values(DEPLOY_VERIFY_LABEL).map(esc).join(' · ')}

배정 상태

${Object.values(ALLOC_LABEL).map(esc).join(' · ')}

+

약관과 정책

가격·SLA·환불·개인정보·DPA·백업·AUP·정지 및 해지 정책은 운영자가 승인한 문구만 표시합니다. 아직 확정되지 않은 정책은 확정 전 으로 표시되며 확정된 약속처럼 안내하지 않습니다.

현재 버전(0.1.1)의 약관·개인정보 처리방침 본문은 운영자 콘솔 › 상품과 정책에서 게시하면 이 자리에 표시됩니다.

+

문의

작업 번호(j_…)와 함께 문의하면 빠르게 확인할 수 있습니다. 비밀번호·인증 키는 절대 문의 내용에 적지 마세요.

` }))); +} diff --git a/portal/mailer.mjs b/portal/mailer.mjs new file mode 100644 index 0000000..68d04eb --- /dev/null +++ b/portal/mailer.mjs @@ -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); + }); +} diff --git a/portal/notifications/api.mjs b/portal/notifications/api.mjs new file mode 100644 index 0000000..c98b165 --- /dev/null +++ b/portal/notifications/api.mjs @@ -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: `
${t('notifications.all')}${CATEGORIES.map((c) => `${t('notifications.categories.' + c)}`).join('')}${t('notifications.unread')}
${deps.statePanel('loading')}
` })); }); + 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 }; }); +} diff --git a/portal/notifications/public/notifications.js b/portal/notifications/public/notifications.js new file mode 100644 index 0000000..4c90ecf --- /dev/null +++ b/portal/notifications/public/notifications.js @@ -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 = `
` + d.items.map((n) => `
${n.severity === 'critical' ? '⚠' : n.severity === 'warning' ? '!' : 'ⓘ'} ${aw.esc(n.categoryLabel)} ${n.mandatory ? '필수' : ''} ${n.count > 1 ? `${n.count}회 반복` : ''} ${n.state === 'resolved' ? '✔ 해결됨' : ''}
${aw.esc(aw.fmt(n.lastAt))}

${aw.esc(n.title)}

${n.body ? `

${aw.esc(n.body)}

` : ''}${n.actionRequired ? `

필요한 조치: ${aw.esc(n.actionRequired)}

` : ''}
${n.link ? `조치 화면으로` : ''}${!n.readAt ? `` : ''}
`).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(); +})(); diff --git a/portal/scheduler.mjs b/portal/scheduler.mjs new file mode 100644 index 0000000..d88acad --- /dev/null +++ b/portal/scheduler.mjs @@ -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 deps.store.run('DELETE FROM login_attempts WHERE updated_at { + 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(); } diff --git a/test/auth.test.mjs b/test/auth.test.mjs new file mode 100644 index 0000000..3d536f4 --- /dev/null +++ b/test/auth.test.mjs @@ -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(//.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(); diff --git a/test/dashboard.test.mjs b/test/dashboard.test.mjs new file mode 100644 index 0000000..0ffb7eb --- /dev/null +++ b/test/dashboard.test.mjs @@ -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(); diff --git a/test/groups.test.mjs b/test/groups.test.mjs new file mode 100644 index 0000000..75b46df --- /dev/null +++ b/test/groups.test.mjs @@ -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();