Files
AriWeb-Suite/test/auth.test.mjs
T

101 lines
13 KiB
JavaScript
Raw Normal View History

/** test/auth.test.mjs — 가입·확인·로그인·잠금·세션·재설정·MFA·CSRF 회귀 테스트(실제 쿠키 흐름). */
import { test, eq, ok, done } from '../lib/test.mjs';
import { makeDeps, appFor, call } from './helpers.mjs';
import { invoke } from '../lib/http.mjs';
import * as totp from '../portal/auth/totp.mjs';
const mails = []; const mailer = { send: async (m) => { mails.push(m); return { ok: true, mode: 'test' }; }, mode: () => 'test' };
const deps = await makeDeps({ mailer });
const app = await appFor(deps, ['auth', 'groups', 'dashboard', 'notifications', 'auditui', 'help']);
const AREAS_OK = true;
/** 로그인 상태의 호출 도우미: 세션 쿠키 + CSRF */
function client(cookie, csrf) { return (method, path, body) => invoke(app, method, path, { body, headers: { cookie: `aw_session=${cookie}`, 'x-csrf': csrf || '', accept: 'application/json' } }); }
async function session(r) { const cookie = r.cookies.aw_session; const c = await invoke(app, 'GET', '/api/auth/csrf', { headers: { cookie: `aw_session=${cookie}` } }); return client(cookie, c.json.csrf); }
const linkToken = (m, kind) => (m.text.match(kind === 'verify' ? /token=([A-Za-z0-9_-]+)/ : /reset-password\/([A-Za-z0-9_-]+)/) || [])[1];
await test('가입 → 확인 메일 → 이메일 확인 → 개인 그룹과 별칭 user-01 → 대시보드 접근', async () => {
let r = await invoke(app, 'POST', '/api/auth/signup', { body: { email: 'alice@example.test', displayName: '앨리스', password: 'pw-example-1', consents: { terms: true, privacy: true } } });
eq(r.status, 200, JSON.stringify(r.json)); ok(r.cookies.aw_session, '가입 즉시 세션'); eq(mails.length, 1); ok(/token=/.test(mails[0].text));
const api = await session(r);
let me = await api('GET', '/api/auth/me'); eq(me.json.user.email_verified_at, null); eq(me.json.group.kind, 'personal'); eq(me.json.role, 'owner');
// 확인 전에는 verified 필수 API 거부(그룹 만들기)
r = await api('POST', '/api/groups', { name: '팀' }); eq(r.status, 403); eq(r.json.error, 'email_unverified');
r = await invoke(app, 'POST', '/api/auth/verify-email', { body: { token: linkToken(mails[0], 'verify') } }); eq(r.status, 200);
r = await invoke(app, 'POST', '/api/auth/verify-email', { body: { token: linkToken(mails[0], 'verify') } }); eq(r.json.error, 'token_used', '한 번만');
me = await api('GET', '/api/auth/me'); ok(me.json.user.email_verified_at);
const g = await api('GET', '/api/group'); eq(g.json.members[0].alias, 'user-01'); eq(g.json.members[0].role, 'owner');
const d = await api('GET', '/api/dashboard'); eq(d.status, 200); eq(d.json.summary.sites.purchased, 0);
});
await test('중복 가입: 존재를 드러내지 않는 일반 안내 / 필수 동의 없으면 거부', async () => {
let r = await invoke(app, 'POST', '/api/auth/signup', { body: { email: 'ALICE@example.test', displayName: 'x', password: 'pw-example-2', consents: { terms: true, privacy: true } } }); eq(r.status, 409); eq(r.json.error, 'signup_unavailable'); ok(!/이미 가입|존재/.test(r.json.message) || /로그인/.test(r.json.message));
r = await invoke(app, 'POST', '/api/auth/signup', { body: { email: 'bob@example.test', displayName: 'x', password: 'pw-example-2', consents: { terms: true } } }); eq(r.json.error, 'consent_required');
});
await test('로그인 실패 메시지 동일 / 5회 실패 → 15분 잠금(해제 시각 표시) / HTML 미로그인 → /login 리디렉션', async () => {
let r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'nobody@example.test', password: 'x' } }); const msg1 = r.json.message;
r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'wrong' } }); eq(r.json.message, msg1, '존재 여부 비노출');
for (let i = 0; i < 4; i++) r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'wrong' } });
eq(r.status, 429); eq(r.json.error, 'locked'); ok(r.json.detail.lockUntil); ok(/KST/.test(r.json.message));
r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-1' } }); eq(r.status, 429, '맞는 비밀번호도 잠금 중에는 거부');
deps.store.run('DELETE FROM login_attempts');
r = await invoke(app, 'GET', '/sites', { headers: { accept: 'text/html' } }); eq(r.status, 302); ok(r.headers.Location.startsWith('/login?next=%2Fsites'));
r = await invoke(app, 'GET', '/api/dashboard'); eq(r.status, 401);
});
await test('로그인 → 기기 목록 → 개별 종료 → 다른 모든 세션 종료 → 비밀번호 변경이 다른 세션 무효화 / CSRF 거부', async () => {
const a = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-1' }, headers: { 'user-agent': 'Mozilla/5.0 (Windows) Chrome/120' } }); eq(a.status, 200);
const b = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-1' } }); const c = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-1' } });
const A = await session(a); const B = await session(b); const C = await session(c);
let r = await A('GET', '/api/account/sessions'); ok(r.json.sessions.length >= 3); ok(r.json.sessions.some((s) => /Windows/.test(s.label)));
const bCur = (await B('GET', '/api/account/sessions')).json.current;
r = await A('DELETE', '/api/account/sessions/' + bCur); eq(r.json.revoked, 1); r = await B('GET', '/api/auth/me'); eq(r.json.user, null, '종료된 세션');
// CSRF: 세션은 있는데 헤더가 틀리면 403
r = await invoke(app, 'POST', '/api/account/sessions/revoke-others', { headers: { cookie: `aw_session=${a.cookies.aw_session}`, 'x-csrf': 'wrong' } }); eq(r.status, 403); eq(r.json.error, 'csrf');
r = await A('POST', '/api/account/sessions/revoke-others'); ok(r.json.revoked >= 1); r = await C('GET', '/api/auth/me'); eq(r.json.user, null);
// 비밀번호 변경 → 다른 세션 종료(이 세션 유지)
const d = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-1' } }); const D = await session(d);
r = await A('POST', '/api/account/password', { current: 'pw-example-1', next: 'pw-example-9' }); eq(r.status, 200); r = await D('GET', '/api/auth/me'); eq(r.json.user, null); r = await A('GET', '/api/auth/me'); ok(r.json.user);
r = await A('POST', '/api/account/password', { current: 'bad', next: 'pw-example-9' }); eq(r.json.error, 'wrong_password');
});
await test('비밀번호 재설정: 요청 응답 동일 · 링크 상태(valid→used) · 재설정 후 모든 세션 종료', async () => {
const n0 = mails.length; let r = await invoke(app, 'POST', '/api/auth/request-reset', { body: { email: 'ghost@example.test' } }); const msg = r.json.message; eq(mails.length, n0, '없는 계정은 메일 없음');
r = await invoke(app, 'POST', '/api/auth/request-reset', { body: { email: 'alice@example.test' } }); eq(r.json.message, msg); eq(mails.length, n0 + 1);
const tok = linkToken(mails[n0], 'reset'); ok(tok); r = await invoke(app, 'GET', '/api/auth/reset/' + tok); eq(r.json.state, 'valid');
const live = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-9' } }); const L = await session(live);
r = await invoke(app, 'POST', '/api/auth/reset', { body: { token: tok, password: 'pw-example-3' } }); eq(r.status, 200);
r = await invoke(app, 'GET', '/api/auth/reset/' + tok); eq(r.json.state, 'used'); r = await invoke(app, 'GET', '/reset-password/' + tok, { headers: { accept: 'text/html' } }); ok(/이미 사용한/.test(r.text));
r = await L('GET', '/api/auth/me'); eq(r.json.user, null, '재설정 뒤 기존 세션 종료');
r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); eq(r.status, 200);
deps.store.run('UPDATE email_tokens SET expires_at=? WHERE token_hash IS NOT NULL AND kind=\'reset\'', new Date(0).toISOString()); r = await invoke(app, 'GET', '/api/auth/reset/' + tok); eq(r.json.state, 'used');
});
await test('MFA: 등록(코드 확인) → 복구 코드 1회 표시 → 로그인 2단계 → 같은 코드 재사용 거부 → 복구 코드 1회용 → 해제', async () => {
const l = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); const A = await session(l);
let r = await A('POST', '/api/account/mfa/enable', { code: '000000' }); eq(r.json.error, 'mfa_not_pending');
r = await A('POST', '/api/account/mfa/setup'); const secret = r.json.secret.replace(/ /g, ''); ok(/^otpauth:\/\/totp\//.test(r.json.otpauth));
r = await A('POST', '/api/account/mfa/enable', { code: '123456' }); eq(r.json.error, 'mfa_bad_code');
r = await A('POST', '/api/account/mfa/enable', { code: totp.currentCode(secret) }); eq(r.status, 200); eq(r.json.recoveryCodes.length, 10); const rc = r.json.recoveryCodes;
ok(!JSON.stringify(deps.store.get('SELECT recovery_json FROM users WHERE email=?', 'alice@example.test')).includes(rc[0]), '복구 코드는 해시만 저장');
// 로그인: 1단계 → preauth → 2단계
r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); eq(r.json.mfaRequired, true); ok(!r.cookies.aw_session); const pre = r.json.preauth;
/** 등록·이전 로그인에 쓴 카운터를 지워 현재 구간 코드를 다시 쓸 수 있게 한다(시간 경계에 따른 흔들림 방지) */
const rewind = () => { const u = deps.store.get('SELECT mfa_json FROM users WHERE email=?', 'alice@example.test'); const m = JSON.parse(u.mfa_json); m.lastCounter = null; deps.store.run('UPDATE users SET mfa_json=? WHERE email=?', JSON.stringify(m), 'alice@example.test'); };
rewind(); const code = totp.currentCode(secret);
r = await invoke(app, 'POST', '/api/auth/mfa', { body: { preauth: pre, code: 'bad' } }); eq(r.status, 401);
r = await invoke(app, 'POST', '/api/auth/mfa', { body: { preauth: pre, code } }); eq(r.status, 200, JSON.stringify(r.json)); ok(r.cookies.aw_session);
r = await invoke(app, 'POST', '/api/auth/mfa', { body: { preauth: pre, code } }); ok(/preauth_/.test(r.json.error), '사전 인증 토큰 1회용');
r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); r = await invoke(app, 'POST', '/api/auth/mfa', { body: { preauth: r.json.preauth, code } }); eq(r.status, 401, '같은 TOTP 코드 재사용 거부');
// 복구 코드
r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); const pre2 = r.json.preauth; r = await invoke(app, 'POST', '/api/auth/mfa', { body: { preauth: pre2, code: rc[0] } }); eq(r.status, 200); eq(r.json.recoveryLeft, 9);
r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); r = await invoke(app, 'POST', '/api/auth/mfa', { body: { preauth: r.json.preauth, code: rc[0] } }); eq(r.status, 401, '복구 코드 1회용');
// 해제: 비밀번호+코드
rewind(); const l2 = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); r = await invoke(app, 'POST', '/api/auth/mfa', { body: { preauth: l2.json.preauth, code: totp.currentCode(secret) } }); eq(r.status, 200, JSON.stringify(r.json)); const B = await session(r);
r = await B('POST', '/api/account/mfa/disable', { password: 'pw-example-3', code: '000000' }); eq(r.json.error, 'mfa_bad_code');
rewind(); r = await B('POST', '/api/account/mfa/disable', { password: 'pw-example-3', code: totp.currentCode(secret) }); eq(r.status, 200, JSON.stringify(r.json));
r = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); ok(r.cookies.aw_session, '해제 뒤 바로 로그인');
});
await test('화면 렌더: 로그인·가입·내 계정·보안·기기·도움말 HTML 이 열린다', async () => {
const l = await invoke(app, 'POST', '/api/auth/login', { body: { email: 'alice@example.test', password: 'pw-example-3' } }); const h = { cookie: `aw_session=${l.cookies.aw_session}`, accept: 'text/html' };
for (const p of ['/account', '/account/security', '/account/sessions', '/help', '/notifications', '/activity', '/group', '/']) { const r = await invoke(app, 'GET', p, { headers: h }); eq(r.status, 200, p); ok(/<html lang="ko">/.test(r.text), p); }
for (const p of ['/login', '/signup', '/reset-password']) { const r = await invoke(app, 'GET', p, { headers: { accept: 'text/html' } }); eq(r.status, 200, p); }
const r = await invoke(app, 'GET', '/login', { headers: h }); eq(r.status, 302, '로그인 상태면 /login 은 대시보드로');
});
done();