feat(backend): 12단계 계정 보안 — 2단계 로그인 · 2FA · 앱 비밀번호 · 세션 끊기 · 로그인 기록 · 엔진 웹훅

엔진(Stalwart 0.16.27)의 TOTP 와 앱 비밀번호를 쓰되, 엔진이 하지 않는 것을 서버가 맡는다.
- 등록 때 첫 코드 확인, 성공에 쓴 코드 2분 막기(확인 중인 같은 코드도), 코드 실패 한도(15분 5 · 24시간 20)
- 웹메일 로그인 · 비밀번호 바꾸기에서 앱 비밀번호 모양 거절, 앱 비밀번호는 메일 프로그램 권한만
- 엔진 Basic 의 402 를 장애(MAIL_001)로 다루던 것을 고쳐 코드 단계로 — 402 면 정식 주소로 한도 · 기록
- 비밀번호를 바꾸면 앱 비밀번호도 지우고, 끊은 세션은 액세스 토큰의 sid 로 바로 막는다
- 메일 프로그램 로그인은 엔진 웹훅(소켓 주소 · HMAC · 내용 지문 · 1,000행씩)으로 받아 90일 남긴다
- 요청 대상에 날 \ · # 가 있으면 라우팅 전에 400 — Express 가 이를 다시 읽어 앞단이 닫은 경로가 열렸다
에러 코드 AUTH_006 ~ AUTH_009 와 웹 · 앱 사전, 마이그레이션 AddAccountSecurity 를 함께 넣었다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 17:17:27 +09:00
parent adb992e6d8
commit f9e3b1c57a
96 changed files with 11588 additions and 210 deletions
+894 -5
View File
@@ -1,9 +1,10 @@
import type { NestExpressApplication } from '@nestjs/platform-express';
import { JwtService } from '@nestjs/jwt';
import { Test, TestingModule } from '@nestjs/testing';
import { createHash, randomBytes, randomUUID } from 'crypto';
import { createHash, createHmac, randomBytes, randomUUID } from 'crypto';
import { existsSync, mkdtempSync, rmSync, unlinkSync } from 'fs';
import { get as httpGet, type IncomingMessage, type Server } from 'http';
import { connect } from 'net';
import { tmpdir } from 'os';
import { basename, join } from 'path';
import { Readable } from 'stream';
@@ -15,8 +16,20 @@ import { DataSource } from 'typeorm';
import { AppModule } from '@/app.module';
import { configureApp, createHttpAdapter } from '@/app.setup';
import { BusinessException } from '@/common/errors/business.exception';
import {
ACCOUNT_SECURITY_PROVIDER,
type AppPasswordRecord,
type CreatedAppPassword,
type TwoFactorDisableResult,
type TwoFactorEnableResult,
} from '@/modules/account-security/account-security.provider';
import {
ACCOUNT_SECURITY_SETTINGS,
type AccountSecuritySettings,
} from '@/modules/account-security/account-security.settings';
import { AUTH_SETTINGS, type AuthSettings } from '@/modules/auth/auth.settings';
import { MAX_CONTACTS } from '@/modules/contacts/contacts.constants';
import { DirectoryService } from '@/modules/contacts/directory.service';
import {
CONTACTS_PROVIDER,
DIRECTORY_PROVIDER,
@@ -90,6 +103,7 @@ import type {
MessageSummary,
MessageUpdate,
OutgoingMessage,
CredentialCheck,
PasswordChangeOutcome,
PendingSend,
RecipientRejectionReason,
@@ -104,7 +118,9 @@ import {
} from '@/modules/mail-signature/mail-signature.constants';
import { MailSignatureRepository } from '@/modules/mail-signature/mail-signature.repository';
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 { pngBytes } from './image-fixtures';
/**
@@ -279,6 +295,46 @@ const PROFILE_NAMES = new Map([
['colleague.b@e2e.arasystem.test', '박서준'],
]);
/**
* 12단계 — 엔진 웹훅 서명 키(영문 · 숫자 32자 이상)와 e2e 의 접속 주소(supertest 는 127.0.0.1 로 붙는다)를 받는 앱 네트워크 대역.
* 테스트가 대역 밖으로 바꿔 볼 수 있게 열어 둔다 (e2eAppNetworkOpen)
*/
const E2E_WEBHOOK_SECRET = 'E2eWebhookSecret0000000000000000000';
let e2eAppNetworkOpen = true;
const e2eLoopback = createCidrMatcher(parseCidr('127.0.0.0/8')!);
const TEST_ACCOUNT_SECURITY_SETTINGS: AccountSecuritySettings = {
webhookSecret: E2E_WEBHOOK_SECRET,
appNetwork: parseCidr('127.0.0.0/8')!,
isAppNetworkAddress: (address) => e2eAppNetworkOpen && e2eLoopback(address),
};
/** base32(RFC 4648) → 바이트 — 등록 응답의 비밀로 인증 앱 코드를 만든다 */
function decodeBase32(text: string): Buffer {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let bits = 0;
let value = 0;
const bytes: number[] = [];
for (const char of text) {
value = ((value << 5) | alphabet.indexOf(char)) & 0xffff;
bits += 5;
if (bits >= 8) {
bytes.push((value >>> (bits - 8)) & 0xff);
bits -= 8;
}
}
return Buffer.from(bytes);
}
/** otpauth 주소의 비밀(바이트) */
function secretOfOtpauth(url: string): Buffer {
return decodeBase32(new URL(url).searchParams.get('secret') ?? '');
}
/** 비밀로 만든 인증 앱 코드 — offset 단계만큼 앞 · 뒤 (앞뒤 한 단계는 받는다) */
function otpCodeOf(secret: Buffer, offset = 0): string {
return totpCode(secret, totpCounterAt(Date.now()) + offset);
}
/** 문을 열 조건을 다시 보는 간격 */
const GATE_POLL_MS = 10;
/** 문을 열 조건을 기다리는 최대 시간 — 회귀가 생기면 멈춰 있지 않고 이 시간 뒤에 분명히 실패한다 */
@@ -584,6 +640,87 @@ describe('응답 계약 (e2e)', () => {
},
};
/** 12단계 — 대역 엔진의 2단계 인증(직원 → otpauth 주소) · 앱 비밀번호(직원마다) */
const twoFactorUrls = new Map<string, string>();
const appPasswordsByUser = new Map<string, AppPasswordRecord[]>();
let appPasswordSequence = 0;
/** 엔진 기본 상한 (x:Authentication.maxAppPasswords) */
const E2E_MAX_APP_PASSWORDS = 5;
const fakeAccountSecurityProvider = {
isTwoFactorEnabled: (user: string) =>
Promise.resolve(twoFactorUrls.has(user)),
enableTwoFactor: (
user: string,
currentPassword: string,
otpauthUrl: string,
): Promise<TwoFactorEnableResult> => {
// 엔진처럼 코드를 보지 않고 저장한다 — 이미 켜져 있으면(지금 코드를 요구) 거절
if (currentPassword !== CORRECT_PASSWORD || twoFactorUrls.has(user)) {
return Promise.resolve('refused');
}
twoFactorUrls.set(user, otpauthUrl);
return Promise.resolve('enabled');
},
disableTwoFactor: (
user: string,
currentPassword: string,
otpCode: string,
): Promise<TwoFactorDisableResult> => {
const url = twoFactorUrls.get(user);
if (
currentPassword !== CORRECT_PASSWORD ||
url === undefined ||
verifyTotp(secretOfOtpauth(url), otpCode, Date.now()) === null
) {
return Promise.resolve('code-rejected');
}
twoFactorUrls.delete(user);
return Promise.resolve('disabled');
},
listAppPasswords: (user: string) =>
Promise.resolve([...(appPasswordsByUser.get(user) ?? [])]),
createAppPassword: (
user: string,
name: string,
): Promise<CreatedAppPassword> => {
const list = appPasswordsByUser.get(user) ?? [];
if (list.length >= E2E_MAX_APP_PASSWORDS) {
return Promise.reject(
new BusinessException('AUTH_009', {
context: { reason: 'e2e-app-password-limit' },
}),
);
}
appPasswordSequence += 1;
const record = {
id: String.fromCharCode(97 + (appPasswordSequence % 26)) + 'e2e',
name,
createdAt: new Date(
Date.UTC(2026, 9, 31, 0, 0, appPasswordSequence),
).toISOString(),
};
appPasswordsByUser.set(user, [...list, record]);
return Promise.resolve({
...record,
password: `app_${'abcdefghijklmnopqrstuvwxyz0123791203'}`,
});
},
deleteAppPassword: (user: string, id: string) => {
appPasswordsByUser.set(
user,
(appPasswordsByUser.get(user) ?? []).filter(
(record) => record.id !== id,
),
);
return Promise.resolve();
},
deleteAllAppPasswords: (user: string) => {
const count = (appPasswordsByUser.get(user) ?? []).length;
appPasswordsByUser.set(user, []);
return Promise.resolve(count);
},
};
/** 5단계 — 대역 엔진에 열린 실시간 알림 연결 (직원마다 하나여야 한다) */
const liveWatches: {
user: string;
@@ -775,11 +912,33 @@ describe('응답 계약 (e2e)', () => {
email,
name: PROFILE_NAMES.get(email) ?? EMPLOYEE.name,
}),
verifyCredentials: async (email: string, password: string) => {
verifyCredentials: async (
email: string,
password: string,
): Promise<CredentialCheck> => {
engineChecks.set(email, (engineChecks.get(email) ?? 0) + 1);
// 테스트가 문을 열 때까지 답하지 않는다 — 그동안 같은 주소의 다른 시도가 어떻게 되는지 본다
if (email === SLOW_ACCOUNT && slowEngineGate) await slowEngineGate;
return password === CORRECT_PASSWORD ? email : null;
if (password !== CORRECT_PASSWORD) return { kind: 'invalid' };
// 12단계 — 2단계 인증을 켠 직원은 비밀번호가 맞아도 402 (엔진이 코드를 요구한다)
return twoFactorUrls.has(email)
? { kind: 'two-factor-required' }
: { kind: 'valid', account: email };
},
// 12단계 — 엔진의 로그인 요청(비밀번호 + 코드). 엔진처럼 같은 코드를 다시 받는다(막기는 서버 몫)
verifyTwoFactorLogin: (
email: string,
password: string,
otpCode: string,
) => {
const url = twoFactorUrls.get(email);
return Promise.resolve(
password === CORRECT_PASSWORD &&
url !== undefined &&
verifyTotp(secretOfOtpauth(url), otpCode, Date.now()) !== null
? ('authenticated' as const)
: ('code-rejected' as const),
);
},
// 발송은 받은 것을 기록만 한다 — 중복 방지는 "처음 보내는 키"로 본다.
// 받는 사람별 결과는 ENGINE_REJECTIONS 로 흉내 낸다 — 모두 받지 않으면 어댑터처럼 MAIL_010(5xx 가 섞임) · MAIL_011(모두 4xx)
@@ -964,7 +1123,21 @@ describe('응답 계약 (e2e)', () => {
email: string,
currentPassword: string,
newPassword: string,
options: { readonly otpCode?: string } = {},
): Promise<PasswordChangeOutcome> => {
// 12단계 — 2단계 인증을 켠 직원은 코드가 없으면 바꾸지 않고(402), 코드가 틀리면 엔진이 거절한다
const url = twoFactorUrls.get(email);
if (url !== undefined) {
if (options.otpCode === undefined) {
return Promise.resolve('two-factor-required');
}
if (
verifyTotp(secretOfOtpauth(url), options.otpCode, Date.now()) ===
null
) {
return Promise.resolve('code-rejected');
}
}
passwordChanges.push({ email, newPassword });
if (currentPassword !== CORRECT_PASSWORD) {
return Promise.resolve('current-password-rejected');
@@ -1339,6 +1512,12 @@ describe('응답 계약 (e2e)', () => {
.useValue(testFileStorageSettings(fileDir))
.overrideProvider(FILE_SHARING_SETTINGS)
.useValue(TEST_FILE_SHARING_SETTINGS)
// 12단계 — 엔진의 2단계 인증 · 앱 비밀번호는 대역으로(어댑터는 jmap-account-security.provider.spec.ts),
// 웹훅 서명 키 · 앱 네트워크 대역은 env 와 무관하게 고정한다
.overrideProvider(ACCOUNT_SECURITY_PROVIDER)
.useValue(fakeAccountSecurityProvider)
.overrideProvider(ACCOUNT_SECURITY_SETTINGS)
.useValue(TEST_ACCOUNT_SECURITY_SETTINGS)
.compile();
// CI 의 빈 DB 에도 표가 있어야 한다 — 운영처럼 마이그레이션으로 만든다 (synchronize 를 켜지 않는다)
@@ -1381,6 +1560,10 @@ describe('응답 계약 (e2e)', () => {
// 11단계 — 링크(파일 묶음은 함께 지워진다) · 파일(조각 기록도 함께)
'DELETE FROM share_links WHERE owner_email LIKE $1',
'DELETE FROM stored_files WHERE owner_email LIKE $1',
// 12단계 — 로그인 기록 · 앱 비밀번호 기록 · 2단계 인증을 켠 때
'DELETE FROM login_events WHERE user_email LIKE $1',
'DELETE FROM app_password_usages WHERE user_email LIKE $1',
'DELETE FROM two_factor_settings WHERE user_email LIKE $1',
]) {
await dataSource.query(statement, [TEST_DOMAIN_PATTERN]);
}
@@ -6055,8 +6238,6 @@ describe('응답 계약 (e2e)', () => {
for (const [target, host] of [
[`http://${E2E_SHARE_HOST}/api/public-links/${token}`, portalHost],
[`HTTPS://${E2E_SHARE_HOST}:443/api/public-links/${token}`, portalHost],
[`/api\\public-links\\${token}#a`, portalHost],
[`/api\\public-links\\${token}`, portalHost],
[`/api%2Fpublic-links/${token}`, portalHost],
[`http://${E2E_SHARE_HOST}/api/public-links/${token}`, E2E_SHARE_HOST],
[`/api/public-links/${token}/%2e%2e/%2e%2e/users`, E2E_SHARE_HOST],
@@ -6068,6 +6249,19 @@ describe('응답 계약 (e2e)', () => {
code: 'RES_001',
});
}
// 날 `\` · `#` 이 든 대상은 그보다 앞에서(요청 대상 원문 검사 — 12단계 인프라 리뷰) 400 VAL_001 — 공개 호스트 막기까지 가지 않는다
for (const [target, host] of [
[`/api\\public-links\\${token}#a`, portalHost],
[`/api\\public-links\\${token}`, portalHost],
[`/api/public-links/${token}#a`, E2E_SHARE_HOST],
]) {
expect({ target, host, ...(await rawGet(target, host)) }).toEqual({
target,
host,
status: 400,
code: 'VAL_001',
});
}
expect(
await rawGet(`/api/public-links/${token}`, E2E_SHARE_HOST),
).toEqual({ status: 200, code: undefined });
@@ -6584,6 +6778,701 @@ describe('응답 계약 (e2e)', () => {
* 출처 검사(CSRF) — 상태를 바꾸는 요청은 Sec-Fetch-Site(same-origin·none) 또는 허용 목록의 Origin 이어야 한다.
* 둘 다 없으면 브라우저가 아닌 클라이언트로 보고 통과시킨다 (docs/mail/api-contract.md 머리말).
*/
/**
* 12단계 — 계정 보안 (계약 5절). 엔진의 2단계 인증 · 앱 비밀번호는 대역(fakeAccountSecurityProvider)이고, 세션 · 로그인 기록은 실제 DB 다.
* 클라이언트 IP 는 X-Forwarded-For 로 시나리오마다 나눈다(198.18.x.x — 다른 시나리오와 겹치지 않게)
*/
describe('12단계 — 계정 보안 (2단계 인증 · 앱 비밀번호 · 로그인된 브라우저 · 로그인 기록 · 엔진 웹훅)', () => {
const CHROME =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 Edg/141.0.0.0';
const SAFARI =
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1';
const loginAs = (
email: string,
from: string,
extra: object = {},
userAgent = CHROME,
) =>
request(server)
.post('/api/auth/login')
.set('X-Forwarded-For', from)
.set('User-Agent', userAgent)
.send({ email, password: CORRECT_PASSWORD, ...extra });
/** 로그인해 두 쿠키를 받는다 — 세션 id(sid)는 리프레시 토큰에서 읽는다 */
const signIn = async (email: string, from: string, userAgent = CHROME) => {
const res = await loginAs(email, from, {}, userAgent).expect(200);
const access = cookieValue(setCookie(res, 'access_token'));
const refresh = cookieValue(setCookie(res, 'refresh_token'));
const { sid } = jwt.decode<{ sid: string }>(refresh);
return {
sid,
cookies: [`access_token=${access}`, `refresh_token=${refresh}`],
access: `access_token=${access}`,
};
};
afterEach(() => {
e2eAppNetworkOpen = true;
});
describe('2단계 인증', () => {
const ME = 'otp.flow@e2e.arasystem.test';
it('등록 → 켜기 → 로그인 두 단계(쿠키는 코드 뒤에만) → 2분 안에 다시 쓴 코드는 AUTH_006 → 끄기', async () => {
const session = await signIn(ME, '198.18.1.1');
const asMe = (req: HttpTest) => req.set('Cookie', session.cookies);
const before = await asMe(
request(server).get('/api/two-factor'),
).expect(200);
expect(body(before).data).toEqual({
isEnabled: false,
enabledAt: null,
});
const enrolled = await asMe(
request(server).post('/api/two-factor/enrollments'),
).expect(201);
expect(enrolled.headers['cache-control']).toBe('private, no-store');
const enrollment = dataOf<{
enrollmentId: string;
secret: string;
otpauthUrl: string;
expiresAt: string;
}>(enrolled);
expect(enrollment.secret).toMatch(/^[A-Z2-7]{32}$/);
expect(enrollment.otpauthUrl).toBe(
`otpauth://totp/ARA%20SYSTEM:otp.flow%40e2e.arasystem.test?secret=${enrollment.secret}&issuer=ARA%20SYSTEM&algorithm=SHA1&digits=6&period=30`,
);
const secret = decodeBase32(enrollment.secret);
// 틀린 코드 — 엔진에 넣지 않는다
const wrongCode = otpCodeOf(secret) === '000000' ? '111111' : '000000';
const wrong = await asMe(
request(server).post('/api/two-factor/activation'),
)
.send({
enrollmentId: enrollment.enrollmentId,
otpCode: wrongCode,
currentPassword: CORRECT_PASSWORD,
})
.expect(400);
expect(body(wrong).error?.code).toBe('AUTH_006');
expect(twoFactorUrls.has(ME)).toBe(false);
const activationCode = otpCodeOf(secret);
const activated = await asMe(
request(server).post('/api/two-factor/activation'),
)
.send({
enrollmentId: enrollment.enrollmentId,
otpCode: activationCode,
currentPassword: CORRECT_PASSWORD,
})
.expect(200);
expect(body(activated).data).toEqual({
isEnabled: true,
enabledAt: expect.any(String) as unknown,
});
expect(twoFactorUrls.get(ME)).toBe(enrollment.otpauthUrl);
// 1) 비밀번호만 — twoFactorRequired, 쿠키 없음
const first = await loginAs(ME, '198.18.1.2').expect(200);
expect(body(first).data).toEqual({
status: 'twoFactorRequired',
email: null,
name: null,
});
expect(first.headers['set-cookie']).toBeUndefined();
// 켤 때 쓴 코드는 2분 동안 다시 받지 않는다 (엔진은 받지만 서버가 막는다)
const reused = await loginAs(ME, '198.18.1.3', {
otpCode: activationCode,
}).expect(400);
expect(body(reused).error?.code).toBe('AUTH_006');
// 2) 비밀번호 + 새 코드 — signedIn + 쿠키
const loginCode = otpCodeOf(secret, 1);
const second = await loginAs(ME, '198.18.1.4', {
otpCode: loginCode,
}).expect(200);
expect(body(second).data).toEqual({
status: 'signedIn',
email: ME,
name: EMPLOYEE.name,
});
expect(cookieValue(setCookie(second, 'access_token'))).not.toBe('');
const again = await loginAs(ME, '198.18.1.5', {
otpCode: loginCode,
}).expect(400);
expect(body(again).error?.code).toBe('AUTH_006');
const malformed = await loginAs(ME, '198.18.1.6', {
otpCode: '12-34',
}).expect(400);
expect(body(malformed).error?.code).toBe('VAL_001');
const status = await asMe(
request(server).get('/api/two-factor'),
).expect(200);
expect(body(status).data).toMatchObject({ isEnabled: true });
const off = await asMe(
request(server).post('/api/two-factor/deactivation'),
)
.send({
currentPassword: CORRECT_PASSWORD,
otpCode: otpCodeOf(secret, -1),
})
.expect(200);
expect(body(off).data).toEqual({ isEnabled: false, enabledAt: null });
expect(twoFactorUrls.has(ME)).toBe(false);
// 다시 비밀번호만으로 로그인한다
const plain = await loginAs(ME, '198.18.1.7').expect(200);
expect(body(plain).data).toMatchObject({ status: 'signedIn' });
});
it('otpCode 가 null 이면 400 VAL_001 — 칸을 빼고 보낸다(검사를 비켜 서비스에서 500 이 나던 길)', async () => {
const res = await loginAs('otp.null@e2e.arasystem.test', '198.18.1.9', {
otpCode: null,
}).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
const changed = await request(server)
.put('/api/auth/password')
.set('Cookie', cookieOf('otp.null@e2e.arasystem.test'))
.send({
currentPassword: CORRECT_PASSWORD,
newPassword: 'new-password-1234',
otpCode: null,
})
.expect(400);
expect(body(changed).error?.code).toBe('VAL_001');
});
it('등록이 없거나 다른 등록 id 면 409 AUTH_008 · 지금 비밀번호가 틀리면 400 AUTH_005 · 앱 비밀번호도 AUTH_005', async () => {
const OTHER = 'otp.errors@e2e.arasystem.test';
const session = await signIn(OTHER, '198.18.2.1');
const enrolled = await request(server)
.post('/api/two-factor/enrollments')
.set('Cookie', session.cookies)
.expect(201);
const { enrollmentId, secret } = dataOf<{
enrollmentId: string;
secret: string;
}>(enrolled);
const code = otpCodeOf(decodeBase32(secret));
const missing = await request(server)
.post('/api/two-factor/activation')
.set('Cookie', session.cookies)
.send({
enrollmentId: '00000000-0000-4000-8000-000000000000',
otpCode: code,
currentPassword: CORRECT_PASSWORD,
})
.expect(409);
expect(body(missing).error?.code).toBe('AUTH_008');
for (const currentPassword of [
'wrong-password',
`app_${'abcdefghijklmnopqrstuvwxyz0123791203'}`,
]) {
const rejected = await request(server)
.post('/api/two-factor/activation')
.set('Cookie', session.cookies)
.send({ enrollmentId, otpCode: code, currentPassword })
.expect(400);
expect(body(rejected).error?.code).toBe('AUTH_005');
}
expect(twoFactorUrls.has(OTHER)).toBe(false);
});
it('2단계 인증을 켠 직원의 비밀번호 바꾸기 — 코드가 없으면 AUTH_007, 코드와 함께 바꾸면 앱 비밀번호가 모두 지워진다', async () => {
const OTP_USER = 'otp.password@e2e.arasystem.test';
const session = await signIn(OTP_USER, '198.18.3.1');
// 엔진 관리 화면에서 켠 것처럼 대역에 바로 둔다
const secret = Buffer.alloc(20, 3);
twoFactorUrls.set(
OTP_USER,
`otpauth://totp/ARA%20SYSTEM:x?secret=${'AMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYD'}&issuer=ARA%20SYSTEM`,
);
appPasswordsByUser.set(OTP_USER, [
{ id: 'b', name: '회사 PC', createdAt: '2026-10-30T00:00:00.000Z' },
]);
const noCode = await request(server)
.put('/api/auth/password')
.set('Cookie', session.cookies)
.set('X-Forwarded-For', '198.18.3.2')
.send({
currentPassword: CORRECT_PASSWORD,
newPassword: 'new-password-1234',
})
.expect(400);
expect(body(noCode).error?.code).toBe('AUTH_007');
await request(server)
.put('/api/auth/password')
.set('Cookie', session.cookies)
.set('X-Forwarded-For', '198.18.3.3')
.send({
currentPassword: CORRECT_PASSWORD,
newPassword: 'new-password-1234',
otpCode: otpCodeOf(secret),
})
.expect(200);
expect(appPasswordsByUser.get(OTP_USER)).toEqual([]);
// 2단계 인증은 그대로 남는다
expect(twoFactorUrls.has(OTP_USER)).toBe(true);
twoFactorUrls.delete(OTP_USER);
});
});
describe('앱 비밀번호 (/api/app-passwords)', () => {
const ME = 'apppw.me@e2e.arasystem.test';
it('만들기(값은 이 응답에만 · no-store) → 목록(최근 것이 위) → 같은 이름 VAL_001 → 상한 AUTH_009 → 지우기(이미 없어도 200)', async () => {
const session = await signIn(ME, '198.18.4.1');
const create = (name: string) =>
request(server)
.post('/api/app-passwords')
.set('Cookie', session.cookies)
.send({ name });
const created = await create(' 회사 PC 아웃룩 ').expect(201);
expect(created.headers['cache-control']).toBe('private, no-store');
expect(dataOf(created)).toEqual({
id: expect.any(String) as unknown,
name: '회사 PC 아웃룩',
createdAt: expect.any(String) as unknown,
lastUsed: null,
password: expect.stringMatching(/^app_.{36}$/) as unknown,
});
const firstId = dataOf<{ id: string }>(created).id;
const taken = await create('회사 PC 아웃룩').expect(400);
expect(body(taken).error?.code).toBe('VAL_001');
const empty = await create(' ').expect(400);
expect(body(empty).error?.code).toBe('VAL_001');
const long = await create('가'.repeat(41)).expect(400);
expect(body(long).error?.code).toBe('VAL_001');
for (const name of ['휴대폰', '태블릿', '집 PC', '노트북']) {
await create(name).expect(201);
}
const full = await create('여섯째').expect(409);
expect(body(full).error?.code).toBe('AUTH_009');
const listed = await request(server)
.get('/api/app-passwords')
.set('Cookie', session.cookies)
.expect(200);
const page = dataOf<{
items: { id: string; name: string; password?: string }[];
pageInfo: unknown;
}>(listed);
expect(page.items.map(({ name }) => name)).toEqual([
'노트북',
'집 PC',
'태블릿',
'휴대폰',
'회사 PC 아웃룩',
]);
expect(page.items.every((item) => item.password === undefined)).toBe(
true,
);
expect(page.pageInfo).toEqual({
hasNext: false,
nextCursor: null,
total: 5,
});
await request(server)
.delete(`/api/app-passwords/${firstId}`)
.set('Cookie', session.cookies)
.expect(200);
await request(server)
.delete(`/api/app-passwords/${firstId}`)
.set('Cookie', session.cookies)
.expect(200);
expect(appPasswordsByUser.get(ME)).toHaveLength(4);
});
it('앱 비밀번호로는 웹메일에 로그인할 수 없다 — 엔진에 묻지 않고 401 AUTH_004', async () => {
const checksBefore = engineChecks.get(ME) ?? 0;
const res = await request(server)
.post('/api/auth/login')
.set('X-Forwarded-For', '198.18.4.9')
.send({
email: ME,
password: `app_${'abcdefghijklmnopqrstuvwxyz0123791203'}`,
})
.expect(401);
expect(body(res).error?.code).toBe('AUTH_004');
expect(engineChecks.get(ME) ?? 0).toBe(checksBefore);
});
});
describe('로그인된 브라우저 (/api/auth/sessions)', () => {
const ME = 'sessions.me@e2e.arasystem.test';
it('지금 세션이 맨 앞 · 하나 끊으면 그 액세스 토큰이 바로 401 · 지금 세션은 VAL_001 · 없는 것은 RES_001 · 다른 곳 모두 끊기', async () => {
const current = await signIn(ME, '198.18.5.1', CHROME);
const phone = await signIn(ME, '198.18.5.2', SAFARI);
const third = await signIn(ME, '198.18.5.3', CHROME);
const listed = await request(server)
.get('/api/auth/sessions')
.set('Cookie', current.cookies)
.expect(200);
const items = dataOf<{
items: {
id: string;
isCurrent: boolean;
ip: string | null;
device: { browser: string | null; os: string | null } | null;
}[];
pageInfo: { total: number };
}>(listed);
expect(items.items[0]).toMatchObject({
id: current.sid,
isCurrent: true,
ip: '198.18.5.1',
device: { browser: 'Edge', os: 'Windows' },
});
expect(items.items.find(({ id }) => id === phone.sid)).toMatchObject({
isCurrent: false,
device: { browser: 'Safari', os: 'iOS' },
});
expect(items.pageInfo.total).toBe(3);
// 끊기 전에는 그 브라우저의 액세스 토큰이 된다
await request(server)
.get('/api/auth/me')
.set('Cookie', phone.access)
.expect(200);
await request(server)
.delete(`/api/auth/sessions/${phone.sid}`)
.set('Cookie', current.cookies)
.expect(200);
// 끊은 세션의 액세스 토큰은 만료 전이어도 바로 401
const revoked = await request(server)
.get('/api/auth/me')
.set('Cookie', phone.access)
.expect(401);
expect(body(revoked).error?.code).toBe('AUTH_001');
const self = await request(server)
.delete(`/api/auth/sessions/${current.sid}`)
.set('Cookie', current.cookies)
.expect(400);
expect(body(self).error?.code).toBe('VAL_001');
const unknown = await request(server)
.delete(`/api/auth/sessions/${randomUUID()}`)
.set('Cookie', current.cookies)
.expect(404);
expect(body(unknown).error?.code).toBe('RES_001');
await request(server)
.delete(`/api/auth/sessions/${phone.sid}`)
.set('Cookie', current.cookies)
.expect(404);
const ended = await request(server)
.delete('/api/auth/sessions')
.set('Cookie', current.cookies)
.expect(200);
expect(body(ended).data).toEqual({ ended: 1 });
await request(server)
.get('/api/auth/me')
.set('Cookie', third.access)
.expect(401);
// 이 세션은 그대로 쓴다
await request(server)
.get('/api/auth/me')
.set('Cookie', current.access)
.expect(200);
});
it('로그아웃한 세션의 액세스 토큰도 바로 401 (12단계 — 만료까지 기다리지 않는다)', async () => {
const session = await signIn(ME, '198.18.5.9');
await request(server)
.post('/api/auth/logout')
.set('Cookie', session.cookies)
.expect(200);
await request(server)
.get('/api/auth/me')
.set('Cookie', session.access)
.expect(401);
});
});
describe('로그인 기록 (/api/login-events)', () => {
const ME = 'history.me@e2e.arasystem.test';
/**
* 비밀번호 틀림은 사내 목록에 있는 주소만 남긴다 — 사내 목록은 10분 기억하고 다른 시나리오(8단계)가 채우므로,
* 이 시나리오는 사내 목록의 답을 직접 정한다(테스트 차례와 상관없게)
*/
let directorySpy: jest.SpyInstance | null = null;
const directoryHas = (known: boolean) => {
directorySpy = jest
.spyOn(app.get(DirectoryService, { strict: false }), 'hasEmail')
.mockResolvedValue(known);
};
afterEach(() => {
directorySpy?.mockRestore();
directorySpy = null;
});
it('사내 목록에 없는 주소의 비밀번호 틀림은 남기지 않는다 (아무 주소나 뿌려 표를 채우지 못하게)', async () => {
const STRANGER = 'history.stranger@e2e.arasystem.test';
directoryHas(false);
await request(server)
.post('/api/auth/login')
.set('X-Forwarded-For', '198.18.6.9')
.send({ email: STRANGER, password: 'wrong-password' })
.expect(401);
const rows: unknown[] = await dataSource.query(
'SELECT id FROM login_events WHERE user_email = $1',
[STRANGER],
);
expect(rows).toEqual([]);
});
it('웹메일 성공 · 실패가 최근 차례로 · isSuccess=false 는 실패만 · 커서로 이어 읽는다 · 다른 거르기의 커서는 VAL_001', async () => {
directoryHas(true);
await request(server)
.post('/api/auth/login')
.set('X-Forwarded-For', '198.18.6.1')
.send({ email: ME, password: 'wrong-password' })
.expect(401);
const session = await signIn(ME, '198.18.6.2');
const all = await request(server)
.get('/api/login-events?limit=1')
.set('Cookie', session.cookies)
.expect(200);
const firstPage = dataOf<{
items: Record<string, unknown>[];
pageInfo: {
hasNext: boolean;
nextCursor: string | null;
total: number;
};
}>(all);
expect(firstPage.pageInfo).toMatchObject({ hasNext: true, total: 2 });
expect(firstPage.items[0]).toMatchObject({
method: 'webmail',
isSuccess: true,
isTwoFactor: false,
failureReason: null,
ip: '198.18.6.2',
device: { browser: 'Edge', os: 'Windows' },
appPassword: null,
});
const next = await request(server)
.get(
`/api/login-events?limit=1&cursor=${firstPage.pageInfo.nextCursor ?? ''}`,
)
.set('Cookie', session.cookies)
.expect(200);
expect(dataOf<{ items: unknown[] }>(next).items).toEqual([
expect.objectContaining({
isSuccess: false,
failureReason: 'wrongPassword',
ip: '198.18.6.1',
}),
]);
const failures = await request(server)
.get('/api/login-events?isSuccess=false')
.set('Cookie', session.cookies)
.expect(200);
expect(
dataOf<{ items: { isSuccess: boolean }[] }>(failures).items.every(
({ isSuccess }) => !isSuccess,
),
).toBe(true);
const otherScope = await request(server)
.get(
`/api/login-events?isSuccess=false&cursor=${firstPage.pageInfo.nextCursor ?? ''}`,
)
.set('Cookie', session.cookies)
.expect(400);
expect(body(otherScope).error?.code).toBe('VAL_001');
const badFilter = await request(server)
.get('/api/login-events?isSuccess=maybe')
.set('Cookie', session.cookies)
.expect(400);
expect(body(badFilter).error?.code).toBe('VAL_001');
});
});
describe('요청 대상 원문 검사 (인프라 리뷰 — nginx 가 닫은 경로가 parseurl 의 옛 url.parse 로 열리던 길)', () => {
/** 원문 바이트 그대로 보낸다 — supertest 는 백슬래시를 `/` 로 고쳐 보낸다 */
const sendRaw = (
httpServer: Server,
method: string,
target: string,
): Promise<{ status: number; text: string }> => {
const address = httpServer.address();
const port = typeof address === 'object' && address ? address.port : 0;
return new Promise((resolve, reject) => {
const socket = connect(port, '127.0.0.1');
const chunks: Buffer[] = [];
socket.on('data', (chunk: Buffer) => chunks.push(chunk));
socket.on('error', reject);
socket.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8');
resolve({ status: Number(text.split(' ')[1]), text });
});
socket.write(
`${method} ${target} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n{}`,
'latin1',
);
});
};
it('날 `\\` · `#` 이 든 대상은 라우팅 전에 표준 실패 형식의 400 VAL_001 — 웹훅 가드 · 헬스체크에 닿지 않는다, 정상 경로는 그대로', async () => {
const httpServer = app.getHttpServer();
const startedServer = !httpServer.listening;
if (startedServer) await listenOnAnyPort(httpServer);
try {
for (const [method, target] of [
['POST', '/api/internal\\mail-engine-events#x'],
['GET', '/api/health\\#a'],
['GET', '/api/auth/me#x'],
] as const) {
const res = await sendRaw(httpServer, method, target);
expect(res.status).toBe(400);
expect(res.text).toContain('"code":"VAL_001"');
expect(res.text).toMatch(/"traceId":"[^"]+"/);
}
// 정상 경로 · 퍼센트 인코딩한 한글은 라우터까지 간다 (로그인하지 않았으니 401)
const normal = await sendRaw(
httpServer,
'GET',
'/api/messages?q=%ED%95%9C%EA%B8%80',
);
expect(normal.status).toBe(401);
expect(normal.text).toContain('"code":"AUTH_001"');
} finally {
if (startedServer) await closeServer(httpServer);
}
});
});
describe('엔진 웹훅 (POST /api/internal/mail-engine-events)', () => {
const ME = 'webhook.me@e2e.arasystem.test';
const signature = (raw: string, secret = E2E_WEBHOOK_SECRET) =>
createHmac('sha256', Buffer.from(secret, 'utf8'))
.update(raw)
.digest('base64');
const deliver = (raw: string, signed = signature(raw)) =>
request(server)
.post('/api/internal/mail-engine-events')
.set('Content-Type', 'application/json')
.set('X-Signature', signed)
// setup 이 붙이는 표시 머리글 — 서버는 쓰지 않는다
.set('X-Arasystem-Setup', '[setup.mjs] key=e2e')
.send(raw);
it('서명한 빈 묶음은 200 (setup 이 받는 쪽을 확인하는 방법)', async () => {
const res = await deliver('{"events":[]}').expect(200);
expect(body(res)).toEqual({ success: true, data: null });
});
it('서명한 IMAP 로그인 이벤트는 로그인 기록에 남는다 — 엔진이 다시 보낸 묶음(이벤트 id 만 다르다)은 겹치지 않는다', async () => {
const event = (id: string) => ({
id,
createdAt: '2026-10-31T00:00:05Z',
type: 'auth.success',
data: {
listenerId: 'imaps',
localPort: 993,
remoteIp: '211.186.95.252',
remotePort: 51234,
accountName: ME,
accountId: 9,
spanId: 777,
},
});
await deliver(JSON.stringify({ events: [event('first')] })).expect(200);
await deliver(JSON.stringify({ events: [event('retried')] })).expect(
200,
);
const rows: { method: string; ip: string; is_success: boolean }[] =
await dataSource.query(
'SELECT method, ip, is_success FROM login_events WHERE user_email = $1',
[ME],
);
expect(rows).toEqual([
{ method: 'imap', ip: '211.186.95.252', is_success: true },
]);
});
// 6,000행을 넣는다 — 개발 DB 의 쿼리 로그가 켜져 있으면 혼자 3초 남짓이라 기본 한도(5초)보다 넉넉히 둔다
it('큰 묶음(6,000건 — 한 문장에 넣으면 매개변수 상한 65,535 를 넘는 크기)도 200 — 나눠 넣어 모두 남는다', async () => {
const BIG = 'webhook.big@e2e.arasystem.test';
const events = Array.from({ length: 6_000 }, (_, index) => ({
id: `big-${index}`,
createdAt: '2026-10-31T00:10:00Z',
type: 'auth.success',
data: {
listenerId: 'imaps',
localPort: 993,
remoteIp: '211.186.95.252',
remotePort: 10_000 + index,
accountName: BIG,
accountId: 9,
spanId: 50_000 + index,
},
}));
await deliver(JSON.stringify({ events })).expect(200);
const [{ count }]: { count: number }[] = await dataSource.query(
'SELECT count(*)::int AS count FROM login_events WHERE user_email = $1',
[BIG],
);
expect(count).toBe(6_000);
}, 60_000);
it('서명이 없거나 틀리면 · 앱 네트워크 밖의 접속이면 · JSON 이 아니면 401 AUTH_001 — 아무것도 남기지 않는다', async () => {
const raw = JSON.stringify({ events: [] });
const unsigned = await request(server)
.post('/api/internal/mail-engine-events')
.set('Content-Type', 'application/json')
.send(raw)
.expect(401);
expect(body(unsigned).error?.code).toBe('AUTH_001');
await deliver(raw, signature(raw, `${E2E_WEBHOOK_SECRET}x`)).expect(
401,
);
await deliver(raw, signature('{"events":[1]}')).expect(401);
const plain = await request(server)
.post('/api/internal/mail-engine-events')
.set('Content-Type', 'text/plain')
.set('X-Signature', signature(raw))
.send(raw)
.expect(401);
expect(body(plain).error?.code).toBe('AUTH_001');
e2eAppNetworkOpen = false;
await deliver(raw).expect(401);
});
});
});
describe('출처 검사 (상태 변경 요청)', () => {
const EVIL_ORIGIN = 'https://evil.example';