generated from ttipo/project-base
feat(backend): 13단계 전환 준비 — 제한된 세션 · 2단계 필수 · 운영 도구 · 받은편지함 알림 · 문의 연락처
전 직원 전환 때 관리자가 계정을 다루고(입사 · 비밀번호 재설정 · 2단계 해제 · 잠금 · 퇴사 마무리 · 공용 주소) 회사가 2단계 인증을 필수로 할 수 있게 했다. - 제한된 세션(AUTH_010): 임시 비밀번호(엔진 만료) · 2단계 필수인 직원은 로그인은 되지만 새 비밀번호 · 2단계 켜기만 지난다. 푸는 요청은 그 세션만 다음 제한으로 바꾸고, 2단계를 켜면 2단계를 거친 적 없는 다른 세션은 끊는다. 만료는 게이트웨이 대리 접근 세션의 권한(다섯)으로 로그인 · 갱신 때 알아챈다. - 운영 도구 CLI(dist/cli/ops.js): 바뀔 것 → y/N → 실행 → ops_audit_log. 관리자 비밀번호는 TTY 에서만 · 입력 기록 0, 재설정은 앱 비밀번호 → 세션 → 알림 → 만료 차례(만료 뒤 대리 접근이 막힌다). - 받은편지함 알림 메일(Email/import — 링크 없음): 재설정 · 2단계 해제 · 위험 파일 판정. 끊은 세션 기억은 15초마다 DB 에서 채운다. - 2단계를 켠 잠긴 계정도 로그인은 AUTH_004 — 엔진이 잠긴 계정에도 2단계를 먼저 봐, 게이트웨이 자신의 권한으로 잠김과 설정 문제를 가른다(실사용 시험에서 찾음). 검증: lint 0 · 단위 3,138 · e2e 266 · 리뷰 두 번 · 실사용 시험(브라우저 + 운영 도구) 89/89. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+427
-13
@@ -73,7 +73,11 @@ import {
|
||||
type MailImageResponse,
|
||||
type ResolvedAddress,
|
||||
} from '@/modules/mail/images/mail-image-fetcher';
|
||||
import { MAIL_PROVIDER } from '@/modules/mail/mail.provider';
|
||||
import {
|
||||
AccountLoginDisabledException,
|
||||
MAIL_PROVIDER,
|
||||
PasswordExpiredException,
|
||||
} from '@/modules/mail/mail.provider';
|
||||
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
|
||||
import type {
|
||||
BlockedSender,
|
||||
@@ -117,10 +121,21 @@ import {
|
||||
SIGNATURE_IMAGE_MAX_BYTES,
|
||||
} from '@/modules/mail-signature/mail-signature.constants';
|
||||
import { MailSignatureRepository } from '@/modules/mail-signature/mail-signature.repository';
|
||||
import {
|
||||
SystemSettingsService,
|
||||
type SystemSettingsPatch,
|
||||
} from '@/modules/system-settings/system-settings.service';
|
||||
import { UPLOAD_DIR } from '@/modules/upload/upload.config';
|
||||
import { createCidrMatcher, parseCidr } from '@/shared/utils/cidr';
|
||||
import { buildAttachmentDisposition } from '@/shared/utils/content-disposition';
|
||||
import { totpCode, totpCounterAt, verifyTotp } from '@/shared/utils/totp';
|
||||
import { kstDateString, kstStartOfDate } from '@/shared/utils/kst-date';
|
||||
import {
|
||||
buildOtpauthUrl,
|
||||
encodeBase32,
|
||||
totpCode,
|
||||
totpCounterAt,
|
||||
verifyTotp,
|
||||
} from '@/shared/utils/totp';
|
||||
import { pngBytes } from './image-fixtures';
|
||||
|
||||
/**
|
||||
@@ -172,6 +187,8 @@ function cookieValue(cookie: string | undefined): string {
|
||||
}
|
||||
|
||||
const EMPLOYEE = { email: 'ara.kim@e2e.arasystem.test', name: '김아라' };
|
||||
/** 13단계 — 로그인(signedIn) · me · 갱신 응답은 세션의 제한 · 2단계 필수 유예를 늘 싣는다 (제한 없음 · 유예 아님) */
|
||||
const UNRESTRICTED = { restriction: null, twoFactorDeadline: null };
|
||||
|
||||
/** 대역 메일 엔진이 받아 주는 비밀번호 (그 밖의 비밀번호는 틀린 것으로 본다) */
|
||||
const CORRECT_PASSWORD = 'correct-password-for-e2e';
|
||||
@@ -344,6 +361,11 @@ const GATE_DEADLINE_MS = 10_000;
|
||||
* test/jest-e2e.json 에 testTimeout 이 없어 jest 기본값(5초)이라, 이 테스트만 따로 늘린다.
|
||||
*/
|
||||
const GATED_TEST_TIMEOUT_MS = 20_000;
|
||||
/**
|
||||
* 앱을 띄우는 준비(맨 바깥 beforeAll)의 시간 제한 — DB 연결 · 마이그레이션, 그리고 TypeORM 이 그때 읽는 엔티티 · 마이그레이션 파일의 ts-jest 변환이 들어 있다.
|
||||
* 변환 캐시가 빈 실행을 바인드 마운트 너머(개발 컨테이너)에서 돌리면 jest 기본값(5초)을 넘어 모든 테스트가 실패했다 (2026-11-02, 13단계)
|
||||
*/
|
||||
const APP_SETUP_TIMEOUT_MS = 60_000;
|
||||
|
||||
/** 열 때까지 기다리게 하는 문 — 한 번 열면 기다리던 쪽이 모두 풀린다 */
|
||||
function createGate(): { readonly opened: Promise<void>; open: () => void } {
|
||||
@@ -642,6 +664,10 @@ describe('응답 계약 (e2e)', () => {
|
||||
|
||||
/** 12단계 — 대역 엔진의 2단계 인증(직원 → otpauth 주소) · 앱 비밀번호(직원마다) */
|
||||
const twoFactorUrls = new Map<string, string>();
|
||||
/** 13단계 — 대역 엔진에서 비밀번호가 만료된 직원 (메일 기능이 막히고, 본인이 바꾸면 엔진이 만료를 푼다) */
|
||||
const expiredPasswords = new Set<string>();
|
||||
/** 13단계 — 대역 엔진에서 잠긴 직원 (로그인 권한을 뺐다 — 대리 접근 403, 게이트웨이는 정상) */
|
||||
const lockedAccounts = new Set<string>();
|
||||
const appPasswordsByUser = new Map<string, AppPasswordRecord[]>();
|
||||
let appPasswordSequence = 0;
|
||||
/** 엔진 기본 상한 (x:Authentication.maxAppPasswords) */
|
||||
@@ -906,12 +932,22 @@ describe('응답 계약 (e2e)', () => {
|
||||
})
|
||||
.overrideProvider(MAIL_PROVIDER)
|
||||
.useValue({
|
||||
// 리프레시는 계정이 살아 있는지 메일 엔진에 다시 묻는다
|
||||
// 리프레시는 계정이 살아 있는지 메일 엔진에 다시 묻는다.
|
||||
// 13단계 — 비밀번호가 만료된 계정은 어댑터가 PasswordExpiredException 으로 알린다(메일 메서드가 forbidden)
|
||||
getProfile: (email: string) =>
|
||||
Promise.resolve({
|
||||
email,
|
||||
name: PROFILE_NAMES.get(email) ?? EMPLOYEE.name,
|
||||
}),
|
||||
lockedAccounts.has(email)
|
||||
? Promise.reject(new AccountLoginDisabledException(email))
|
||||
: expiredPasswords.has(email)
|
||||
? Promise.reject(
|
||||
new PasswordExpiredException(
|
||||
email,
|
||||
PROFILE_NAMES.get(email) ?? EMPLOYEE.name,
|
||||
),
|
||||
)
|
||||
: Promise.resolve({
|
||||
email,
|
||||
name: PROFILE_NAMES.get(email) ?? EMPLOYEE.name,
|
||||
}),
|
||||
verifyCredentials: async (
|
||||
email: string,
|
||||
password: string,
|
||||
@@ -1147,7 +1183,10 @@ describe('응답 계약 (e2e)', () => {
|
||||
[ENGINE_REFUSED_PASSWORD, 'current-password-refused'],
|
||||
[ENGINE_UNKNOWN_PASSWORD, 'outcome-unknown'],
|
||||
]);
|
||||
return Promise.resolve(outcomes.get(newPassword) ?? 'changed');
|
||||
const outcome = outcomes.get(newPassword) ?? 'changed';
|
||||
// 13단계 — 엔진은 본인이 비밀번호를 바꾸면 만료를 스스로 푼다
|
||||
if (outcome === 'changed') expiredPasswords.delete(email);
|
||||
return Promise.resolve(outcome);
|
||||
},
|
||||
createMailbox: (
|
||||
user: string,
|
||||
@@ -1536,7 +1575,7 @@ describe('응답 계약 (e2e)', () => {
|
||||
server = app.getHttpServer();
|
||||
jwt = app.get(JwtService, { strict: false });
|
||||
authSettings = app.get<AuthSettings>(AUTH_SETTINGS, { strict: false });
|
||||
});
|
||||
}, APP_SETUP_TIMEOUT_MS);
|
||||
|
||||
afterAll(async () => {
|
||||
await dataSource.query(
|
||||
@@ -1852,7 +1891,10 @@ describe('응답 계약 (e2e)', () => {
|
||||
|
||||
const res = await refreshWith(refreshToken).expect(200);
|
||||
|
||||
expect(body(res)).toEqual({ success: true, data: EMPLOYEE });
|
||||
expect(body(res)).toEqual({
|
||||
success: true,
|
||||
data: { ...EMPLOYEE, ...UNRESTRICTED },
|
||||
});
|
||||
// 토큰은 본문에 없다
|
||||
expect(JSON.stringify(body(res))).not.toContain('eyJ');
|
||||
|
||||
@@ -1872,7 +1914,7 @@ describe('응답 계약 (e2e)', () => {
|
||||
.get('/api/auth/me')
|
||||
.set('Cookie', `access_token=${cookieValue(access)}`)
|
||||
.expect(200);
|
||||
expect(body(me).data).toEqual(EMPLOYEE);
|
||||
expect(body(me).data).toEqual({ ...EMPLOYEE, ...UNRESTRICTED });
|
||||
});
|
||||
|
||||
it('만료된 액세스 쿠키가 함께 와도 /auth/refresh 는 리프레시 쿠키로 동작한다', async () => {
|
||||
@@ -1917,7 +1959,7 @@ describe('응답 계약 (e2e)', () => {
|
||||
const second = rotated(await refreshWith(first).expect(200));
|
||||
|
||||
const late = await refreshWith(first).expect(200);
|
||||
expect(body(late).data).toEqual(EMPLOYEE);
|
||||
expect(body(late).data).toEqual({ ...EMPLOYEE, ...UNRESTRICTED });
|
||||
expect(late.headers['set-cookie']).toBeUndefined();
|
||||
|
||||
// 이긴 쪽의 새 토큰은 그대로 쓸 수 있다
|
||||
@@ -6830,6 +6872,8 @@ describe('응답 계약 (e2e)', () => {
|
||||
expect(body(before).data).toEqual({
|
||||
isEnabled: false,
|
||||
enabledAt: null,
|
||||
isRequired: false,
|
||||
deadline: null,
|
||||
});
|
||||
|
||||
const enrolled = await asMe(
|
||||
@@ -6875,6 +6919,8 @@ describe('응답 계약 (e2e)', () => {
|
||||
expect(body(activated).data).toEqual({
|
||||
isEnabled: true,
|
||||
enabledAt: expect.any(String) as unknown,
|
||||
isRequired: false,
|
||||
deadline: null,
|
||||
});
|
||||
expect(twoFactorUrls.get(ME)).toBe(enrollment.otpauthUrl);
|
||||
|
||||
@@ -6884,6 +6930,7 @@ describe('응답 계약 (e2e)', () => {
|
||||
status: 'twoFactorRequired',
|
||||
email: null,
|
||||
name: null,
|
||||
...UNRESTRICTED,
|
||||
});
|
||||
expect(first.headers['set-cookie']).toBeUndefined();
|
||||
|
||||
@@ -6902,6 +6949,7 @@ describe('응답 계약 (e2e)', () => {
|
||||
status: 'signedIn',
|
||||
email: ME,
|
||||
name: EMPLOYEE.name,
|
||||
...UNRESTRICTED,
|
||||
});
|
||||
expect(cookieValue(setCookie(second, 'access_token'))).not.toBe('');
|
||||
|
||||
@@ -6928,7 +6976,12 @@ describe('응답 계약 (e2e)', () => {
|
||||
otpCode: otpCodeOf(secret, -1),
|
||||
})
|
||||
.expect(200);
|
||||
expect(body(off).data).toEqual({ isEnabled: false, enabledAt: null });
|
||||
expect(body(off).data).toEqual({
|
||||
isEnabled: false,
|
||||
enabledAt: null,
|
||||
isRequired: false,
|
||||
deadline: null,
|
||||
});
|
||||
expect(twoFactorUrls.has(ME)).toBe(false);
|
||||
|
||||
// 다시 비밀번호만으로 로그인한다
|
||||
@@ -7473,6 +7526,367 @@ describe('응답 계약 (e2e)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('13단계 — 제한된 세션 · 2단계 인증 필수 · 문의 연락처', () => {
|
||||
let systemSettings: SystemSettingsService;
|
||||
/** 시작 전 설정 — 끝나면 되돌린다(컨테이너 안에서 돌리면 개발 DB 다). 준비가 실패했으면 없다 */
|
||||
let original: SystemSettingsPatch | undefined;
|
||||
/** 처음 값 — 시험마다 이것에서 시작한다 */
|
||||
const DEFAULTS: SystemSettingsPatch = {
|
||||
twoFactor: { required: false, startDate: null, exempt: [] },
|
||||
supportContact: null,
|
||||
};
|
||||
|
||||
const loginAs = (email: string, from: string) =>
|
||||
request(server)
|
||||
.post('/api/auth/login')
|
||||
.set('X-Forwarded-For', from)
|
||||
.send({ email, password: CORRECT_PASSWORD });
|
||||
const cookiesOf = (res: Response) => [
|
||||
`access_token=${cookieValue(setCookie(res, 'access_token'))}`,
|
||||
`refresh_token=${cookieValue(setCookie(res, 'refresh_token'))}`,
|
||||
];
|
||||
const settle = (patch: SystemSettingsPatch) =>
|
||||
systemSettings.update(patch, new Date());
|
||||
|
||||
beforeAll(async () => {
|
||||
systemSettings = app.get(SystemSettingsService, { strict: false });
|
||||
const current = await systemSettings.reload();
|
||||
original = {
|
||||
twoFactor: { ...current.twoFactor },
|
||||
supportContact: current.supportContact,
|
||||
};
|
||||
});
|
||||
beforeEach(() => settle(DEFAULTS));
|
||||
afterAll(async () => {
|
||||
if (original) await settle(original);
|
||||
});
|
||||
|
||||
it('비밀번호가 만료된 직원 — 쿠키는 받되 passwordChange, 허용한 경로만 부르고 그 밖은 403 AUTH_010 → 바꾸면 풀린다', async () => {
|
||||
const ME = 'expired.me@e2e.arasystem.test';
|
||||
expiredPasswords.add(ME);
|
||||
try {
|
||||
const res = await loginAs(ME, '198.18.13.1').expect(200);
|
||||
expect(body(res).data).toEqual({
|
||||
status: 'signedIn',
|
||||
email: ME,
|
||||
name: EMPLOYEE.name,
|
||||
restriction: 'passwordChange',
|
||||
twoFactorDeadline: null,
|
||||
});
|
||||
const cookies = cookiesOf(res);
|
||||
const as = (test: HttpTest) => test.set('Cookie', cookies);
|
||||
|
||||
const me = await as(request(server).get('/api/auth/me')).expect(200);
|
||||
expect(body(me).data).toEqual({
|
||||
...EMPLOYEE,
|
||||
email: ME,
|
||||
restriction: 'passwordChange',
|
||||
twoFactorDeadline: null,
|
||||
});
|
||||
await as(request(server).get('/api/auth/sessions')).expect(200);
|
||||
await as(request(server).get('/api/two-factor')).expect(200);
|
||||
await as(request(server).get('/api/support-contact')).expect(200);
|
||||
for (const path of [
|
||||
'/api/mailboxes',
|
||||
'/api/mail-state',
|
||||
'/api/mail-events',
|
||||
'/api/contacts',
|
||||
'/api/app-passwords',
|
||||
]) {
|
||||
const blocked = await as(request(server).get(path)).expect(403);
|
||||
expect(body(blocked).error?.code).toBe('AUTH_010');
|
||||
}
|
||||
// 갱신도 엔진에 다시 묻는다 — 아직 만료면 그대로
|
||||
const still = await as(
|
||||
request(server).post('/api/auth/refresh'),
|
||||
).expect(200);
|
||||
expect(body(still).data).toMatchObject({
|
||||
restriction: 'passwordChange',
|
||||
});
|
||||
|
||||
// 새 비밀번호 — 엔진이 만료를 풀고 서버가 세션의 제한을 지운다
|
||||
const fresh = cookiesOf(still);
|
||||
await request(server)
|
||||
.put('/api/auth/password')
|
||||
.set('X-Forwarded-For', '198.18.13.1')
|
||||
.set('Cookie', fresh)
|
||||
.send({
|
||||
currentPassword: CORRECT_PASSWORD,
|
||||
newPassword: 'new-password-for-e2e-13',
|
||||
})
|
||||
.expect(200);
|
||||
// 제한을 푼 뒤에는 옛 액세스 토큰(rst 클레임)으로도 세션 표의 지금 값 — 마친 단계가 다시 보이지 않는다
|
||||
const after = await request(server)
|
||||
.get('/api/auth/me')
|
||||
.set('Cookie', fresh)
|
||||
.expect(200);
|
||||
expect(body(after).data).toMatchObject(UNRESTRICTED);
|
||||
await request(server)
|
||||
.get('/api/mailboxes')
|
||||
.set('Cookie', fresh)
|
||||
.expect(200);
|
||||
const renewed = await request(server)
|
||||
.post('/api/auth/refresh')
|
||||
.set('Cookie', fresh)
|
||||
.expect(200);
|
||||
expect(body(renewed).data).toEqual({
|
||||
...EMPLOYEE,
|
||||
email: ME,
|
||||
...UNRESTRICTED,
|
||||
});
|
||||
} finally {
|
||||
expiredPasswords.delete(ME);
|
||||
}
|
||||
});
|
||||
|
||||
it('2단계 인증 필수 — 시작일부터 켜지 않은 직원은 twoFactorSetup · 끄기는 400 VAL_001, 유예 중에는 시작 시각만, 예외 주소는 그대로', async () => {
|
||||
const ME = 'policy.me@e2e.arasystem.test';
|
||||
const today = kstDateString(new Date());
|
||||
await settle({ twoFactor: { required: true, startDate: today } });
|
||||
|
||||
const res = await loginAs(ME, '198.18.13.2').expect(200);
|
||||
expect(body(res).data).toMatchObject({
|
||||
restriction: 'twoFactorSetup',
|
||||
twoFactorDeadline: null,
|
||||
});
|
||||
const cookies = cookiesOf(res);
|
||||
const status = await request(server)
|
||||
.get('/api/two-factor')
|
||||
.set('Cookie', cookies)
|
||||
.expect(200);
|
||||
expect(body(status).data).toEqual({
|
||||
isEnabled: false,
|
||||
enabledAt: null,
|
||||
isRequired: true,
|
||||
deadline: null,
|
||||
});
|
||||
const blocked = await request(server)
|
||||
.get('/api/mailboxes')
|
||||
.set('Cookie', cookies)
|
||||
.expect(403);
|
||||
expect(body(blocked).error?.code).toBe('AUTH_010');
|
||||
|
||||
// 유예 — 시작일이 내일이면 제한 없이 시작 시각(내일 0시 KST)만
|
||||
const tomorrow = kstDateString(new Date(Date.now() + 24 * 3_600_000));
|
||||
await settle({ twoFactor: { startDate: tomorrow } });
|
||||
const grace = await loginAs(ME, '198.18.13.3').expect(200);
|
||||
expect(body(grace).data).toMatchObject({
|
||||
restriction: null,
|
||||
twoFactorDeadline: kstStartOfDate(tomorrow)?.toISOString(),
|
||||
});
|
||||
const graceStatus = await request(server)
|
||||
.get('/api/two-factor')
|
||||
.set('Cookie', cookiesOf(grace))
|
||||
.expect(200);
|
||||
expect(body(graceStatus).data).toMatchObject({
|
||||
isRequired: true,
|
||||
deadline: kstStartOfDate(tomorrow)?.toISOString(),
|
||||
});
|
||||
|
||||
// 예외 주소는 정책을 따르지 않는다
|
||||
await settle({ twoFactor: { startDate: today, exempt: [ME] } });
|
||||
const exempt = await loginAs(ME, '198.18.13.4').expect(200);
|
||||
expect(body(exempt).data).toMatchObject(UNRESTRICTED);
|
||||
});
|
||||
|
||||
it('만료 계정 + 2단계 필수 시작 — 비밀번호를 바꾸면 그 세션은 twoFactorSetup 으로 바뀌어 옛 토큰으로도 다음 단계 밖은 막힌다 (리뷰 M2)', async () => {
|
||||
const ME = 'expired.policy@e2e.arasystem.test';
|
||||
expiredPasswords.add(ME);
|
||||
try {
|
||||
await settle({
|
||||
twoFactor: { required: true, startDate: kstDateString(new Date()) },
|
||||
});
|
||||
const res = await loginAs(ME, '198.18.13.6').expect(200);
|
||||
// 비밀번호 만료가 먼저다
|
||||
expect(body(res).data).toMatchObject({ restriction: 'passwordChange' });
|
||||
const cookies = cookiesOf(res);
|
||||
|
||||
await request(server)
|
||||
.put('/api/auth/password')
|
||||
.set('X-Forwarded-For', '198.18.13.6')
|
||||
.set('Cookie', cookies)
|
||||
.send({
|
||||
currentPassword: CORRECT_PASSWORD,
|
||||
newPassword: 'new-password-for-e2e-13m2',
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
// 옛 토큰(rst=passwordChange) — 가드는 세션 표의 지금 값(twoFactorSetup)을 본다
|
||||
const blocked = await request(server)
|
||||
.get('/api/mailboxes')
|
||||
.set('Cookie', cookies)
|
||||
.expect(403);
|
||||
expect(body(blocked).error?.code).toBe('AUTH_010');
|
||||
const me = await request(server)
|
||||
.get('/api/auth/me')
|
||||
.set('Cookie', cookies)
|
||||
.expect(200);
|
||||
expect(body(me).data).toMatchObject({ restriction: 'twoFactorSetup' });
|
||||
// 켜기 단계의 경로는 연다
|
||||
await request(server)
|
||||
.get('/api/two-factor')
|
||||
.set('Cookie', cookies)
|
||||
.expect(200);
|
||||
} finally {
|
||||
expiredPasswords.delete(ME);
|
||||
}
|
||||
});
|
||||
|
||||
it('2단계를 켜면 요청 세션의 제한만 풀리고, twoFactorSetup 이 붙은 다른 세션은 끊긴다 (리뷰 M1)', async () => {
|
||||
const ME = 'policy.two@e2e.arasystem.test';
|
||||
await settle({
|
||||
twoFactor: { required: true, startDate: kstDateString(new Date()) },
|
||||
});
|
||||
// 같은 직원의 두 세션 — 둘 다 2단계를 거치지 않았다(비밀번호만 아는 다른 사람의 세션일 수 있다)
|
||||
const mine = cookiesOf(await loginAs(ME, '198.18.13.7').expect(200));
|
||||
const other = cookiesOf(await loginAs(ME, '198.18.13.8').expect(200));
|
||||
try {
|
||||
const enrolled = await request(server)
|
||||
.post('/api/two-factor/enrollments')
|
||||
.set('Cookie', mine)
|
||||
.expect(201);
|
||||
const { enrollmentId, secret } = dataOf<{
|
||||
enrollmentId: string;
|
||||
secret: string;
|
||||
}>(enrolled);
|
||||
const activated = await request(server)
|
||||
.post('/api/two-factor/activation')
|
||||
.set('Cookie', mine)
|
||||
.send({
|
||||
enrollmentId,
|
||||
otpCode: otpCodeOf(decodeBase32(secret)),
|
||||
currentPassword: CORRECT_PASSWORD,
|
||||
})
|
||||
.expect(200);
|
||||
expect(body(activated).data).toMatchObject({
|
||||
isEnabled: true,
|
||||
isRequired: true,
|
||||
deadline: null,
|
||||
});
|
||||
|
||||
// 켠 세션 — 옛 토큰으로도 제한이 풀렸다
|
||||
const me = await request(server)
|
||||
.get('/api/auth/me')
|
||||
.set('Cookie', mine)
|
||||
.expect(200);
|
||||
expect(body(me).data).toMatchObject(UNRESTRICTED);
|
||||
await request(server)
|
||||
.get('/api/mailboxes')
|
||||
.set('Cookie', mine)
|
||||
.expect(200);
|
||||
|
||||
// 다른 세션 — 2단계 없이 열리지 않고 끊겼다
|
||||
const cut = await request(server)
|
||||
.get('/api/mailboxes')
|
||||
.set('Cookie', other)
|
||||
.expect(401);
|
||||
expect(body(cut).error?.code).toBe('AUTH_001');
|
||||
await request(server)
|
||||
.post('/api/auth/refresh')
|
||||
.set('Cookie', other)
|
||||
.expect(401);
|
||||
} finally {
|
||||
twoFactorUrls.delete(ME);
|
||||
}
|
||||
});
|
||||
|
||||
it('잠긴 계정(2단계 켜짐 — 엔진이 2단계를 먼저 봐 402)의 맞는 비밀번호도 틀린 비밀번호와 같은 401 AUTH_004 (실사용 시험)', async () => {
|
||||
const ME = 'locked.otp@e2e.arasystem.test';
|
||||
const secret = randomBytes(20);
|
||||
twoFactorUrls.set(
|
||||
ME,
|
||||
buildOtpauthUrl({
|
||||
issuer: 'ARA SYSTEM',
|
||||
account: ME,
|
||||
secret: encodeBase32(secret),
|
||||
}),
|
||||
);
|
||||
lockedAccounts.add(ME);
|
||||
try {
|
||||
const right = await loginAs(ME, '198.18.13.9').expect(401);
|
||||
const withCode = await request(server)
|
||||
.post('/api/auth/login')
|
||||
.set('X-Forwarded-For', '198.18.13.10')
|
||||
.send({
|
||||
email: ME,
|
||||
password: CORRECT_PASSWORD,
|
||||
otpCode: otpCodeOf(secret),
|
||||
})
|
||||
.expect(401);
|
||||
const wrong = await request(server)
|
||||
.post('/api/auth/login')
|
||||
.set('X-Forwarded-For', '198.18.13.11')
|
||||
.send({ email: ME, password: 'wrong-password-for-e2e' })
|
||||
.expect(401);
|
||||
// 셋 다 같은 코드 · 문구 — 맞는 비밀번호인지 바깥에서 가를 수 없다
|
||||
for (const res of [right, withCode, wrong]) {
|
||||
expect(body(res).error?.code).toBe('AUTH_004');
|
||||
expect(body(res).error?.message).toBe(body(wrong).error?.message);
|
||||
expect(res.headers['set-cookie']).toBeUndefined();
|
||||
}
|
||||
} finally {
|
||||
lockedAccounts.delete(ME);
|
||||
twoFactorUrls.delete(ME);
|
||||
}
|
||||
});
|
||||
|
||||
it('필수인 직원은 2단계 인증을 끌 수 없다 — 400 VAL_001 (엔진에 묻지 않는다)', async () => {
|
||||
const ME = 'policy.on@e2e.arasystem.test';
|
||||
// 켠 직원으로 만든다 — 대역 엔진에 바로 넣고 비밀번호 + 코드로 로그인
|
||||
const secret = randomBytes(20);
|
||||
twoFactorUrls.set(
|
||||
ME,
|
||||
buildOtpauthUrl({
|
||||
issuer: 'ARA SYSTEM',
|
||||
account: ME,
|
||||
secret: encodeBase32(secret),
|
||||
}),
|
||||
);
|
||||
try {
|
||||
await settle({
|
||||
twoFactor: { required: true, startDate: kstDateString(new Date()) },
|
||||
});
|
||||
const res = await request(server)
|
||||
.post('/api/auth/login')
|
||||
.set('X-Forwarded-For', '198.18.13.5')
|
||||
.send({
|
||||
email: ME,
|
||||
password: CORRECT_PASSWORD,
|
||||
otpCode: otpCodeOf(secret),
|
||||
})
|
||||
.expect(200);
|
||||
// 켠 직원은 제한 · 유예가 없다
|
||||
expect(body(res).data).toMatchObject({
|
||||
status: 'signedIn',
|
||||
...UNRESTRICTED,
|
||||
});
|
||||
const off = await request(server)
|
||||
.post('/api/two-factor/deactivation')
|
||||
.set('Cookie', cookiesOf(res))
|
||||
.send({
|
||||
currentPassword: CORRECT_PASSWORD,
|
||||
otpCode: otpCodeOf(secret, 1),
|
||||
})
|
||||
.expect(400);
|
||||
expect(body(off).error?.code).toBe('VAL_001');
|
||||
expect(twoFactorUrls.has(ME)).toBe(true);
|
||||
} finally {
|
||||
twoFactorUrls.delete(ME);
|
||||
}
|
||||
});
|
||||
|
||||
it('문의 연락처 — 로그인 없이 { text }, 정하지 않았으면 null', async () => {
|
||||
const none = await request(server)
|
||||
.get('/api/support-contact')
|
||||
.expect(200);
|
||||
expect(body(none).data).toEqual({ text: null });
|
||||
|
||||
await settle({ supportContact: 'IT 담당 홍길동 · 02-000-0000' });
|
||||
const set = await request(server).get('/api/support-contact').expect(200);
|
||||
expect(body(set).data).toEqual({ text: 'IT 담당 홍길동 · 02-000-0000' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('출처 검사 (상태 변경 요청)', () => {
|
||||
const EVIL_ORIGIN = 'https://evil.example';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user