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:
+28
-45
@@ -1,58 +1,54 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { Logger, RequestMethod, ValidationPipe } from '@nestjs/common';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { WinstonModule } from 'nest-winston';
|
||||
import helmet from 'helmet';
|
||||
import type {
|
||||
Express,
|
||||
Request as ExpressRequest,
|
||||
Response as ExpressResponse,
|
||||
} from 'express';
|
||||
|
||||
import { Reflector } from '@nestjs/core';
|
||||
|
||||
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
|
||||
import { TransformInterceptor } from '@/common/interceptors/transform.interceptor';
|
||||
import { configureApp } from '@/app.setup';
|
||||
import { ACCESS_TOKEN_COOKIE } from '@/modules/auth/auth.constants';
|
||||
import { winstonConfig } from '@/shared/logger/winston.config';
|
||||
import {
|
||||
buildAllowedOrigins,
|
||||
normalizeOrigin,
|
||||
} from '@/shared/utils/request-origin';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
logger: WinstonModule.createLogger(winstonConfig),
|
||||
// 본문 파서는 configureApp 이 직접 등록한다 (JSON 만, 크기 한도 지정)
|
||||
bodyParser: false,
|
||||
});
|
||||
|
||||
// 리버스 프록시(Nginx, Docker 배포 환경 등) 뒤에서 올바른 클라이언트 IP 식별 지원을 위해 설정합니다.
|
||||
const expressApp = app.getHttpAdapter().getInstance() as Express;
|
||||
expressApp.set('trust proxy', 1);
|
||||
|
||||
// 글로벌 prefix — 모든 라우트가 /api/* 로 정규화됨 (Flutter, Web 양쪽 baseURL과 일치)
|
||||
app.setGlobalPrefix('api', {
|
||||
// 헬스체크 / 루트 상태 엔드포인트는 prefix 미적용
|
||||
exclude: [{ path: '/', method: RequestMethod.GET }],
|
||||
});
|
||||
|
||||
// 기본 주소('/') 접속 시 상태 확인용 텍스트 응답 추가
|
||||
// 기본 주소('/') 접속 시 상태 확인용 텍스트 응답 (컨테이너 healthcheck 가 읽는다)
|
||||
const expressApp = app.getHttpAdapter().getInstance();
|
||||
expressApp.get('/', (_req: ExpressRequest, res: ExpressResponse) => {
|
||||
res.send('Backend API Server is running 정상 구동 중입니다.');
|
||||
});
|
||||
|
||||
// 1. 보안 헤더 (Helmet) - XSS 방지 및 기본 웹 보안
|
||||
app.use(helmet());
|
||||
// 1. 공통 HTTP 파이프라인 — prefix · helmet · 쿠키 · 본문 파서 · 검증 · 표준 응답
|
||||
// e2e 테스트도 같은 함수를 쓴다 (app.setup.ts)
|
||||
configureApp(app);
|
||||
|
||||
// 2. 화이트리스트 기반 CORS 정책
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||
const corsOriginEnv = process.env.CORS_ORIGIN;
|
||||
// 2. 화이트리스트 기반 CORS 정책 — 운영은 CORS_ORIGIN 만, localhost 기본값은 개발에서만.
|
||||
// 화면과 API 를 같은 주소로 배포하면 CORS 는 쓰이지 않는다 (docs/mail/api-contract.md 머리말).
|
||||
// 같은 목록을 상태 변경 요청의 출처 검사(OriginGuard)도 쓴다.
|
||||
const allowedOrigins = new Set(
|
||||
buildAllowedOrigins((key) => process.env[key]),
|
||||
);
|
||||
|
||||
app.enableCors({
|
||||
origin: (
|
||||
origin: string | undefined,
|
||||
callback: (err: Error | null, allow?: boolean) => void,
|
||||
) => {
|
||||
// 서버간의 통신(origin이 undefined)이거나 명시된 리스트일 때만 허용
|
||||
const whitelist = [frontendUrl, 'http://localhost:3000'];
|
||||
if (corsOriginEnv) whitelist.push(corsOriginEnv);
|
||||
|
||||
if (!origin || whitelist.indexOf(origin) !== -1) {
|
||||
// 서버 간 통신(origin 없음)이거나 목록에 있는 출처일 때만 허용
|
||||
const normalized = origin === undefined ? null : normalizeOrigin(origin);
|
||||
if (!origin || (normalized !== null && allowedOrigins.has(normalized))) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
// Error 를 넘기면 500(SYS_001)이 되어 원인 파악이 어려우므로,
|
||||
@@ -67,30 +63,17 @@ async function bootstrap() {
|
||||
// 라우트 단위 제어(@Throttle / @SkipThrottle)가 가능하고, 한도 초과는 REQ_001(429)로 응답된다.
|
||||
// (전역 예외 필터가 429 → REQ_001 로 매핑한다 — .claude/rules/error-codes.md)
|
||||
|
||||
// 4. 전역 DTO 유효성 파이프 (강력한 검증 단계)
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true, // DTO에 불필요한 값이 오면 삭제
|
||||
forbidNonWhitelisted: true, // DTO에 없는 필드가 주입되면 400 에러 발생
|
||||
transform: true, // 네트워크 데이터를 DTO나 기본 타입으로 자동 변환
|
||||
}),
|
||||
);
|
||||
|
||||
// 5. 프론트엔드 연동을 위한 규격화된 에러 & 성공 페이로드 인터셉터 적용
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector)));
|
||||
|
||||
// 6. Swagger API 문서 설정
|
||||
// 4. Swagger API 문서 설정 — 인증은 HttpOnly 쿠키(access_token)뿐이다 (Authorization 헤더는 읽지 않는다)
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Backend API Documentation')
|
||||
.setDescription('The API documentation conforming to the global guidelines')
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.addCookieAuth(ACCESS_TOKEN_COOKIE)
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api-docs', app, document);
|
||||
|
||||
// 7. Graceful shutdown — SIGTERM 수신 시 새 요청을 받지 않고
|
||||
// 5. Graceful shutdown — SIGTERM 수신 시 새 요청을 받지 않고
|
||||
// 처리 중인 요청을 마친 뒤 DB/Redis 커넥션을 정리하고 종료한다.
|
||||
// (이 설정이 없으면 배포·재시작 때 처리 중이던 요청이 그대로 끊긴다)
|
||||
app.enableShutdownHooks();
|
||||
|
||||
Reference in New Issue
Block a user