generated from ttipo/project-base
6ee6c71ace
계약 1절 "앱 로그인"이다(웹의 쿠키를 앱에 쓰지 않는다). 세션 규칙은 웹과 같고 토큰을 나르는 길만 다르다. - POST /api/auth/app/login · refresh · logout — 같은 AuthService.login 을 지나 실패 카운터 · 2단계 코드 가드 · 앱 비밀번호 모양 거절 · 관리자 차단이 웹과 같고, 로그인 요청 수 한도는 웹 · 앱이 한 칸을 함께 쓴다. 토큰 응답은 private, no-store - Bearer 는 앱 표시(cli: app)와 UUID sid 가 있는 액세스 토큰만, 쿠키는 웹 토큰만 — 리프레시 토큰 · 세션 표의 client 도 맞아야 하고 다르면 401(재사용으로 보지 않음) - 앱 세션의 갱신 유예는 그 세션을 끝낸다(app-refresh-grace — 응답을 잃었거나 토큰을 훔친 것이다) - 지금 세션은 앱이면 액세스 토큰의 sid(비밀번호 바꾸기 · 로그인된 기기 · 앱 logout), 로그아웃은 리프레시 토큰이 틀리면 sid 로 끝낸다 - 세션 표 client 칸(마이그레이션 AddAppSessionClient — 기본 web), 세션 목록 항목에 client, 앱 이름 "ARA SYSTEM 앱"은 client · method 로만(꾸민 User-Agent 로 앱처럼 보이지 않게), 로그인 기록 method app - 운영 도구: 현황의 마지막 로그인이 앱도 세고, 세션 끊기 글 · 직원 알림 메일이 앱을 말한다 - Swagger 에 Bearer 를 등록하고 쿠키로 보호하는 작업마다 "또는"으로 붙인다 리뷰 반영: 유예에서 앱 세션 끊기, 앱 이름을 진짜 클라이언트로만, sid 없는 앱 토큰 거절, 빈 · 너무 긴 리프레시 토큰은 401(로그아웃은 sid 로), 시험(웹 쿠키 + 앱 logout 등). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
120 lines
5.4 KiB
TypeScript
120 lines
5.4 KiB
TypeScript
import { NestFactory } from '@nestjs/core';
|
|
import { AppModule } from './app.module';
|
|
import { Logger } from '@nestjs/common';
|
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
|
import { WinstonModule } from 'nest-winston';
|
|
import type {
|
|
Request as ExpressRequest,
|
|
Response as ExpressResponse,
|
|
} from 'express';
|
|
|
|
import { configureApp, createHttpAdapter } from '@/app.setup';
|
|
import { ACCESS_TOKEN_COOKIE } from '@/modules/auth/auth.constants';
|
|
import { winstonConfig } from '@/shared/logger/winston.config';
|
|
import { parsePositiveInt } from '@/shared/utils/env';
|
|
import { addAlternativeSecurity } from '@/shared/utils/openapi-security';
|
|
import {
|
|
buildAllowedOrigins,
|
|
normalizeOrigin,
|
|
} from '@/shared/utils/request-origin';
|
|
|
|
/** PORT 가 없거나 비었을 때 듣는 포트 */
|
|
const DEFAULT_PORT = 3000;
|
|
|
|
/** Swagger 인증 이름 — `@ApiCookieAuth()` · `@ApiBearerAuth()` 의 기본 이름과 같다 */
|
|
const SWAGGER_COOKIE_AUTH = 'cookie';
|
|
const SWAGGER_BEARER_AUTH = 'bearer';
|
|
|
|
async function bootstrap() {
|
|
// 어댑터는 createHttpAdapter — 라우팅이 대소문자를 가린다(어댑터를 만들 때 켜야 한다 — app.setup.ts). e2e 도 같은 함수를 쓴다
|
|
const app = await NestFactory.create<NestExpressApplication>(
|
|
AppModule,
|
|
createHttpAdapter(),
|
|
{
|
|
logger: WinstonModule.createLogger(winstonConfig),
|
|
// 본문 파서는 configureApp 이 직접 등록한다 (JSON 만, 크기 한도 지정)
|
|
bodyParser: false,
|
|
},
|
|
);
|
|
|
|
// 기본 주소('/') 접속 시 상태 확인용 텍스트 응답 (컨테이너 healthcheck 가 읽는다)
|
|
const expressApp = app.getHttpAdapter().getInstance();
|
|
expressApp.get('/', (_req: ExpressRequest, res: ExpressResponse) => {
|
|
res.send('Backend API Server is running 정상 구동 중입니다.');
|
|
});
|
|
|
|
// 1. 공통 HTTP 파이프라인 — prefix · helmet · 쿠키 · 본문 파서 · 검증 · 표준 응답
|
|
// e2e 테스트도 같은 함수를 쓴다 (app.setup.ts)
|
|
configureApp(app);
|
|
|
|
// 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 없음)이거나 목록에 있는 출처일 때만 허용
|
|
const normalized = origin === undefined ? null : normalizeOrigin(origin);
|
|
if (!origin || (normalized !== null && allowedOrigins.has(normalized))) {
|
|
callback(null, true);
|
|
} else {
|
|
// Error 를 넘기면 500(SYS_001)이 되어 원인 파악이 어려우므로,
|
|
// CORS 헤더를 부여하지 않아 브라우저가 차단하도록 한다.
|
|
callback(null, false);
|
|
}
|
|
},
|
|
credentials: true, // 쿠키 교환 허용
|
|
});
|
|
|
|
// 3. Rate Limiting 은 AppModule 의 ThrottlerGuard(APP_GUARD)로 일원화되어 있다.
|
|
// 라우트 단위 제어(@Throttle / @SkipThrottle)가 가능하고, 한도 초과는 REQ_001(429)로 응답된다.
|
|
// (전역 예외 필터가 429 → REQ_001 로 매핑한다 — .claude/rules/error-codes.md)
|
|
|
|
// 4. Swagger API 문서 설정 — 인증은 웹은 HttpOnly 쿠키(access_token), 앱은 Authorization: Bearer (앱 로그인이 준 토큰만 — 계약 1절 "앱 로그인").
|
|
// 보호 라우트는 @ApiCookieAuth() 만 적는다 — 가드(JwtAuthGuard)가 두 길을 다 받으므로 Bearer 는 여기서 "또는"으로 한 번에 붙인다
|
|
const config = new DocumentBuilder()
|
|
.setTitle('Backend API Documentation')
|
|
.setDescription('The API documentation conforming to the global guidelines')
|
|
.setVersion('1.0')
|
|
.addCookieAuth(ACCESS_TOKEN_COOKIE, { type: 'apiKey' }, SWAGGER_COOKIE_AUTH)
|
|
.addBearerAuth(
|
|
{
|
|
type: 'http',
|
|
scheme: 'bearer',
|
|
bearerFormat: 'JWT',
|
|
description:
|
|
'앱(모바일) 전용 — POST /api/auth/app/login · refresh 가 준 액세스 토큰만 받는다(웹 토큰은 401). 웹은 쿠키를 쓴다',
|
|
},
|
|
SWAGGER_BEARER_AUTH,
|
|
)
|
|
.build();
|
|
const document = SwaggerModule.createDocument(app, config);
|
|
addAlternativeSecurity(document, SWAGGER_COOKIE_AUTH, SWAGGER_BEARER_AUTH);
|
|
SwaggerModule.setup('api-docs', app, document);
|
|
|
|
// 5. Graceful shutdown — SIGTERM 수신 시 새 요청을 받지 않고
|
|
// 처리 중인 요청을 마친 뒤 DB/Redis 커넥션을 정리하고 종료한다.
|
|
// (이 설정이 없으면 배포·재시작 때 처리 중이던 요청이 그대로 끊긴다)
|
|
app.enableShutdownHooks();
|
|
|
|
// 종료 로그는 ShutdownLogger(OnApplicationShutdown)가 남긴다.
|
|
|
|
// compose 는 정의되지 않은 변수를 빈 문자열로 넣는다 — Number('') 는 0 이라 parsePositiveInt 로 기본값을 쓴다
|
|
const port = parsePositiveInt(process.env.PORT, DEFAULT_PORT);
|
|
await app.listen(port, '0.0.0.0');
|
|
Logger.log(`🚀 Backend running on http://localhost:${port}/api`, 'Bootstrap');
|
|
Logger.log(
|
|
`📘 Swagger docs at http://localhost:${port}/api-docs`,
|
|
'Bootstrap',
|
|
);
|
|
}
|
|
|
|
void bootstrap();
|