인증 도메인 보안 구현 가이드
개요
이 문서는 인증 도메인의 보안 구현에 관한 개발자 가이드입니다. 인증 도메인의 보안 취약점과 이를 방지하기 위한 구현 방법을 설명합니다.
인증 보안 원칙
- 심층 방어(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)를 명확히 정의하고 검증합니다. - 토큰 무효화: 로그아웃, 비밀번호 변경, 보안 이벤트 발생 시 관련 토큰(특히 리프레시 토큰 및 캐시된 액세스 토큰)을 명시적으로 무효화할 수 있는 메커니즘이 필요합니다.
구현 참조:
이 프로젝트에서의 실제 토큰 구현 방식은 다음 문서들을 참조하십시오:
- 사용자 인증 토큰 (액세스/리프레시): JWT 인증 구현 가이드
- 앱 인증 토큰: 앱 토큰 구현 가이드
- JWK/RS256 기반 키 관리 및 처리: JWK 관리 서비스 구현 가이드
// 아래는 토큰 서비스의 개념적 예시이며, 실제 구현은 위 참조 문서를 따릅니다.
/*
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.0 | 2025-03-29 | bok@weltcorp.com | 초기 버전 작성 |