Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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');
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
File renamed without changes.
49 changes: 49 additions & 0 deletions src/common/utils/http-meta.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type { Request } from 'express';
import {
apiVersionOf,
clientIpOf,
tryClientIp,
tryUserAgent,
userAgentOf,
} from '@/common/utils/http-meta';

Expand Down Expand Up @@ -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);
});
});
});
37 changes: 29 additions & 8 deletions src/common/utils/http-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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 =
Expand Down
5 changes: 1 addition & 4 deletions src/common/utils/request-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
Expand Down
File renamed without changes.
18 changes: 0 additions & 18 deletions src/features/auth/helpers/auth-request-meta.helper.ts

This file was deleted.

9 changes: 3 additions & 6 deletions src/features/auth/services/seller-credential.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
});
}
}
23 changes: 11 additions & 12 deletions src/features/auth/services/token.service.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
});

Expand Down Expand Up @@ -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,
});

Expand All @@ -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 {
Expand All @@ -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);
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/global/filters/global-exception.filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/global/filters/graphql-exception.filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -44,6 +45,17 @@ async function bootstrap(): Promise<void> {
? 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<string>('TRUST_PROXY_HOPS')) || 0;
if (trustProxyHops > 0) {
const expressApp = app.getHttpAdapter().getInstance() as ExpressApplication;
expressApp.set('trust proxy', trustProxyHops);
}

// CORS 설정
app.enableCors({
origin: allowedOrigins,
Expand Down
Loading