generated from ttipo/project-base
feat(backend): 직원 인증(쿠키 JWT·DB 세션)과 웹메일 API(Stalwart JMAP 중계)
첫 사내 모듈인 웹메일의 백엔드다. 메일 엔진은 외부(Stalwart)에 두고 백엔드가 JMAP 으로 중계한다. - 직원 로그인 = 메일 계정: 엔진으로 비밀번호를 확인한 뒤 HttpOnly 쿠키 JWT 를 발급한다. 리프레시 세션은 DB 에 해시로 저장하고 1회용으로 회전한다 (이미 쓴 토큰이 오면 세션 폐기) - 로그인 실패 한도는 엔진에 묻기 전에 원자적으로 차지하고, IPv6 는 /64 로 묶어 센다 - 상태를 바꾸는 요청은 출처(Sec-Fetch-Site·Origin)를 검사하고, 클라이언트 IP 는 TRUST_PROXY 로만 해석한다 - 메일함·목록·읽기·발송·첨부 API: 받은 HTML 은 정화해서 내보내고, 발송은 Idempotency-Key 로 중복을 막는다 - 메일 엔진 관리자 계정은 로그인·대리 접근 대상에서 막는다 (게이트웨이가 관리자까지 대리할 수 있다) - 에러 코드 AUTH_004·MAIL_001~006 을 추가하고 클라이언트 에러 사전을 다시 생성했다 계약: docs/mail/api-contract.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
import { RequestMethod, ValidationPipe } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import helmet from 'helmet';
|
||||
|
||||
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
|
||||
import { TransformInterceptor } from '@/common/interceptors/transform.interceptor';
|
||||
import { requestIdMiddleware } from '@/common/middleware/request-id.middleware';
|
||||
import { resolveTrustProxy } from '@/shared/utils/trust-proxy';
|
||||
|
||||
/**
|
||||
* JSON 본문 최대 크기.
|
||||
* 메일 발송 본문은 100,000자까지 받는다 — 한글은 UTF-8 로 3바이트라 약 300KB 다 (기본값 100KB 로는 잘린다).
|
||||
*/
|
||||
export const JSON_BODY_LIMIT = '1mb';
|
||||
|
||||
/**
|
||||
* HTTP 파이프라인 전역 설정 — main.ts 와 e2e 테스트가 **같은 함수**를 쓴다
|
||||
* (따로 적으면 e2e 가 실제 응답과 다른 설정으로 돌게 된다).
|
||||
*
|
||||
* 앱은 `{ bodyParser: false }` 로 만들어야 한다 — 본문 파서를 여기서 직접 등록한다.
|
||||
* CORS·Swagger·종료 훅은 배포 환경에 묶인 설정이라 main.ts 에 둔다.
|
||||
*/
|
||||
export function configureApp(app: NestExpressApplication): void {
|
||||
// 요청 추적 ID — **가장 먼저** 붙인다. 본문 파서·helmet 보다 앞이라 그 단계의 실패(413·400·415)에도
|
||||
// 응답 traceId 와 로그가 남고, 이후 모든 처리가 요청 컨텍스트(traceTag) 안에서 돈다
|
||||
app.use(requestIdMiddleware);
|
||||
|
||||
// 믿을 프록시 뒤에서 실제 클라이언트 IP 를 읽는다 — 로그인 실패 한도·요청 수 한도의 기준 (TRUST_PROXY).
|
||||
// 운영 체인: 클라이언트 → 외부 리버스 프록시 → 프론트 nginx → 백엔드. 값이 없으면(비운영) 1
|
||||
app.set('trust proxy', resolveTrustProxy(process.env.TRUST_PROXY));
|
||||
|
||||
// 글로벌 prefix — 모든 라우트가 /api/* (클라이언트 baseURL 과 일치)
|
||||
app.setGlobalPrefix('api', {
|
||||
exclude: [{ path: '/', method: RequestMethod.GET }],
|
||||
});
|
||||
|
||||
// 보안 헤더
|
||||
app.use(helmet());
|
||||
|
||||
// 인증 쿠키(access_token·refresh_token)를 읽는다
|
||||
app.use(cookieParser());
|
||||
|
||||
// JSON 만 받는다 — urlencoded 파서를 등록하지 않으면 다른 사이트의 <form> 으로 로그인·발송을 시도하는
|
||||
// 요청(CSRF)은 본문이 비어 VAL_001 이 된다. JSON 요청은 CORS 사전 확인을 거치므로 화이트리스트가 막는다.
|
||||
// inflate=false: 압축된 요청 본문을 받지 않는다 (브라우저는 보내지 않는다 — 압축 폭탄·body-parser DoS 경로 차단)
|
||||
app.useBodyParser('json', { limit: JSON_BODY_LIMIT, inflate: false });
|
||||
|
||||
// 전역 DTO 검증
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true, // DTO 에 없는 값은 버린다
|
||||
forbidNonWhitelisted: true, // DTO 에 없는 필드가 오면 400
|
||||
transform: true, // DTO·기본 타입으로 변환
|
||||
}),
|
||||
);
|
||||
|
||||
// 표준 실패·성공 응답
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector)));
|
||||
}
|
||||
Reference in New Issue
Block a user