Files
AriWeb-Suite/portal/auth/api.mjs

205 lines
27 KiB
JavaScript

/**
* 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') }; });
}