From d474525dd2ad2f5a5d5188a4f7aecb91941610e2 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 28 May 2026 20:50:09 +0900 Subject: [PATCH] =?UTF-8?q?refactor(common):=20common=20=EA=B2=BD=EA=B3=84?= =?UTF-8?q?=20=EB=A3=B0=20=EC=A0=95=EB=A6=AC=20+=20IP/UA=20=EC=B6=94?= =?UTF-8?q?=EC=B6=9C=20=EB=8B=A8=EC=9D=BC=ED=99=94=20(P1-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1-4 본 작업과 함께, IP/UA 추출 로직이 두 곳으로 분기되어 운영에서 audit log / refresh session 의 IP 가 proxy IP 로 기록되던 문제도 함께 정리. common 경계 룰 정리: - crypto.helper.ts → utils/crypto.ts (순수 함수, NestJS DI 없음) - url-query.helper.ts → utils/url-query.ts (순수 함수) - error.helper.ts → utils/error.ts (HttpException 클래스만 사용, DI 매개변수 없음) - helpers/ 는 ConfigService 등 NestJS 서비스를 매개변수로 받는 경우만 잔존 (현재 config.helper.ts 만 해당) IP/UA 추출 단일화: - 기존 2 구현 분기 (utils/http-meta 의 정밀 + auth helper 의 naive) → utils/http-meta 단일 - utils/http-meta.ts 에 tryClientIp / tryUserAgent 추가 (DB persistence 용, undefined fallback) - 기존 clientIpOf / userAgentOf 는 try* 위에 'Unknown *' fallback 적용 (로깅용) - token.service / seller-credential.service: auth-request-meta 의 naive getIp/getUserAgent → utils/http-meta 의 tryClientIp/tryUserAgent 로 교체. audit log / refresh session 의 IP 가 X-Forwarded-For 기반으로 정확하게 기록되도록 정상화. - src/features/auth/helpers/auth-request-meta.helper.ts 삭제 (callsite 0) trust proxy 설정: - main.ts 에 env-based 적용 (TRUST_PROXY_HOPS). - 미설정 / 0: 비활성 (현 동작 그대로). - 운영에서 실제 reverse proxy hop 수 (ELB=1, ELB+CloudFront=2 등) 와 맞춰 설정해야 X-Forwarded-For 가 정상 신뢰됨. 잘못 설정 시 IP spoofing 위험이므로 인프라 검증 필요. 기타: - TokenService 의 sha256Hex / generateRefreshToken 인라인 구현 → utils/crypto 호출로 교체. 외부 API (TokenService.sha256Hex public method) 는 유지, 내부만 delegation. 부수 효과: - 신규 코드는 utils/ 에서 import (helpers/ → utils/ 경로 변경) - audit log / refresh session 의 ip_address 가 proxy 환경에서 실제 client IP 로 기록 (TRUST_PROXY_HOPS 활성화 시) --- .../crypto.spec.ts} | 4 +- .../crypto.helper.ts => utils/crypto.ts} | 0 .../error.spec.ts} | 4 +- .../error.helper.ts => utils/error.ts} | 0 src/common/utils/http-meta.spec.ts | 49 +++++++++++++++++++ src/common/utils/http-meta.ts | 37 +++++++++++--- src/common/utils/request-context.ts | 5 +- .../url-query.spec.ts} | 7 +-- .../url-query.ts} | 0 .../auth/helpers/auth-request-meta.helper.ts | 18 ------- .../services/seller-credential.service.ts | 9 ++-- src/features/auth/services/token.service.ts | 23 +++++---- src/global/filters/global-exception.filter.ts | 2 +- .../filters/graphql-exception.filter.ts | 2 +- src/main.ts | 12 +++++ 15 files changed, 113 insertions(+), 59 deletions(-) rename src/common/{helpers/crypto.helper.spec.ts => utils/crypto.spec.ts} (89%) rename src/common/{helpers/crypto.helper.ts => utils/crypto.ts} (100%) rename src/common/{helpers/error.helper.spec.ts => utils/error.spec.ts} (90%) rename src/common/{helpers/error.helper.ts => utils/error.ts} (100%) rename src/common/{helpers/url-query.helper.spec.ts => utils/url-query.spec.ts} (92%) rename src/common/{helpers/url-query.helper.ts => utils/url-query.ts} (100%) delete mode 100644 src/features/auth/helpers/auth-request-meta.helper.ts 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,