2단계 인증은 7.0.6 에서 서버측이 갖춰졌지만 인증번호를 입력할 화면이 어느 버전에도
없었다. 그래서 그 설정을 켠 사이트는 관리자를 포함한 전원이 로그인할 수 없었다.
원인은 `POST /api/auth/login` 이 조건에 따라 **다른 형태의 200** 을 돌려준다는 것이다.
평소에는 `{token, user}` 지만 2단계 인증이 켜져 있으면 `{two_factor_required,
challenge_id, ...}` 를 돌려준다. 프론트는 앞의 형태만 선언하고 `response.data.user.language`
를 바로 읽었으므로 그 자리에서 TypeError 가 났고, 영문 원문이 로그인 화면에 그대로 노출됐다.
서버는 정상 응답했으므로 서버 로그에는 아무 흔적도 남지 않는다.
이어서 `setToken(undefined)` 가 `localStorage` 에 문자열 `"undefined"` 를 남겼다.
이 값은 truthy 라 이후 모든 요청이 `Bearer undefined` 로 나가 401 이 되고, 사용자에게는
「세션이 만료되었습니다」로 보인다. 관리자 로그인은 한발 더 나가 `null->isAdmin` 으로
500 이 되어, 설정을 되돌릴 수단까지 함께 사라졌다.
## 구현
- 로그인 응답을 판별 유니온(`LoginResult`)으로 표현하고, 형태를 판별한 뒤에 읽는다.
`ApiClient.setToken` 은 비어 있지 않은 문자열만 저장한다.
- 사용자·관리자 로그인 화면에 인증번호 입력 단계를 추가했다. 같은 카드 안에서 넘어가며
「인증번호 다시 받기」와 「처음부터」를 제공한다. 관리자 판정은 코드 확인에 성공한 뒤에
수행하고, 거부할 때는 그 직전에 발급된 토큰을 회수한다.
- 재발송(`login/two-factor/resend`)은 기존 challenge 를 취소하고 새로 발행한다. 유효한
코드를 여러 개 살려 두면 대입 시도의 표적이 넓어진다.
- 인증번호를 보내지 못하면 401 이 아니라 503 으로 답한다. 자격 증명은 올바른데 401 로
뭉개면 사용자는 비밀번호를 의심하며 같은 시도를 반복하고, 운영자는 메일 설정이 깨진
사실을 알 방법이 없다.
- 공개 본인인증 경로(`identity/verify`·`cancel`)가 로그인 목적의 challenge 를 소진하지
못하도록 403 게이트를 세웠다. 소진되면 그 challenge 로 영영 로그인할 수 없다.
- 로그인 시도 제한 429 응답이 다국어 문구를 싣도록 했다(종전에는 프레임워크 기본 영문).
- 다국어 파라미터에서 파이프 표현식이 평가되지 않아 「유효시간 까지」처럼 값이 빠지던
문제를 함께 고쳤다. 같은 결함이 문의 목록 화면에도 있었다.
## 이번 점검에서 함께 고친 것
- 계정 잠금(423)·발송 실패(503) 응답이 사용자·관리자 컨트롤러에 동일하게 복제돼 있었고
그 주석 자신은 "단일 지점에서 만든다" 고 적혀 있었다. 페이로드에 필드가 하나 추가되면
한쪽만 따라가 같은 실패를 두 화면이 다르게 안내하게 된다 — 트레이트로 통합했다.
- 테스트가 개발자 자신의 사이트 설정을 읽고 있었다. 2단계 인증을 켜 둔 환경에서는 로그인
성공을 전제한 테스트가 503 으로 깨지는데 실패 메시지가 원인을 가리키지도 않는다.
같은 결함군을 위해 이미 존재하던 단일 지점에 그 축을 추가했다.
## 버전
코어 7.0.11 · sirsoft-basic 1.1.4 · sirsoft-admin_basic 1.0.9 ·
번들 일본어팩 3종 · 템플릿 엔진 engine-v1.65.0.
284 lines
10 KiB
TypeScript
284 lines
10 KiB
TypeScript
/**
|
|
* Playwright 2단계 인증 fixture (코어 영역).
|
|
*
|
|
* 로그인 2단계 인증은 **사이트 설정과 메일 발송**에 의존한다. 브라우저만으로는 재현할 수 없으므로
|
|
* 이 fixture 가 세 가지를 가역적으로 준비한다.
|
|
*
|
|
* ① 보안 환경설정 `security.two_factor_auth` 를 켜고, 끝나면 원래 값으로 되돌린다.
|
|
* ② 메일을 받아서 버리는 로컬 SMTP 싱크를 띄우고 메일 설정을 그쪽으로 돌린다. 끝나면
|
|
* 원래 값으로 되돌린다. (실제 메일을 보내면 테스트가 외부 상태를 바꾼다)
|
|
* `log` 메일러를 쓰지 않는 이유는 `smtp-sink.ts` 머리말 참조.
|
|
* ③ 인증번호는 해시로만 저장되어 되읽을 수 없으므로, 알려진 코드를 심는다
|
|
* (`php artisan playwright:seed-two-factor --plant=…`).
|
|
*
|
|
* 보안 설정은 **관리자 설정 API** 로 바꾼다 — 운영자가 화면에서 하는 것과 같은 경로다.
|
|
* 메일 설정만 파일을 직접 다룬다: 원래 값(빈 SMTP 호스트)은 저장 검증을 통과하지 못해
|
|
* API 로는 **되돌릴 수 없기 때문**이다. 파일은 바이트 단위로 백업했다가 그대로 복원한다.
|
|
*
|
|
* 원복은 실패해도 조용히 넘기지 않는다 — 되돌리지 못한 채 끝나면 사이트가 2단계 인증이 켜진
|
|
* 상태로 남아 이후 모든 로그인이 막힌다.
|
|
*/
|
|
import type { APIRequestContext } from '@playwright/test';
|
|
import { startSmtpSink, type SmtpSink } from './smtp-sink';
|
|
import { execSync } from 'node:child_process';
|
|
import { copyFileSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { dirname, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
|
/** artisan 이 있는 코어 루트 */
|
|
function coreRoot(): string {
|
|
return process.env.G7_ROOT || resolve(__dirname, '../../../');
|
|
}
|
|
|
|
/**
|
|
* 관리자 설정 API 경로.
|
|
*
|
|
* 절대 URL 을 만들지 않는다 — Playwright 의 `request` 컨텍스트가 `baseURL` 과
|
|
* `ignoreHTTPSErrors` 를 이미 갖고 있다. Node 의 `fetch` 를 쓰면 자체 서명 인증서를 쓰는
|
|
* 개발 호스트에서 TLS 로 막히고, 그것을 우회하려면 이 파일이 TLS 검증을 끄게 된다.
|
|
*/
|
|
const SETTINGS_PATH = '/api/admin/settings';
|
|
|
|
/**
|
|
* `playwright:seed-two-factor` 를 실행하고 마지막 출력 줄을 돌려준다.
|
|
*
|
|
* @param args 커맨드 인자 (예: ['--plant=<uuid>', '--code=135790'])
|
|
* @returns stdout 의 마지막 비어있지 않은 줄
|
|
*/
|
|
export function runSeedTwoFactor(args: string[]): string {
|
|
const command = `php artisan playwright:seed-two-factor ${args.join(' ')}`.trim();
|
|
|
|
let lastError: unknown;
|
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
try {
|
|
const stdout = execSync(command, {
|
|
cwd: coreRoot(),
|
|
encoding: 'utf-8',
|
|
env: { ...process.env, G7_PLAYWRIGHT_BYPASS: '1' },
|
|
});
|
|
const lines = stdout.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
|
if (lines.length === 0) {
|
|
throw new Error(`playwright:seed-two-factor 가 빈 응답을 반환했습니다: ${command}`);
|
|
}
|
|
return lines[lines.length - 1].trim();
|
|
} catch (error) {
|
|
lastError = error;
|
|
if (attempt < 2) {
|
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 400);
|
|
}
|
|
}
|
|
}
|
|
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
}
|
|
|
|
/**
|
|
* 알려진 인증번호를 challenge 에 심는다.
|
|
*
|
|
* @param challengeId 로그인 응답이 돌려준 challenge UUID
|
|
* @param code 심을 인증번호 (기본 135790)
|
|
* @returns 심어 둔 인증번호
|
|
*/
|
|
export function plantTwoFactorCode(challengeId: string, code = '135790'): string {
|
|
return runSeedTwoFactor([`--plant=${challengeId}`, `--code=${code}`, '--gc-hours=0']);
|
|
}
|
|
|
|
/**
|
|
* 실측이 만든 테스트 계정을 전부 제거한다.
|
|
*
|
|
* 이 계정들은 알려진 비밀번호를 갖고, 관리자용은 관리자 역할까지 갖는다 — 나이 기준 정리를
|
|
* 기다리면 그 사이가 그대로 열린 문이다. 실측이 끝나면 즉시 지운다.
|
|
*
|
|
* @returns 커맨드의 마지막 출력 줄
|
|
*/
|
|
export function purgeTwoFactorUsers(): string {
|
|
return runSeedTwoFactor(['--purge-users', '--gc-hours=0']);
|
|
}
|
|
|
|
/**
|
|
* 알려진 비밀번호를 가진 Active 테스트 계정을 준비한다.
|
|
*
|
|
* @param suffix 계정 구분 접미사 (예: 'user' / 'nonadmin')
|
|
* @param options admin 역할 부여 여부와 비밀번호
|
|
* @returns 준비된 계정 이메일
|
|
*/
|
|
export function ensureTwoFactorUser(
|
|
suffix: string,
|
|
options: { admin?: boolean; password?: string } = {}
|
|
): string {
|
|
const args = [`--ensure-user=${suffix}`, `--password=${options.password ?? 'Passw0rd!2fa'}`];
|
|
if (options.admin) args.push('--admin');
|
|
return runSeedTwoFactor(args);
|
|
}
|
|
|
|
/** 되돌리기 위해 보관하는 상태 */
|
|
type TwoFactorFixtureState = {
|
|
/** 보안 탭 전체 값 (API 로 그대로 되쓴다) */
|
|
security: Record<string, unknown>;
|
|
/** 메일 설정 파일 백업 경로 (없으면 파일 자체가 없던 설치) */
|
|
mailBackupPath: string | null;
|
|
sink: SmtpSink;
|
|
};
|
|
|
|
/** 메일 설정 파일 경로 */
|
|
function mailSettingsPath(): string {
|
|
return resolve(coreRoot(), 'storage/app/settings/mail.json');
|
|
}
|
|
|
|
/**
|
|
* 메일 설정을 SMTP 싱크로 돌린다.
|
|
*
|
|
* 값 3개만 줄 단위로 치환한다 — 재직렬화하면 운영자 파일의 서식이 통째로 바뀐다.
|
|
* `encryption` 을 비우는 이유는 싱크가 STARTTLS 를 제공하지 않기 때문이다.
|
|
*
|
|
* @param port 싱크 포트
|
|
* @returns 백업 파일 경로 (설정 파일이 없으면 null)
|
|
*/
|
|
function redirectMailToSink(port: number): string | null {
|
|
const path = mailSettingsPath();
|
|
if (! existsSync(path)) {
|
|
return null;
|
|
}
|
|
|
|
const backupPath = `${path}.bak-playwright-2fa`;
|
|
copyFileSync(path, backupPath);
|
|
|
|
const original = readFileSync(path, 'utf-8');
|
|
const swapped = original
|
|
.replace(/"host"(\s*):(\s*)"[^"]*"/, `"host"$1:$2"127.0.0.1"`)
|
|
.replace(/"port"(\s*):(\s*)\d+/, `"port"$1:$2${port}`)
|
|
.replace(/"encryption"(\s*):(\s*)("[^"]*"|null)/, '"encryption"$1:$2""');
|
|
|
|
writeFileSync(path, swapped, 'utf-8');
|
|
|
|
return backupPath;
|
|
}
|
|
|
|
/**
|
|
* 설정 탭 하나를 통째로 저장한다.
|
|
*
|
|
* @param request Playwright API 요청 컨텍스트
|
|
* @param token 관리자 Sanctum 토큰
|
|
* @param tab 설정 탭
|
|
* @param values 저장할 탭 전체 값
|
|
*/
|
|
async function writeSettingsTab(
|
|
request: APIRequestContext,
|
|
token: string,
|
|
tab: string,
|
|
values: Record<string, unknown>
|
|
): Promise<void> {
|
|
const write = await request.post(SETTINGS_PATH, {
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
data: { _tab: tab, [tab]: values },
|
|
});
|
|
|
|
if (!write.ok()) {
|
|
throw new Error(`${tab} 설정 저장 실패: HTTP ${write.status()} — ${await write.text()}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 설정 탭 하나를 읽고, 지정한 키만 바꿔 저장한다.
|
|
*
|
|
* 저장 페이로드는 `{ _tab, <탭>: { ... } }` 형태이고 탭 **전체**가 검증되므로, 바꿀 키만
|
|
* 보내면 나머지 필수 항목이 422 로 거부된다. 현재 값을 그대로 되돌려 보내 다른 설정은
|
|
* 건드리지 않는다.
|
|
*
|
|
* @param request Playwright API 요청 컨텍스트 (baseURL·ignoreHTTPSErrors 상속)
|
|
* @param token 관리자 Sanctum 토큰
|
|
* @param tab 설정 탭 (`security` · `mail` 등)
|
|
* @param patch 바꿀 키-값
|
|
* @returns 바꾸기 전의 탭 전체 값 (원복에 그대로 쓴다)
|
|
*/
|
|
async function patchSettingsTab(
|
|
request: APIRequestContext,
|
|
token: string,
|
|
tab: string,
|
|
patch: Record<string, unknown>
|
|
): Promise<Record<string, unknown>> {
|
|
const read = await request.get(`${SETTINGS_PATH}?tab=${tab}`, {
|
|
headers: {
|
|
Accept: 'application/json',
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
});
|
|
|
|
if (!read.ok()) {
|
|
throw new Error(`${tab} 설정 조회 실패: HTTP ${read.status()}`);
|
|
}
|
|
|
|
const body = await read.json();
|
|
const current = (body?.data?.[tab] ?? {}) as Record<string, unknown>;
|
|
|
|
// `_meta` 같은 내부 키는 저장 페이로드에서 제외한다.
|
|
const previous = Object.fromEntries(
|
|
Object.entries(current).filter(([key]) => !key.startsWith('_'))
|
|
);
|
|
|
|
await writeSettingsTab(request, token, tab, { ...previous, ...patch });
|
|
|
|
return previous;
|
|
}
|
|
|
|
/**
|
|
* 2단계 인증 실측 환경을 준비한다.
|
|
*
|
|
* @param request Playwright API 요청 컨텍스트
|
|
* @param token 관리자 Sanctum 토큰 (`core.settings.read` · `core.settings.update`)
|
|
* @returns 되돌리기에 필요한 상태
|
|
*/
|
|
export async function enableTwoFactorFixture(
|
|
request: APIRequestContext,
|
|
token: string
|
|
): Promise<TwoFactorFixtureState> {
|
|
// 메일 경로를 먼저 돌린다 — 2단계 인증을 켠 뒤에 코드가 발행되면 실제 메일이 나간다.
|
|
const sink = await startSmtpSink();
|
|
const mailBackupPath = redirectMailToSink(sink.port);
|
|
|
|
const security = await patchSettingsTab(request, token, 'security', { two_factor_auth: true });
|
|
|
|
return { security, mailBackupPath, sink };
|
|
}
|
|
|
|
/**
|
|
* 실측 환경을 되돌린다.
|
|
*
|
|
* 되돌리지 못한 채 끝나면 사이트가 2단계 인증이 켜진 상태로 남아 이후 모든 로그인이 막힌다 —
|
|
* 실패는 삼키지 않고 그대로 던진다. 보안 설정을 먼저 되돌리고, 그 실패가 메일 설정 복원을
|
|
* 가리지 않도록 둘 다 시도한 뒤에 던진다.
|
|
*
|
|
* @param request Playwright API 요청 컨텍스트
|
|
* @param token 관리자 Sanctum 토큰
|
|
* @param state `enableTwoFactorFixture` 가 돌려준 상태
|
|
*/
|
|
export async function restoreTwoFactorFixture(
|
|
request: APIRequestContext,
|
|
token: string,
|
|
state: TwoFactorFixtureState
|
|
): Promise<void> {
|
|
let firstError: unknown;
|
|
|
|
try {
|
|
await writeSettingsTab(request, token, 'security', state.security);
|
|
} catch (error) {
|
|
firstError = error;
|
|
}
|
|
|
|
if (state.mailBackupPath && existsSync(state.mailBackupPath)) {
|
|
copyFileSync(state.mailBackupPath, mailSettingsPath());
|
|
rmSync(state.mailBackupPath, { force: true });
|
|
}
|
|
|
|
await state.sink.close();
|
|
|
|
if (firstError) {
|
|
throw firstError;
|
|
}
|
|
}
|