diff --git a/src/common/helpers/crypto.helper.spec.ts b/src/common/utils/crypto.spec.ts similarity index 89% rename from src/common/helpers/crypto.helper.spec.ts rename to src/common/utils/crypto.spec.ts index 743de93..ebe7a4a 100644 --- a/src/common/helpers/crypto.helper.spec.ts +++ b/src/common/utils/crypto.spec.ts @@ -1,6 +1,6 @@ -import { generateRandomToken, sha256Hex } from '@/common/helpers/crypto.helper'; +import { generateRandomToken, sha256Hex } from '@/common/utils/crypto'; -describe('crypto.helper', () => { +describe('crypto', () => { describe('sha256Hex', () => { it('동일 입력에 대해 동일 해시를 반환한다', () => { const hash1 = sha256Hex('hello'); diff --git a/src/common/helpers/crypto.helper.ts b/src/common/utils/crypto.ts similarity index 100% rename from src/common/helpers/crypto.helper.ts rename to src/common/utils/crypto.ts diff --git a/src/common/helpers/error.helper.spec.ts b/src/common/utils/error.spec.ts similarity index 90% rename from src/common/helpers/error.helper.spec.ts rename to src/common/utils/error.spec.ts index 8cdf690..161a42e 100644 --- a/src/common/helpers/error.helper.spec.ts +++ b/src/common/utils/error.spec.ts @@ -1,8 +1,8 @@ import { BadRequestException, HttpException } from '@nestjs/common'; -import { resolveMessage, resolveStatus } from '@/common/helpers/error.helper'; +import { resolveMessage, resolveStatus } from '@/common/utils/error'; -describe('error.helper', () => { +describe('error', () => { describe('resolveStatus', () => { it('HttpException이면 해당 상태 코드를 반환한다', () => { expect(resolveStatus(new BadRequestException())).toBe(400); diff --git a/src/common/helpers/error.helper.ts b/src/common/utils/error.ts similarity index 100% rename from src/common/helpers/error.helper.ts rename to src/common/utils/error.ts diff --git a/src/common/utils/http-meta.spec.ts b/src/common/utils/http-meta.spec.ts index 8a17405..0a6c7ae 100644 --- a/src/common/utils/http-meta.spec.ts +++ b/src/common/utils/http-meta.spec.ts @@ -3,6 +3,8 @@ import type { Request } from 'express'; import { apiVersionOf, clientIpOf, + tryClientIp, + tryUserAgent, userAgentOf, } from '@/common/utils/http-meta'; @@ -77,4 +79,51 @@ describe('http-meta', () => { expect(typeof result).toBe('string'); }); }); + + describe('tryClientIp (DB persistence 용 — 추출 실패 시 undefined)', () => { + it('X-Forwarded-For 첫 번째 IP를 반환한다', () => { + expect( + tryClientIp(mockReq({ 'x-forwarded-for': '1.2.3.4, 5.6.7.8' })), + ).toBe('1.2.3.4'); + }); + + it('X-Real-IP 를 반환한다', () => { + expect(tryClientIp(mockReq({ 'x-real-ip': '10.0.0.1' }))).toBe( + '10.0.0.1', + ); + }); + + it('req.ip 를 반환한다', () => { + expect(tryClientIp(mockReq({}, { ip: '192.168.0.1' }))).toBe( + '192.168.0.1', + ); + }); + + it('socket.remoteAddress 를 반환한다', () => { + expect(tryClientIp(mockReq())).toBe('127.0.0.1'); + }); + + it('아무것도 없으면 undefined 를 반환한다 (Unknown IP 문자열 아님)', () => { + expect( + tryClientIp(mockReq({}, { ip: undefined, socket: {} } as never)), + ).toBeUndefined(); + }); + }); + + describe('tryUserAgent (DB persistence 용)', () => { + it('User-Agent 헤더 raw 문자열을 반환한다 (useragent 파싱 X)', () => { + const raw = 'Mozilla/5.0 (Macintosh; Intel Mac OS X) AppleWebKit/537.36'; + expect(tryUserAgent(mockReq({ 'user-agent': raw }))).toBe(raw); + }); + + it('헤더가 없으면 undefined 를 반환한다', () => { + expect(tryUserAgent(mockReq())).toBeUndefined(); + }); + + it('512 자에서 자른다', () => { + const raw = 'a'.repeat(1000); + const result = tryUserAgent(mockReq({ 'user-agent': raw })); + expect(result).toHaveLength(512); + }); + }); }); diff --git a/src/common/utils/http-meta.ts b/src/common/utils/http-meta.ts index 51c1895..d1cb696 100644 --- a/src/common/utils/http-meta.ts +++ b/src/common/utils/http-meta.ts @@ -2,17 +2,22 @@ import type { Request } from 'express'; import useragent from 'useragent'; /** - * API 버전 헤더 추출 + * User-Agent 헤더 raw 값을 512 자 한도로 자른다. + * 없으면 undefined (DB nullable persistence 용). */ -export function apiVersionOf(req: Request): string | undefined { - const v = req.headers['api-version']; - return Array.isArray(v) ? v[0] : v; +export function tryUserAgent(req: Request): string | undefined { + const ua = req.headers['user-agent']; + return typeof ua === 'string' ? ua.slice(0, 512) : undefined; } /** - * 클라이언트 IP 추출(X-Forwarded-For 우선) + * Client IP 를 추출한다 (X-Forwarded-For → X-Real-IP → req.ip → socket.remoteAddress 순). + * 추출 실패 시 undefined (DB nullable persistence 용). + * + * 운영 환경에서 정확한 client IP 를 얻으려면 main.ts 의 trust proxy 설정 필요 + * (Express 가 X-Forwarded-For 를 신뢰해서 req.ip 에 반영). */ -export function clientIpOf(req: Request): string { +export function tryClientIp(req: Request): string | undefined { const forwardedFor = req.headers['x-forwarded-for']; if (typeof forwardedFor === 'string' && forwardedFor.trim().length > 0) { const forwardedIp = forwardedFor.split(',').map((ip) => ip.trim())[0]; @@ -25,11 +30,27 @@ export function clientIpOf(req: Request): string { } const ip = req.ip ?? req.socket?.remoteAddress; - return typeof ip === 'string' && ip.length > 0 ? ip : 'Unknown IP'; + return typeof ip === 'string' && ip.length > 0 ? ip : undefined; +} + +/** + * API 버전 헤더 추출 + */ +export function apiVersionOf(req: Request): string | undefined { + const v = req.headers['api-version']; + return Array.isArray(v) ? v[0] : v; +} + +/** + * 클라이언트 IP 추출 (로깅용 — 추출 실패 시 'Unknown IP' fallback) + */ +export function clientIpOf(req: Request): string { + return tryClientIp(req) ?? 'Unknown IP'; } /** - * User-Agent 문자열 정규화 + * User-Agent 문자열 정규화 (로깅용 — useragent 라이브러리로 파싱). + * raw 값은 tryUserAgent 사용 (DB persistence 용). */ export function userAgentOf(req: Request): string { const raw = diff --git a/src/common/utils/request-context.ts b/src/common/utils/request-context.ts index 4669bd7..4aef6ea 100644 --- a/src/common/utils/request-context.ts +++ b/src/common/utils/request-context.ts @@ -3,15 +3,12 @@ import { randomUUID } from 'node:crypto'; import type { Request, Response } from 'express'; import type { GraphQLResolveInfo } from 'graphql'; -import { - buildQueryString, - toQueryParams, -} from '@/common/helpers/url-query.helper'; import { apiVersionOf, clientIpOf, userAgentOf, } from '@/common/utils/http-meta'; +import { buildQueryString, toQueryParams } from '@/common/utils/url-query'; export const REQUEST_ID_HEADER = 'x-request-id'; export const RESPONSE_TIME_HEADER = 'x-response-time-ms'; diff --git a/src/common/helpers/url-query.helper.spec.ts b/src/common/utils/url-query.spec.ts similarity index 92% rename from src/common/helpers/url-query.helper.spec.ts rename to src/common/utils/url-query.spec.ts index 0c4e65f..2d40952 100644 --- a/src/common/helpers/url-query.helper.spec.ts +++ b/src/common/utils/url-query.spec.ts @@ -1,9 +1,6 @@ -import { - buildQueryString, - toQueryParams, -} from '@/common/helpers/url-query.helper'; +import { buildQueryString, toQueryParams } from '@/common/utils/url-query'; -describe('url-query.helper', () => { +describe('url-query', () => { describe('buildQueryString', () => { it('단순 키=값 쌍을 변환한다', () => { expect(buildQueryString({ a: '1', b: '2' })).toBe('a=1&b=2'); diff --git a/src/common/helpers/url-query.helper.ts b/src/common/utils/url-query.ts similarity index 100% rename from src/common/helpers/url-query.helper.ts rename to src/common/utils/url-query.ts diff --git a/src/features/auth/helpers/auth-request-meta.helper.ts b/src/features/auth/helpers/auth-request-meta.helper.ts deleted file mode 100644 index a0b8b6b..0000000 --- a/src/features/auth/helpers/auth-request-meta.helper.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Request } from 'express'; - -/** - * Request 에서 user-agent 를 추출한다 (audit/refresh session 메타용). - * - * 길이 512 자에서 자른다. - */ -export function getUserAgent(req: Request): string | undefined { - const ua = req.headers['user-agent']; - return typeof ua === 'string' ? ua.slice(0, 512) : undefined; -} - -/** - * Request 에서 IP 를 추출한다. - */ -export function getIp(req: Request): string | undefined { - return typeof req.ip === 'string' ? req.ip : undefined; -} diff --git a/src/features/auth/services/seller-credential.service.ts b/src/features/auth/services/seller-credential.service.ts index 7301423..d1d567b 100644 --- a/src/features/auth/services/seller-credential.service.ts +++ b/src/features/auth/services/seller-credential.service.ts @@ -15,14 +15,11 @@ import argon2 from 'argon2'; import type { Request, Response } from 'express'; import { ClockService } from '@/common/providers/clock.service'; +import { tryClientIp, tryUserAgent } from '@/common/utils/http-meta'; import { AUDIT_LOG_REPOSITORY, type IAuditLogRepository, } from '@/features/audit-log'; -import { - getIp, - getUserAgent, -} from '@/features/auth/helpers/auth-request-meta.helper'; import { REFRESH_SESSION_REPOSITORY, type IRefreshSessionRepository, @@ -213,8 +210,8 @@ export class SellerCredentialService implements ISellerCredentialService { afterJson: { changedAt: now.toISOString(), }, - ipAddress: getIp(args.req), - userAgent: getUserAgent(args.req), + ipAddress: tryClientIp(args.req), + userAgent: tryUserAgent(args.req), }); } } diff --git a/src/features/auth/services/token.service.ts b/src/features/auth/services/token.service.ts index fc73f7a..51706d7 100644 --- a/src/features/auth/services/token.service.ts +++ b/src/features/auth/services/token.service.ts @@ -1,17 +1,16 @@ -import { createHash, randomBytes } from 'node:crypto'; - import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import type { Request, Response } from 'express'; import { getEnvAsNumber } from '@/common/helpers/config.helper'; +import { + generateRandomToken, + sha256Hex as sha256HexUtil, +} from '@/common/utils/crypto'; +import { tryClientIp, tryUserAgent } from '@/common/utils/http-meta'; import { AuthCookieOptions } from '@/features/auth/helpers/auth-cookie-options.helper'; import { AuthCookie } from '@/features/auth/helpers/auth-cookie.helper'; -import { - getIp, - getUserAgent, -} from '@/features/auth/helpers/auth-request-meta.helper'; import { REFRESH_SESSION_REPOSITORY, type IRefreshSessionRepository, @@ -73,8 +72,8 @@ export class TokenService implements ITokenService { await this.refreshSessions.createRefreshSession({ accountId: args.accountId, tokenHash: refreshHash, - userAgent: getUserAgent(args.req), - ipAddress: getIp(args.req), + userAgent: tryUserAgent(args.req), + ipAddress: tryClientIp(args.req), expiresAt, }); @@ -116,8 +115,8 @@ export class TokenService implements ITokenService { currentSessionId: session.id, accountId: session.account_id, newTokenHash, - userAgent: getUserAgent(req), - ipAddress: getIp(req), + userAgent: tryUserAgent(req), + ipAddress: tryClientIp(req), newExpiresAt, }); @@ -138,7 +137,7 @@ export class TokenService implements ITokenService { } sha256Hex(raw: string): string { - return createHash('sha256').update(raw).digest('hex'); + return sha256HexUtil(raw); } clearRefreshCookie(res: Response): void { @@ -154,7 +153,7 @@ export class TokenService implements ITokenService { * refresh token 랜덤 문자열을 생성한다. (32 bytes → 64 hex) */ private generateRefreshToken(): string { - return randomBytes(32).toString('hex'); + return generateRandomToken(32); } /** diff --git a/src/global/filters/global-exception.filter.ts b/src/global/filters/global-exception.filter.ts index 808b6f0..22b2c7b 100644 --- a/src/global/filters/global-exception.filter.ts +++ b/src/global/filters/global-exception.filter.ts @@ -4,7 +4,7 @@ import type { GqlContextType } from '@nestjs/graphql'; import type { Request, Response } from 'express'; import type { GraphQLError } from 'graphql'; -import { resolveMessage, resolveStatus } from '@/common/helpers/error.helper'; +import { resolveMessage, resolveStatus } from '@/common/utils/error'; import { buildHttpRequestMeta, calculateDuration, diff --git a/src/global/filters/graphql-exception.filter.ts b/src/global/filters/graphql-exception.filter.ts index 57d7dd8..30d2f8e 100644 --- a/src/global/filters/graphql-exception.filter.ts +++ b/src/global/filters/graphql-exception.filter.ts @@ -3,7 +3,7 @@ import { GqlArgumentsHost } from '@nestjs/graphql'; import type { Request } from 'express'; import { GraphQLError, type GraphQLResolveInfo } from 'graphql'; -import { resolveMessage, resolveStatus } from '@/common/helpers/error.helper'; +import { resolveMessage, resolveStatus } from '@/common/utils/error'; import { buildGraphqlRequestMeta, calculateDuration, diff --git a/src/main.ts b/src/main.ts index 9936e37..367b6f5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,6 +9,7 @@ import { HttpAdapterHost, NestFactory } from '@nestjs/core'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import type { ValidationError } from 'class-validator'; import cookieParser from 'cookie-parser'; +import type { Application as ExpressApplication } from 'express'; import { AppModule } from '@/app.module'; import { HttpExceptionFilter } from '@/global/filters/global-exception.filter'; @@ -44,6 +45,17 @@ async function bootstrap(): Promise { ? frontendFromEnv : ['http://localhost:3000']; + // Trust proxy (env-based) — reverse proxy 뒤에서 X-Forwarded-For 처리. + // TRUST_PROXY_HOPS = proxy hop 수 (ex. ELB 1대 → 1, CloudFront+ELB → 2). + // 미설정 / 0 이면 비활성 (default Express 동작). 잘못 설정 시 IP spoofing 위험이므로 + // 운영 인프라 (ELB/CloudFront/Nginx) hop 수를 정확히 맞춰야 한다. + const trustProxyHops = + Number(configService.get('TRUST_PROXY_HOPS')) || 0; + if (trustProxyHops > 0) { + const expressApp = app.getHttpAdapter().getInstance() as ExpressApplication; + expressApp.set('trust proxy', trustProxyHops); + } + // CORS 설정 app.enableCors({ origin: allowedOrigins,