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
+29 -2
View File
@@ -19,6 +19,16 @@ import { BusinessException } from '@/common/errors/business.exception';
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
import { TransformInterceptor } from '@/common/interceptors/transform.interceptor';
import { requestIdMiddleware } from '@/common/middleware/request-id.middleware';
import { requestTargetMiddleware } from '@/common/middleware/request-target.middleware';
import { MAIL_ENGINE_EVENTS_SHAPE_LIMITS } from '@/modules/account-security/account-security.constants';
import {
ACCOUNT_SECURITY_SETTINGS,
type AccountSecuritySettings,
} from '@/modules/account-security/account-security.settings';
import {
createMailEngineEventsBodyParser,
isMailEngineEventsRequest,
} from '@/modules/account-security/mail-engine-events.parser';
import {
FILE_SHARING_SETTINGS,
type FileSharingSettings,
@@ -182,7 +192,8 @@ export function verifyJsonBody(
/**
* 본문 파서가 읽은 JSON 본문의 모양을 본다 (JSON_SHAPE_LIMITS) — 넘으면 DTO 변환 · 검사로 넘기지 않고 400 VAL_001.
* 지금은 읽기 전 확인을 지난 UTF-8 본문만 오므로 같은 판정을 한 번 더 하는 셈이다(값 10,000개 안이라 가볍다) —
* 본문 파서를 새로 붙이거나 읽기 전 확인이 빠져도 DTO 변환 · 검사 전에 끊도록 남겨 둔다
* 본문 파서를 새로 붙이거나 읽기 전 확인이 빠져도 DTO 변환 · 검사 전에 끊도록 남겨 둔다.
* 엔진 웹훅(12단계 — `POST /api/internal/mail-engine-events`)만 그 경로의 한도(MAIL_ENGINE_EVENTS_SHAPE_LIMITS)로 본다
*/
export function rejectOversizedJsonBody(
req: Request,
@@ -190,7 +201,10 @@ export function rejectOversizedJsonBody(
next: NextFunction,
): void {
const body: unknown = req.body;
const problem = findJsonShapeProblem(body, JSON_SHAPE_LIMITS);
const limits = isMailEngineEventsRequest(req)
? MAIL_ENGINE_EVENTS_SHAPE_LIMITS
: JSON_SHAPE_LIMITS;
const problem = findJsonShapeProblem(body, limits);
if (problem === null) {
next();
return;
@@ -241,6 +255,10 @@ export function configureApp(app: NestExpressApplication): void {
// 응답 traceId 와 로그가 남고, 이후 모든 처리가 요청 컨텍스트(traceTag) 안에서 돈다
app.use(requestIdMiddleware);
// 요청 대상 원문 검사 — 날 `\` · `#` · 제어 문자 · 0x80 이상 글자가 있으면 라우팅 전에 400 VAL_001 (common/middleware/request-target.middleware.ts).
// Express 의 parseurl 이 그런 대상을 옛 url.parse 로 다시 읽으며 `\` 를 `/` 로 바꿔, 앞단 nginx 가 정규식으로 닫은 경로가 여기서 열렸다 (2026-10-31)
app.use(requestTargetMiddleware);
// 공개 호스트(대용량 첨부의 내려받기 주소 — 11단계)를 포털과 따로 두면 그 호스트에서는 공개 링크 API 만, 포털에서는 공개 링크 API 를 닫는다.
// 본문 파서 앞이라 막은 요청의 본문을 읽지 않는다. 같은 주소면(개발) 아무것도 막지 않는다 (modules/file-sharing/public-host.ts)
const sharing = app.get<FileSharingSettings>(FILE_SHARING_SETTINGS, {
@@ -272,6 +290,15 @@ export function configureApp(app: NestExpressApplication): void {
// 순서가 중요하다: 4MB 파서가 먼저여야 발송 본문이 1MB 파서에서 413 이 되지 않는다.
// 두 파서 모두 읽기 전 확인(verify — UTF-8 만 · 모양 한도)을 건다 — Nest 의 useBodyParser 는 verify 를 rawBody 용으로 남겨 두고 받지 않아(타입에서 뺀다)
// 같은 파서(express.json = body-parser)를 직접 붙인다 (useBodyParser 도 안에서 이렇게 붙인다)
//
// 엔진 웹훅(12단계 — 계약 5절 "엔진 웹훅 받기")은 맨 앞의 자기 파서가 읽는다: 본문을 받기 전에 소켓 주소(앱 네트워크) · 서명 키 유무를 보고
// 아니면 본문을 받지 않고 401 AUTH_001(연결을 닫는다), 서명(HMAC)은 원문을 받은 뒤 JSON 으로 읽기 전에 본다.
// 한도는 4MB · 배열 10,000 · 값 200,000 (modules/account-security/mail-engine-events.parser.ts)
const accountSecurity = app.get<AccountSecuritySettings>(
ACCOUNT_SECURITY_SETTINGS,
{ strict: false },
);
app.use(createMailEngineEventsBodyParser(accountSecurity));
app.use(
json({
limit: SEND_MESSAGE_JSON_BODY_LIMIT,