본문으로 건너뛰기

인증 도메인 보안 구현 가이드

개요

이 문서는 인증 도메인의 보안 구현에 관한 개발자 가이드입니다. 인증 도메인의 보안 취약점과 이를 방지하기 위한 구현 방법을 설명합니다.

인증 보안 원칙

  • 심층 방어(Defense in Depth): 다중 보안 계층 구현
  • 최소 권한 원칙: 필요한 최소한의 권한만 부여
  • 안전한 기본값: 기본 설정이 가장 안전한 상태여야 함
  • 보안을 통한 모호함 지양: 투명한 보안 메커니즘 사용

구현 가이드

패스워드 처리

import { Injectable } from '@nestjs/common';
import * as bcrypt from 'bcrypt';

@Injectable()
export class PasswordService {
private readonly SALT_ROUNDS = 12;

/**
* 패스워드를 해시합니다.
* @param password 원본 패스워드
* @returns 해시된 패스워드
*/
async hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, this.SALT_ROUNDS);
}

/**
* 입력된 패스워드와 저장된 해시를 비교합니다.
* @param password 입력된 패스워드
* @param hashedPassword 저장된 해시
* @returns 일치 여부
*/
async validatePassword(password: string, hashedPassword: string): Promise<boolean> {
return bcrypt.compare(password, hashedPassword);
}
}

토큰 관리 및 검증 (Token-Based Authentication)

토큰 기반 인증은 상태 비저장(stateless) 인증 메커니즘을 구현하는 효과적인 방법입니다. JWT(JSON Web Token)가 널리 사용되며, 다음과 같은 보안 원칙을 고려하여 구현해야 합니다.

보안 원칙:

  • 알고리즘 선택: 강력한 비대칭 키 암호화 알고리즘(예: RS256) 사용을 권장합니다. 이를 위해 JWK(JSON Web Key) 기반의 키 관리가 필요합니다. 대칭 키 알고리즘(예: HS256)은 시크릿 키 관리에 더 많은 주의가 필요합니다.
  • 액세스 토큰(Access Token):
    • 짧은 유효 기간: 탈취 시 피해를 최소화하기 위해 유효 기간을 짧게 설정합니다(예: 15분 ~ 1시간).
    • 정보 최소화: 민감한 정보나 너무 많은 정보를 페이로드에 담지 않습니다. 필요한 최소한의 식별 정보와 권한 정보만 포함합니다.
    • Stateless 검증: 서버는 토큰 자체의 서명과 내용만으로 유효성을 검증할 수 있어야 합니다. (필요시 Redis 등 캐시를 이용한 추가 검증 가능)
  • 리프레시 토큰(Refresh Token):
    • 긴 유효 기간: 액세스 토큰보다 긴 유효 기간을 가집니다(예: 7일 ~ 30일). 사용자가 자주 로그인하는 불편함을 줄여줍니다.
    • 안전한 저장: 클라이언트 측(HttpOnly 쿠키 권장) 및 서버 측(DB 저장) 모두 안전하게 저장 및 관리되어야 합니다.
    • 토큰 회전(Rotation): 보안 강화를 위해 리프레시 토큰 사용 시 새로운 리프레시 토큰을 발급하고 이전 토큰은 무효화하는 회전 정책을 적용하는 것이 좋습니다.
    • 재사용 감지: 회전 정책 사용 시, 이미 사용된 리프레시 토큰이 다시 사용되면 탈취 가능성이 있으므로 해당 사용자의 모든 세션을 무효화하는 등의 조치가 필요합니다.
  • 전송 보안: 모든 토큰 관련 통신은 HTTPS를 통해 암호화되어야 합니다.
  • 적절한 범위(Scope) 및 대상(Audience): 토큰이 사용될 수 있는 범위(scope)와 대상 서비스(aud)를 명확히 정의하고 검증합니다.
  • 토큰 무효화: 로그아웃, 비밀번호 변경, 보안 이벤트 발생 시 관련 토큰(특히 리프레시 토큰 및 캐시된 액세스 토큰)을 명시적으로 무효화할 수 있는 메커니즘이 필요합니다.

구현 참조:

이 프로젝트에서의 실제 토큰 구현 방식은 다음 문서들을 참조하십시오:

// 아래는 토큰 서비스의 개념적 예시이며, 실제 구현은 위 참조 문서를 따릅니다.
/*
import { Injectable } from '@nestjs/common';
import { JwkManagementService } from './jwk-management.service'; // 가정
import { ConfigService } from '@nestjs/config';

@Injectable()
export abstract class BaseTokenService {
constructor(
protected readonly jwkService: JwkManagementService,
protected readonly configService: ConfigService,
) {}

abstract generateAccessToken(payload: Record<string, any>): Promise<string>;
abstract generateRefreshToken(payload: Record<string, any>): Promise<string>;
abstract verifyToken(token: string): Promise<Record<string, any>>;
}
*/

세션 관리

import { Injectable } from '@nestjs/common';
import { Redis } from 'ioredis';
import { InjectRedis } from '@nestjs/redis';

