포털 인증·계정·사용자 그룹·대시보드·알림 센터·감사 화면·도움말·발송기·스케줄러 (§3·§4·§5·§24)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
0f11424224
commit
c90ac27596
@@ -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') }; });
|
||||
}
|
||||
@@ -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
|
||||
);` }];
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* portal/auth/pages.mjs — 인증·계정 화면 HTML(서버 렌더). 데이터·동작은 /ui/auth/auth.js 가 /api/auth·/api/account 를 불러 처리한다.
|
||||
* 로그인 전 화면(로그인·가입·확인·재설정)은 공통 틀의 비로그인 모드로, 로그인 뒤 화면(내 계정·보안·기기)은 왼쪽 메뉴가 있는 모드로 그린다.
|
||||
*/
|
||||
import { esc } from '../../lib/http.mjs';
|
||||
import { t } from '../../lib/i18n.mjs';
|
||||
|
||||
const box = (title, inner, foot = '') => `<div class="card" style="max-width:460px;margin:24px auto"><h2>${esc(title)}</h2>${inner}${foot ? `<p class="sub" style="margin-top:12px">${foot}</p>` : ''}</div>`;
|
||||
const f = (id, label, type = 'text', extra = '', hint = '') => `<div class="f"><label for="${id}">${esc(label)}</label><input id="${id}" name="${id}" type="${type}" ${extra}>${hint ? `<span class="hint">${esc(hint)}</span>` : ''}<span class="err" data-err="${id}"></span></div>`;
|
||||
|
||||
export function loginPage(shell, { next = '/', notice = '' } = {}) {
|
||||
return shell({ title: t('auth.login.title'), nav: null, scripts: ['/ui/auth/auth.js'], body: box(t('auth.login.title'), `
|
||||
${notice ? `<div class="alert info">${esc(notice)}</div>` : ''}
|
||||
<form id="login" data-next="${esc(next)}">${f('email', t('auth.login.email'), 'email', 'autocomplete="username" required')}${f('password', t('auth.login.password'), 'password', 'autocomplete="current-password" required')}
|
||||
<p class="err" id="login-err" role="alert"></p><button class="btn primary" type="submit">${esc(t('auth.login.submit'))}</button></form>
|
||||
<form id="mfa" hidden><p>${esc(t('auth.login.mfaHint'))}</p>${f('code', t('auth.login.mfaTitle'), 'text', 'inputmode="numeric" autocomplete="one-time-code" required')}<p class="err" id="mfa-err" role="alert"></p><button class="btn primary" type="submit">${esc(t('common.confirm'))}</button></form>`,
|
||||
`<a href="/signup">${esc(t('auth.login.noAccount'))} ${esc(t('auth.signup.title'))}</a> · <a href="/reset-password">${esc(t('auth.login.forgot'))}</a><br><span class="muted">${esc(t('auth.login.operatorNote'))}</span>`) });
|
||||
}
|
||||
export function signupPage(shell) {
|
||||
return shell({ title: t('auth.signup.title'), nav: null, scripts: ['/ui/auth/auth.js'], body: box(t('auth.signup.title'), `
|
||||
<form id="signup">${f('email', t('auth.login.email'), 'email', 'autocomplete="username" required')}${f('displayName', t('auth.signup.displayName'), 'text', 'maxlength="80" required')}${f('password', t('auth.login.password'), 'password', 'autocomplete="new-password" minlength="8" required', t('auth.signup.passwordHint'))}
|
||||
<div class="f"><label><input type="checkbox" name="terms" style="width:auto"> ${esc(t('auth.signup.terms'))} <a href="/help#terms">보기</a></label><label><input type="checkbox" name="privacy" style="width:auto"> ${esc(t('auth.signup.privacy'))} <a href="/help#privacy">보기</a></label><label><input type="checkbox" name="marketing" style="width:auto"> ${esc(t('auth.signup.marketing'))}</label></div>
|
||||
<p class="hint">${esc(t('auth.signup.policyNote'))}</p><p class="err" id="signup-err" role="alert"></p><button class="btn primary" type="submit">${esc(t('auth.signup.submit'))}</button></form>
|
||||
<div id="signup-done" hidden class="alert info"></div>`, `<a href="/login">${esc(t('auth.login.title')) }</a>`) });
|
||||
}
|
||||
export function verifyPage(shell, { state, user }) {
|
||||
const msg = { ok: t('auth.verify.ok'), invalid: t('auth.verify.invalid'), used: t('auth.verify.used') }[state] || t('auth.verify.invalid');
|
||||
return shell({ title: t('auth.verify.title'), nav: null, user, scripts: ['/ui/auth/auth.js'], body: box(t('auth.verify.title'), `<div class="alert ${state === 'ok' ? 'info' : 'warning'}">${esc(msg)}</div>${state === 'ok' ? `<a class="btn primary" href="/">${esc(t('nav.dashboard'))}</a>` : `<button class="btn" data-action="resend-verify">${esc(t('auth.verify.resend'))}</button> <a class="btn" href="/login">${esc(t('auth.login.title'))}</a>`}`) });
|
||||
}
|
||||
export function resetRequestPage(shell) {
|
||||
return shell({ title: t('auth.reset.title'), nav: null, scripts: ['/ui/auth/auth.js'], body: box(t('auth.reset.title'), `<form id="reset-request">${f('email', t('auth.login.email'), 'email', 'required')}<p class="err" id="rr-err"></p><button class="btn primary" type="submit">${esc(t('auth.reset.request'))}</button></form><div id="rr-done" class="alert info" hidden></div>`, `<a href="/login">${esc(t('auth.login.title'))}</a>`) });
|
||||
}
|
||||
export function resetFormPage(shell, { token, state }) {
|
||||
const body = state === 'valid' ? `<form id="reset" data-token="${esc(token)}">${f('password', t('auth.reset.newPassword'), 'password', 'autocomplete="new-password" minlength="8" required', t('auth.signup.passwordHint'))}<p class="err" id="reset-err"></p><button class="btn primary" type="submit">${esc(t('auth.reset.submit'))}</button></form><div id="reset-done" class="alert info" hidden></div>`
|
||||
: `<div class="alert warning"><b>${esc(t('auth.reset.state'))}:</b> ${esc(state === 'used' ? t('auth.reset.used') : t('auth.reset.expired'))}</div><a class="btn" href="/reset-password">${esc(t('auth.reset.request'))}</a>`;
|
||||
return shell({ title: t('auth.reset.title'), nav: null, scripts: ['/ui/auth/auth.js'], body: box(t('auth.reset.title'), body) });
|
||||
}
|
||||
/** 내 계정(프로필·비밀번호·이메일 확인 상태) */
|
||||
export function accountPage(shell, o) {
|
||||
const u = o.user;
|
||||
return shell({ ...o, title: t('auth.account.title'), nav: null, crumbs: [{ label: t('auth.account.title') }], scripts: ['/ui/auth/auth.js'], body: `
|
||||
<div class="tabs"><a class="on" href="/account">${esc(t('auth.account.profile'))}</a><a href="/account/security">${esc(t('auth.account.security'))}</a><a href="/account/sessions">${esc(t('auth.sessions.title'))}</a></div>
|
||||
${!u.email_verified_at ? `<div class="alert warning">${esc(t('auth.verify.banner'))} <button class="btn" data-action="resend-verify">${esc(t('auth.verify.resend'))}</button></div>` : ''}
|
||||
<div class="card"><h2>${esc(t('auth.account.profile'))}</h2><dl class="kv"><dt>${esc(t('auth.login.email'))}</dt><dd>${esc(u.email)} ${u.email_verified_at ? '<span class="badge ok">✔ 확인됨</span>' : '<span class="badge warning">! 확인 전</span>'}</dd></dl>
|
||||
<form id="profile" data-dirty-guard>${f('displayName', t('auth.signup.displayName'), 'text', `value="${esc(u.display_name)}" maxlength="80" required`)}<button class="btn primary" type="submit">${esc(t('common.save'))}</button></form></div>
|
||||
<div class="card"><h2>${esc(t('auth.account.changePassword'))}</h2><form id="password">${f('current', t('auth.account.currentPassword'), 'password', 'autocomplete="current-password" required')}${f('next', t('auth.reset.newPassword'), 'password', 'autocomplete="new-password" minlength="8" required')}<p class="err" id="pw-err"></p><button class="btn primary" type="submit">${esc(t('auth.account.changePassword'))}</button></form><p class="hint">비밀번호를 바꾸면 다른 기기의 로그인이 모두 종료됩니다.</p></div>` });
|
||||
}
|
||||
export function securityPage(shell, o) {
|
||||
const mfaOn = !!o.mfaEnabled;
|
||||
return shell({ ...o, title: t('auth.mfa.title'), nav: null, crumbs: [{ label: t('auth.account.title'), href: '/account' }, { label: t('auth.account.security') }], scripts: ['/ui/auth/auth.js'], body: `
|
||||
<div class="tabs"><a href="/account">${esc(t('auth.account.profile'))}</a><a class="on" href="/account/security">${esc(t('auth.account.security'))}</a><a href="/account/sessions">${esc(t('auth.sessions.title'))}</a></div>
|
||||
<div class="card"><h2>${esc(t('auth.mfa.title'))}</h2><dl class="kv"><dt>${esc(t('auth.mfa.level'))}</dt><dd>${mfaOn ? `<span class="badge ok">✔ ${esc(t('auth.mfa.levelMfa'))}</span>` : `<span class="badge warning">! ${esc(t('auth.mfa.levelPw'))}</span>`}</dd><dt>${esc(t('auth.mfa.title'))}</dt><dd>${esc(mfaOn ? t('auth.mfa.enabled') : t('auth.mfa.disabled'))}${o.mfaEnabledAt ? ` <span class="sub">(${esc(o.mfaEnabledAt)})</span>` : ''}</dd>${mfaOn ? `<dt>${esc(t('auth.mfa.recovery'))}</dt><dd>남은 복구 코드 ${o.recoveryLeft}개</dd>` : ''}</dl>
|
||||
<div id="mfa-area" data-enabled="${mfaOn ? 1 : 0}">${mfaOn ? `<form id="mfa-disable">${f('password', t('auth.login.password'), 'password', 'autocomplete="current-password" required')}${f('code', t('auth.login.mfaTitle'), 'text', 'inputmode="numeric" required')}<p class="hint">${esc(t('auth.mfa.disableNeeds'))}</p><p class="err" id="mfad-err"></p><button class="btn danger" type="submit">${esc(t('auth.mfa.disable'))}</button></form>
|
||||
<form id="mfa-regen" style="margin-top:12px">${f('password2', t('auth.login.password'), 'password', 'autocomplete="current-password" required')}<button class="btn" type="submit">${esc(t('auth.mfa.regenerate'))}</button></form><div id="recovery-out"></div>`
|
||||
: `<button class="btn primary" data-action="mfa-setup">${esc(t('auth.mfa.setup'))}</button><div id="mfa-setup"></div>`}</div></div>` });
|
||||
}
|
||||
export function sessionsPage(shell, o) {
|
||||
return shell({ ...o, title: t('auth.sessions.title'), nav: null, crumbs: [{ label: t('auth.account.title'), href: '/account' }, { label: t('auth.sessions.title') }], scripts: ['/ui/auth/auth.js'], body: `
|
||||
<div class="tabs"><a href="/account">${esc(t('auth.account.profile'))}</a><a href="/account/security">${esc(t('auth.account.security'))}</a><a class="on" href="/account/sessions">${esc(t('auth.sessions.title'))}</a></div>
|
||||
<div class="card"><div class="ph-row"><h2>${esc(t('auth.sessions.title'))}</h2><button class="btn danger" data-action="revoke-others">${esc(t('auth.sessions.revokeOthers'))}</button></div><div id="sessions">${o.statePanel('loading')}</div></div>` });
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/* portal/auth/public/auth.js — 로그인·가입·확인·재설정·내 계정·보안·기기 화면 동작. 서버 응답 message 를 그대로 보이고 오류는 입력 아래에 표시한다. */
|
||||
(() => {
|
||||
const $ = (s, r = document) => r.querySelector(s); const val = (form, n) => (form.elements[n] ? form.elements[n].value : '');
|
||||
const showErr = (id, e) => { const el = $(id); if (el) el.textContent = e.message || String(e); };
|
||||
const login = $('#login'); const mfa = $('#mfa'); let preauth = null;
|
||||
if (login) login.onsubmit = async (e) => { e.preventDefault(); showErr('#login-err', ''); try { const r = await aw.api('POST', '/api/auth/login', { email: val(login, 'email'), password: val(login, 'password'), next: login.dataset.next }); if (r.mfaRequired) { preauth = r.preauth; login.hidden = true; mfa.hidden = false; mfa.elements.code.focus(); } else location.href = r.next || '/'; } catch (err) { showErr('#login-err', err); if (err.detail && err.detail.lockUntil) login.querySelector('button').disabled = true; } };
|
||||
if (mfa) mfa.onsubmit = async (e) => { e.preventDefault(); showErr('#mfa-err', ''); try { const r = await aw.api('POST', '/api/auth/mfa', { preauth, code: val(mfa, 'code') }); if (r.recoveryLeft != null) alert(`복구 코드로 로그인했습니다. 남은 복구 코드 ${r.recoveryLeft}개`); location.href = r.next || '/'; } catch (err) { showErr('#mfa-err', err); if (/preauth_/.test(err.code)) { mfa.hidden = true; login.hidden = false; showErr('#login-err', err); } } };
|
||||
const signup = $('#signup');
|
||||
if (signup) signup.onsubmit = async (e) => { e.preventDefault(); showErr('#signup-err', ''); try { const r = await aw.api('POST', '/api/auth/signup', { email: val(signup, 'email'), displayName: val(signup, 'displayName'), password: val(signup, 'password'), consents: { terms: signup.elements.terms.checked, privacy: signup.elements.privacy.checked, marketing: signup.elements.marketing.checked } }); const d = $('#signup-done'); d.textContent = r.message; d.hidden = false; signup.hidden = true; setTimeout(() => { location.href = r.next || '/'; }, 1500); } catch (err) { showErr('#signup-err', err); } };
|
||||
document.addEventListener('click', async (e) => { if (e.target.matches('[data-action=resend-verify]')) { try { const r = await aw.api('POST', '/api/auth/resend-verification'); aw.toast(r.message || '보냈습니다'); } catch (err) { aw.toast(err.message); } } });
|
||||
const rr = $('#reset-request');
|
||||
if (rr) rr.onsubmit = async (e) => { e.preventDefault(); try { const r = await aw.api('POST', '/api/auth/request-reset', { email: val(rr, 'email') }); const d = $('#rr-done'); d.textContent = r.message; d.hidden = false; rr.hidden = true; } catch (err) { showErr('#rr-err', err); } };
|
||||
const reset = $('#reset');
|
||||
if (reset) reset.onsubmit = async (e) => { e.preventDefault(); try { const r = await aw.api('POST', '/api/auth/reset', { token: reset.dataset.token, password: val(reset, 'password') }); const d = $('#reset-done'); d.textContent = r.message; d.hidden = false; reset.hidden = true; setTimeout(() => { location.href = '/login?notice=reset'; }, 1500); } catch (err) { showErr('#reset-err', err); } };
|
||||
const profile = $('#profile');
|
||||
if (profile) profile.onsubmit = async (e) => { e.preventDefault(); try { const r = await aw.api('PATCH', '/api/account/profile', { displayName: val(profile, 'displayName') }); aw.markClean(); aw.toast(r.message); } catch (err) { aw.toast(err.message); } };
|
||||
const pw = $('#password');
|
||||
if (pw) pw.onsubmit = async (e) => { e.preventDefault(); showErr('#pw-err', ''); try { const r = await aw.api('POST', '/api/account/password', { current: val(pw, 'current'), next: val(pw, 'next') }); pw.reset(); aw.toast(r.message); } catch (err) { showErr('#pw-err', err); } };
|
||||
const codesHtml = (r) => `<div class="alert warning"><b>${aw.esc(r.message)}</b><ul class="mono">${r.recoveryCodes.map((c) => `<li>${aw.esc(c)}</li>`).join('')}</ul><button class="btn copy" data-action="copy" data-copy="${aw.esc(r.recoveryCodes.join('\n'))}">복사</button></div>`;
|
||||
document.addEventListener('click', async (e) => { if (!e.target.matches('[data-action=mfa-setup]')) return; try { const r = await aw.api('POST', '/api/account/mfa/setup'); $('#mfa-setup').innerHTML = `<p>인증 앱에서 아래 주소를 등록하거나 수동 입력 키를 넣으세요.</p><p class="mono" style="word-break:break-all">${aw.esc(r.otpauth)}</p><p><b>수동 입력 키:</b> <span class="mono copy" data-action="copy" data-copy="${aw.esc(r.secret.replace(/ /g, ''))}">${aw.esc(r.secret)}</span></p><form id="mfa-enable"><div class="f"><label for="mcode">인증 앱의 6자리 코드</label><input id="mcode" name="code" inputmode="numeric" required></div><p class="err" id="mfae-err"></p><button class="btn primary">코드 확인 후 켜기</button></form><div id="mfa-codes"></div>`; $('#mfa-enable').onsubmit = async (ev) => { ev.preventDefault(); try { const rr2 = await aw.api('POST', '/api/account/mfa/enable', { code: $('#mcode').value }); $('#mfa-enable').hidden = true; $('#mfa-codes').innerHTML = codesHtml(rr2) + '<a class="btn" href="/account/security">완료</a>'; } catch (err) { showErr('#mfae-err', err); } }; } catch (err) { aw.toast(err.message); } });
|
||||
const md = $('#mfa-disable');
|
||||
if (md) md.onsubmit = async (e) => { e.preventDefault(); try { await aw.api('POST', '/api/account/mfa/disable', { password: val(md, 'password'), code: val(md, 'code') }); location.reload(); } catch (err) { showErr('#mfad-err', err); } };
|
||||
const mr = $('#mfa-regen');
|
||||
if (mr) mr.onsubmit = async (e) => { e.preventDefault(); try { const r = await aw.api('POST', '/api/account/mfa/recovery-codes', { password: val(mr, 'password2') }); $('#recovery-out').innerHTML = codesHtml(r); mr.reset(); } catch (err) { aw.toast(err.message); } };
|
||||
const sess = $('#sessions');
|
||||
async function loadSessions() { try { const r = await aw.api('GET', '/api/account/sessions'); if (!r.sessions.length) return aw.state(sess, 'empty'); sess.innerHTML = `<table class="r"><thead><tr><th>기기</th><th>IP</th><th>로그인 시각</th><th>마지막 사용</th><th></th></tr></thead><tbody>${r.sessions.map((s) => `<tr><td data-l="기기">${aw.esc(s.label || '기기')}${s.id === r.current ? ' <span class="badge ok">● 현재 세션</span>' : ''}</td><td data-l="IP">${aw.esc(s.ip || '')}</td><td data-l="로그인 시각">${aw.esc(aw.fmt(s.created_at))}</td><td data-l="마지막 사용">${aw.esc(aw.fmt(s.last_seen_at))}</td><td class="right">${s.id === r.current ? '' : `<button class="btn danger" data-rev="${aw.esc(s.id)}">종료</button>`}</td></tr>`).join('')}</tbody></table>`; sess.querySelectorAll('[data-rev]').forEach((b) => { b.onclick = async () => { await aw.api('DELETE', '/api/account/sessions/' + b.dataset.rev); loadSessions(); }; }); } catch (err) { aw.state(sess, err.status === 403 ? 'forbidden' : 'failed', err.message, { retry: loadSessions }); } }
|
||||
if (sess) { loadSessions(); document.addEventListener('click', async (e) => { if (e.target.matches('[data-action=revoke-others]')) { if (await aw.confirmDanger({ title: '다른 모든 세션 종료', target: '이 기기를 제외한 모든 로그인', impacts: ['다른 기기에서는 다시 로그인해야 합니다'] })) { await aw.api('POST', '/api/account/sessions/revoke-others'); loadSessions(); } } }); }
|
||||
})();
|
||||
@@ -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)}`; }); }
|
||||
Reference in New Issue
Block a user