@Injectable()
export class SessionService {
constructor(
@InjectRedis() private readonly redis: Redis,
) {}

/**
* 세션을 생성합니다.
* @param userId 사용자 ID
* @param sessionData 세션 데이터
* @param ttlSeconds 세션 유효 시간(초)
*/
async createSession(
userId: string,
sessionData: Record<string, any>,
ttlSeconds = 3600 * 24, // 기본 24시간
): Promise<string> {
const sessionId = this.generateSessionId();
const key = `session:${sessionId}`;

// 세션 데이터에 사용자 ID 추가
const data = { userId, ...sessionData };

// Redis에 세션 저장
await this.redis.set(key, JSON.stringify(data), 'EX', ttlSeconds);

// 사용자별 활성 세션 추적
await this.redis.sadd(`user_sessions:${userId}`, sessionId);

return sessionId;
}

/**
* 세션을 무효화합니다.
* @param sessionId 세션 ID
*/
async invalidateSession(sessionId: string): Promise<void> {
const key = `session:${sessionId}`;
const sessionData = await this.redis.get(key);

if (sessionData) {
const { userId } = JSON.parse(sessionData);

// 세션 삭제
await this.redis.del(key);

// 사용자별 활성 세션에서 제거
await this.redis.srem(`user_sessions:${userId}`, sessionId);
}
}

/**
* 사용자의 모든 세션을 무효화합니다.
* @param userId 사용자 ID
*/
async invalidateAllUserSessions(userId: string): Promise<void> {
const key = `user_sessions:${userId}`;
const sessions = await this.redis.smembers(key);

// 모든 세션 삭제
for (const sessionId of sessions) {
await this.redis.del(`session:${sessionId}`);
}

// 사용자 세션 집합 삭제
await this.redis.del(key);
}

/**
* 고유한 세션 ID를 생성합니다.
*/
private generateSessionId(): string {
return require('crypto').randomBytes(16).toString('hex');
}
}

CSRF 보호

import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import * as crypto from 'crypto';

@Injectable()
export class CsrfProtectionMiddleware implements NestMiddleware {
async use(req: Request, res: Response, next: NextFunction): Promise<void> {
// CSRF 토큰 검증 로직
if (this.isMutatingMethod(req.method)) {
const csrfToken = req.headers['x-csrf-token'] as string;
const expectedToken = req.cookies['csrf-token'];

if (!csrfToken || !expectedToken || !this.safeCompare(csrfToken, expectedToken)) {
return res.status(403).json({ message: 'CSRF 토큰이 유효하지 않습니다.' });
}
}

// 새 CSRF 토큰 발급
if (!req.cookies['csrf-token']) {
const newToken = crypto.randomBytes(32).toString('hex');
res.cookie('csrf-token', newToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
});
}

next();
}

/**
* 상태를 변경하는 HTTP 메서드인지 확인합니다.
*/
private isMutatingMethod(method: string): boolean {
return ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method.toUpperCase());
}

/**
* 타이밍 공격에 안전한 문자열 비교를 수행합니다.
*/
private safeCompare(a: string, b: string): boolean {
return crypto.timingSafeEqual(
Buffer.from(a, 'utf8'),
Buffer.from(b, 'utf8')
);
}
}

보안 헤더 설정 (Helmet)

NestJS 애플리케이션에서 보안 헤더를 설정하는 예시:

// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import * as helmet from 'helmet';

async function bootstrap() {
const app = await NestFactory.create(AppModule);

// 보안 헤더 설정
app.use(helmet());

// Content-Security-Policy 커스터마이징
app.use(
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", 'trusted-cdn.com'],
styleSrc: ["'self'", "'unsafe-inline'", 'trusted-cdn.com'],
imgSrc: ["'self'", 'data:', 'trusted-cdn.com'],
connectSrc: ["'self'", 'api.example.com'],
fontSrc: ["'self'", 'trusted-cdn.com'],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"],
},
})
);

await app.listen(3000);
}
bootstrap();

보안 체크리스트

인증 관련

  • ✅ 패스워드는 bcrypt로 해싱
  • ✅ 모든 토큰은 적절한 만료 시간 설정
  • ✅ 세션/토큰 탈취 시 무효화 기능 구현
  • ✅ 보안에 민감한 작업 시 재인증 요구
  • ✅ 로그인 시도 횟수 제한 구현

통신 보안

  • ✅ HTTPS 사용 강제
  • ✅ 적절한 Content-Security-Policy 설정
  • ✅ CSRF 방어 구현
  • ✅ XSS 방어를 위한 입력값 검증 및 출력값 이스케이프
  • ✅ 민감한 쿠키는 HttpOnly, Secure, SameSite 속성 사용

권한 관리

  • ✅ 역할 기반 접근 제어(RBAC) 구현
  • ✅ API 엔드포인트마다 권한 확인
  • ✅ 직접 객체 참조(IDOR) 취약점 방지
  • ✅ 최소 권한 원칙 적용

로깅 및 모니터링

import { Injectable, Logger } from '@nestjs/common';

@Injectable()
export class SecurityAuditService {
private readonly logger = new Logger(SecurityAuditService.name);

/**
* 보안 관련 이벤트를 로깅합니다.
* @param userId 사용자 ID
* @param action 수행한 작업
* @param details 추가 상세 정보
* @param severity 심각도
*/
logSecurityEvent(
userId: string,
action: string,
details: Record<string, any>,
severity: 'info' | 'warning' | 'critical' = 'info',
): void {
const event = {
timestamp: new Date().toISOString(),
userId,
action,
ipAddress: details.ipAddress,
userAgent: details.userAgent,
details,
severity,
};

// 로그 레벨에 따라 적절한 로깅 메서드 호출
switch (severity) {
case 'critical':
this.logger.error(JSON.stringify(event));
break;
case 'warning':
this.logger.warn(JSON.stringify(event));
break;
default:
this.logger.log(JSON.stringify(event));
}

// 추가적으로 중앙 로깅 시스템에 전송할 수 있음
// this.sendToLogAggregator(event);
}
}

변경 이력

버전날짜작성자변경 사항
0.1.02025-03-29bok@weltcorp.com초기 버전 작성