이메일 인증 구현 가이드
1. 개요
1.1 목적
본 문서는 사용자 인증 과정 중 이메일 주소의 유효성을 검증하기 위한 인증 코드 발송 및 확인 기능의 상세 구현 가이드라인을 제공합니다.
1.2 범위
- 이메일 인증 코드 발송 API (
POST /auth/email/verification-code) 구현 상세 - 이메일 인증 코드 확인 API (
POST /auth/email/verify) 구현 상세 - 관련 서비스(
EmailService) 로직 상세 (OTP 생성, 임시 저장, 이메일 발송, 코드 검증) - 데이터 저장소(Redis) 활용 방안
- 보안 고려 사항 및 오류 처리
2. 아키텍처 및 의존성
이메일 인증 기능은 주로 Auth 도메인 내에서 처리되며, 다음과 같은 주요 컴포넌트와 의존성을 가집니다.
RegistrationController(또는AuthController): API 요청 수신 및EmailService호출EmailService: 핵심 비즈니스 로직 수행- 의존성:
RedisService: 인증 코드 임시 저장 및 조회@sendgrid/mail클라이언트 (또는 SendGrid 래퍼 서비스): 실제 이메일 발송 처리UserRepository(선택적): 이메일 중복 확인 등ConfigService: 코드 유효 시간, 재시도 횟수, 발신자 주소 등 설정값 조회 (@core/config의ConfigService사용)LoggerService: 로깅
- 의존성:
RedisService: Core 인프라 모듈의 Redis 클라이언트 래퍼
3. API 구현 상세
3.1 이메일 인증 코드 발송 (POST /auth/email/verification-code)
3.1.1 DTO 정의
// 파일 경로: libs/shared/contracts-auth/dto/src/lib/auth/email-verification.dto.ts
import { IsEmail, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 요청 DTO
export class SendVerificationCodeDto {
@ApiProperty({ description: '인증 코드를 받을 이메일 주소', example: 'user@example.com' })
@IsEmail({}, { message: '유효한 이메일 주소를 입력해주세요.' })
@IsNotEmpty({ message: '이메일 주소는 필수입니다.' })
email: string;
}
// 응답 DTO
export interface EmailVerificationResultDto {
success: boolean;
email: string;
expiresIn: number; // 코드 유효 시간(초)
requestId: string; // 요청 추적 ID
}
3.1.2 Controller 구현
Controller는 요청 유효성 검사 후 EmailService를 호출하는 역할을 합니다.
// 파일 경로: apps/dta-wide-api/src/app/auth/controllers/registration.controller.ts
// (또는 terms-and-consents.controller.ts)
import { Controller, Post, Body, UseGuards, Inject } from '@nestjs/common';
import { AppTokenGuard } from '@core/auth/guards'; // AppTokenGuard 경로 확인
import { SendVerificationCodeDto, EmailVerificationResultDto } from '@shared-contracts/auth/dto'; // DTO 경로 확인
import { EmailService } from '@feature/auth-core'; // EmailService 경로 확인
import { ApiResponse } from '@shared/api-types'; // ApiResponse 타입 확인
@Controller('auth')
export class RegistrationController {
constructor(
@Inject(EmailService) private readonly emailService: EmailService // EmailService 주입
) {}
@Post('email/verification-code')
@UseGuards(AppTokenGuard) // 앱 토큰 인증 필요
async sendVerificationCode(
@Body() dto: SendVerificationCodeDto
): Promise<ApiResponse<EmailVerificationResultDto>> {
// EmailService의 메서드 호출하여 로직 수행
const result = await this.emailService.sendVerificationCode(dto.email);
// 성공 응답 반환
return {
data: {
success: true,
email: dto.email,
expiresIn: result.expiresIn, // 서비스에서 반환된 유효 시간 사용
requestId: result.requestId, // 요청 ID 반환
},
};
}
// ... 기타 메서드 ...
}
3.1.3 EmailService.sendVerificationCode 상세 로직
이 메서드는 이메일 인증 코드 생성, 저장, 발송의 핵심 로직을 담당합니다.
// 파일 경로: libs/feature/auth/src/lib/domain/services/email.service.ts
import { Injectable, BadRequestException, InternalServerErrorException, Inject } from '@nestjs/common';
import { RedisService } from '@core/redis';
import { ConfigService } from '@core/config';
import { LoggerService } from '@core/logging';
import * as crypto from 'crypto';
import * as sgMail from '@sendgrid/mail';
import { AuthErrorFactory, AuthErrorCode } from '@feature/auth-core';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class EmailService {
private readonly VERIFICATION_CODE_TTL_SECONDS: number;
private readonly VERIFICATION_CODE_KEY_PREFIX = 'email-verify:';
constructor(
@Inject(RedisService) private readonly redisService: RedisService,
private readonly configService: ConfigService,
@Inject(LoggerService) private readonly logger: LoggerService,
) {
this.VERIFICATION_CODE_TTL_SECONDS = this.configService.getConfig<number>('auth', 'emailVerification.codeTtlSeconds', 300); // 네임스페이스 사용 예시
}
/**
* 지정된 이메일로 인증 코드를 생성하고 SendGrid를 통해 발송합니다.
* @param email 대상 이메일 주소
* @returns 코드 유효 시간(초)
*/
async sendVerificationCode(email: string): Promise<{ expiresIn: number; requestId: string }> {
this.logger.log(`Attempting to send verification code to ${email}`, EmailService.name);
const verificationCode = this.generateOtp();
const requestId = uuidv4();
const redisKey = `${this.VERIFICATION_CODE_KEY_PREFIX}${email}`;
const verificationData = {
code: verificationCode,
attempts: 0,
requestId: requestId
};
try {
await this.redisService.setex(redisKey, this.VERIFICATION_CODE_TTL_SECONDS, JSON.stringify(verificationData));
this.logger.debug(`Verification data for ${email} (requestId: ${requestId}) stored in Redis with TTL ${this.VERIFICATION_CODE_TTL_SECONDS}s`, EmailService.name);
} catch (error) {
this.logger.error(`Failed to store verification data in Redis for ${email}: ${error.message}`, error.stack, EmailService.name);
throw AuthErrorFactory.create(AuthErrorCode.SERVER_ERROR, 'Redis 저장 실패');
}
// 3. SendGrid를 사용하여 이메일 발송
const msg = {
to: email,
// CoreConfigModule의 ConfigService를 사용하여 설정값 조회
from: this.configService.getConfig<string>('externalApi', 'sendgrid.fromEmail', 'noreply@sleepq.ai'),
subject: '[SleepQ] 이메일 인증 코드 안내',
html: this.generateVerificationEmailHtml(verificationCode),
};
try {
// CoreConfigModule의 ConfigService를 사용하여 API 키 설정 여부 확인
if (!this.configService.get<string>('SENDGRID_API_KEY')) {
this.logger.error('SendGrid API Key is not configured. Cannot send email.', EmailService.name);
throw AuthErrorFactory.create(AuthErrorCode.SERVER_ERROR, '이메일 서비스 설정 오류');
}
await sgMail.send(msg);
this.logger.log(`Verification code successfully sent to ${email} via SendGrid`, EmailService.name);
} catch (error) {
this.logger.error(`Failed to send verification email to ${email} via SendGrid: ${error.message}`, error.stack, EmailService.name);
// SendGrid 오류 응답 본문 로깅 (오류 객체 구조 확인 필요)
if (error.response) {
this.logger.error(`SendGrid error response body: ${JSON.stringify(error.response.body)}`, EmailService.name);
}
// 이메일 발송 실패 시, 사용자에게 코드가 전달되지 않았으므로 Redis에 저장된 코드를 삭제하는 것을 권장합니다.
await this.redisService.del(redisKey);
this.logger.log(`Rolled back verification code from Redis for ${email} due to send failure.`, EmailService.name);
throw AuthErrorFactory.create(AuthErrorCode.EMAIL_SEND_FAILED, '이메일 발송 실패', { sendgridError: error.message });
}
return { expiresIn: this.VERIFICATION_CODE_TTL_SECONDS, requestId: requestId };
}
// 6자리 숫자 OTP 생성 헬퍼
private generateOtp(): string {
return crypto.randomInt(100000, 999999).toString();
}
// 인증 이메일 HTML 내용 생성 헬퍼 (예시)
private generateVerificationEmailHtml(code: string): string {
const expiresInMinutes = Math.floor(this.VERIFICATION_CODE_TTL_SECONDS / 60);
// 실제 운영에서는 보다 정교한 HTML 템플릿 엔진 사용 권장
return `
<div style="font-family: sans-serif; padding: 20px; border: 1px solid #eee; border-radius: 5px;">
<h2 style="color: #333;">DTA-WIDE 이메일 인증</h2>
<p>안녕하세요,</p>
<p>요청하신 이메일 인증 코드는 다음과 같습니다. 이 코드는 ${expiresInMinutes}분 동안 유효합니다.</p>
<p style="font-size: 24px; font-weight: bold; color: #007bff; letter-spacing: 2px; margin: 20px 0;">
${code}
</p>
<p>이 코드를 인증 화면에 입력해주세요.</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
<p style="font-size: 12px; color: #888;">본인이 요청하지 않은 경우 이 이메일을 무시해주세요.</p>
</div>
`;
}
// ... verifyCode, isEmailVerified 등 다른 메서드 ...
}
3.2 이메일 인증 코드 확인 (POST /auth/email/registration-verify)
3.2.1 DTO 정의
// 파일 경로: libs/shared/contracts-auth/dto/src/lib/auth/email-verification.dto.ts
import { IsEmail, IsNotEmpty, IsString, Length, IsUUID } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 요청 DTO
export class VerifyEmailCodeDto {
@ApiProperty({ description: '인증 코드를 받은 이메일 주소', example: 'user@example.com' })
@IsEmail({}, { message: '유효한 이메일 주소를 입력해주세요.' })
@IsNotEmpty({ message: '이메일 주소는 필수입니다.' })
email: string;
@ApiProperty({ description: '이메일로 받은 6자리 인증 코드', example: '123456' })
@IsString()
@Length(6, 6, { message: '인증 코드는 6자리여야 합니다.' })
@IsNotEmpty({ message: '인증 코드는 필수입니다.' })
code: string;
@ApiProperty({ description: '코드 발송 요청 시 받은 요청 ID', example: 'req_123' })
@IsNotEmpty({ message: '요청 ID는 필수입니다.' })
@IsString()
requestId: string;
}
// 응답 DTO
export interface EmailVerificationStatusDto {
verified: boolean;
email: string;
// verificationToken?: string; // 상태 저장 대신 토큰 반환 방식 사용 시
}
3.2.2 Controller 구현
// 파일 경로: apps/dta-wide-api/src/app/auth/controllers/registration.controller.ts
// (또는 terms-and-consents.controller.ts)
import { Controller, Post, Body, UseGuards, BadRequestException, Inject } from '@nestjs/common';
import { AppTokenGuard } from '@core/auth/guards';
import { VerifyEmailCodeDto, EmailVerificationStatusDto } from '@shared-contracts/auth/dto';
import { EmailService } from '@feature/auth-core';
import { ApiResponse } from '@shared/api-types';
@Controller('auth')
export class RegistrationController {
constructor(
@Inject(EmailService) private readonly emailService: EmailService
) {}
@Post('email/registration-verify')
@UseGuards(AppTokenGuard)
async verifyEmailCode(
@Body() dto: VerifyEmailCodeDto
): Promise<ApiResponse<EmailVerificationStatusDto>> {
// EmailService의 메서드 호출하여 코드 검증
const isValid = await this.emailService.verifyCode(dto.email, dto.code, dto.requestId);
if (!isValid) {
// 서비스 내부에서 던져진 특정 예외를 그대로 반환하거나, 여기서 BadRequestException 발생
throw new BadRequestException({
code: 1055, // 또는 서비스에서 정의된 구체적인 오류 코드
message: 'INVALID_VERIFICATION_CODE',
detail: '유효하지 않거나 만료된 인증 코드입니다.'
});
}
// (선택적) 인증 완료 상태 저장
// await this.emailService.markEmailAsVerified(dto.email);
return {
data: {
verified: true,
email: dto.email,
},
};
}
// ... 기타 메서드 ...
}
3.2.3 EmailService.verifyCode 상세 로직
이 메서드는 Redis에서 코드를 조회하고 사용자가 입력한 코드와 비교하며, 재시도 횟수 제한을 적용합니다.
// 파일 경로: libs/feature/auth/src/lib/domain/services/email.service.ts
import { Injectable, BadRequestException, NotFoundException, Inject, InternalServerErrorException } from '@nestjs/common';
import { RedisService } from '@core/redis';
import { AuthErrorFactory, AuthErrorCode } from '@feature/auth-core';
import { ConfigService } from '@core/config';
import { LoggerService } from '@core/logging';
@Injectable()
export class EmailService {
private readonly VERIFICATION_CODE_KEY_PREFIX = 'email-verify:';
private readonly VERIFIED_EMAIL_KEY_PREFIX = 'email-verified:';
private readonly VERIFIED_EMAIL_TTL_SECONDS: number;
private readonly MAX_VERIFICATION_ATTEMPTS = 10; // 최대 시도 횟수 정의
constructor(
@Inject(RedisService) private readonly redisService: RedisService,
private readonly configService: ConfigService,
@Inject(LoggerService) private readonly logger: LoggerService,
) {
this.VERIFIED_EMAIL_TTL_SECONDS = this.configService.get<number>('auth.emailVerification.verifiedTtlSeconds', 1800); // 기본 30분
}
/**
* 제공된 이메일, 코드, 요청 ID가 유효한지 검증하고, 시도 횟수를 제한합니다.
* @param email 검증할 이메일 주소
* @param code 사용자가 입력한 인증 코드
* @param requestId 코드 발송 시 받은 요청 ID
* @returns 코드가 유효하면 true, 아니면 false 또는 예외 발생
*/
async verifyCode(email: string, code: string, requestId: string): Promise<boolean> {
this.logger.log(`Attempting to verify code for ${email} with requestId ${requestId}`, EmailService.name);
const redisKey = `${this.VERIFICATION_CODE_KEY_PREFIX}${email}`;
try {
const storedDataString = await this.redisService.get(redisKey);
if (!storedDataString) {
this.logger.warn(`Verification data not found or expired for ${email}`, EmailService.name);
throw AuthErrorFactory.create(AuthErrorCode.VERIFICATION_CODE_EXPIRED);
}
const storedData: VerificationData & { requestId?: string } = JSON.parse(storedDataString);
// 1. Request ID 검증 (선택적이지만 권장)
if (storedData.requestId && storedData.requestId !== requestId) {
this.logger.warn(`Request ID mismatch for ${email}. Expected ${storedData.requestId}, got ${requestId}.`, EmailService.name);
throw AuthErrorFactory.create(AuthErrorCode.INVALID_VERIFICATION_CODE, 'Invalid request ID.');
}
// 2. 저장된 코드와 사용자 입력 코드 비교
if (storedData.code === code) {
this.logger.log(`Verification code matched for ${email}`, EmailService.name);
// 성공 시 Redis에서 코드 삭제 (일회용)
await this.redisService.del(redisKey);
// 인증 완료 상태 저장
await this.markEmailAsVerified(email);
return true;
} else {
// 3. 코드 불일치 시 시도 횟수 증가 및 처리
storedData.attempts += 1;
const remainingAttempts = this.MAX_VERIFICATION_ATTEMPTS - storedData.attempts;
this.logger.warn(`Verification code mismatch for ${email}. Attempt ${storedData.attempts}/${this.MAX_VERIFICATION_ATTEMPTS}. Remaining: ${remainingAttempts}`, EmailService.name);
if (storedData.attempts >= this.MAX_VERIFICATION_ATTEMPTS) {
// 최대 시도 횟수 초과 시 코드 삭제 및 특정 오류 발생
this.logger.warn(`Max verification attempts reached for ${email}. Deleting code.`, EmailService.name);
await this.redisService.del(redisKey);
// meta에 남은 시도 횟수(0) 포함
throw AuthErrorFactory.create(AuthErrorCode.VERIFICATION_ATTEMPTS_EXCEEDED, undefined, { remainingAttempts: 0 });
} else {
// 시도 횟수 업데이트하여 Redis에 다시 저장
const remainingTtl = await this.redisService.ttl(redisKey);
if (remainingTtl > 0) {
await this.redisService.setex(redisKey, remainingTtl, JSON.stringify(storedData));
} else {
this.logger.warn(`Verification code expired just before attempt update for ${email}`, EmailService.name);
throw AuthErrorFactory.create(AuthErrorCode.VERIFICATION_CODE_EXPIRED);
}
// meta에 남은 시도 횟수 포함
throw AuthErrorFactory.create(AuthErrorCode.INVALID_VERIFICATION_CODE, undefined, { remainingAttempts });
}
}
} catch (error) {
// Redis 오류 등 예외 처리
if (error instanceof NotFoundException || error instanceof BadRequestException) {
throw error; // 특정 예외는 그대로 전달 (AuthError 포함)
}
this.logger.error(`Error verifying code for ${email}: ${error.message}`, error.stack, EmailService.name);
throw AuthErrorFactory.create(AuthErrorCode.SERVER_ERROR, error.message, { originalError: error.stack });
}
}
/**
* 이메일 인증이 완료되었음을 표시합니다. (회원가입 단계에서 사용)
* @param email 인증된 이메일 주소
*/
async markEmailAsVerified(email: string): Promise<void> {
const redisKey = `${this.VERIFIED_EMAIL_KEY_PREFIX}${email}`;
try {
// 일정 시간 동안만 유효한 상태 저장 (예: 회원가입 완료 시점까지)
await this.redisService.setex(redisKey, this.VERIFIED_EMAIL_TTL_SECONDS, 'true');
this.logger.log(`Marked email ${email} as verified`, EmailService.name);
} catch (error) {
this.logger.error(`Failed to mark email ${email} as verified: ${error.message}`, error.stack, EmailService.name);
// 이 오류가 치명적이지 않다면 로깅만 하고 넘어갈 수 있음
}
}
/**
* 특정 이메일이 인증되었는지 확인합니다. (회원가입 최종 단계에서 사용)
* @param email 확인할 이메일 주소
* @returns 인증되었으면 true, 아니면 false
*/
async isEmailVerified(email: string): Promise<boolean> {
const redisKey = `${this.VERIFIED_EMAIL_KEY_PREFIX}${email}`;
try {
const result = await this.redisService.get(redisKey);
return result === 'true';
} catch (error) {
this.logger.error(`Failed to check verification status for ${email}: ${error.message}`, error.stack, EmailService.name);
return false; // 오류 발생 시 false 반환
}
}
// ... sendVerificationCode, generateOtp 등 다른 메서드 ...
}
4. 데이터 저장소 (Redis)
- 인증 코드 저장:
- 키 형식:
email-verify:{email}(예:email-verify:user@example.com) - 값: 인증 코드와 시도 횟수를 포함하는 JSON 문자열. 예:
'{ "code": "123456", "attempts": 0 }' - TTL: 설정된 유효 시간 (예: 300초 = 5분) (
SETEX명령어 사용)
- 키 형식:
- 인증 완료 상태 저장:
- 키 형식:
email-verified:{email}(예:email-verified:user@example.com) - 값:
'true'(문자열) - TTL: 설정된 유효 시간 (예: 1800초 = 30분, 회원가입 완료 시점까지 유효해야 함) (
SETEX명령어 사용)
- 키 형식:
5. 보안 고려 사항
- Rate Limiting 구현:
- 목표: 서비스 거부 공격(DoS) 방지 및 시스템 가용성 보장을 위해 이메일 인증 코드 발송 API(
POST /auth/email/verification-code)에 대한 요청 빈도를 제한합니다. - 도구:
@nestjs/throttler모듈을 활용하여 구현합니다. Redis를 저장소로 사용하여 여러 인스턴스 간에 상태를 공유합니다. (@nestjs/throttler가 Redis를 사용하도록 설정 필요) - 정책:
- IP 주소 기준: 동일 IP 주소에서 1분당 60회 요청으로 제한합니다.
- 이메일 주소 기준: 동일 이메일 주소로 10분당 5회 요청으로 제한합니다.
- 제한 초과 시:
429 Too Many Requests오류를 반환하고, 응답 헤더에Retry-After정보를 포함하여 클라이언트에게 재시도 가능 시간을 안내합니다.
- 구현 방안 (권장: 커스텀 가드에서 통합 처리):
-
커스텀
EmailThrottlerGuard구현:ThrottlerGuard를 상속받아handleRequest메서드 내에서 IP 기준과 이메일 기준 제한을 모두 확인합니다.- IP 추적기:
super.getTracker(req)또는 직접 구현 (예:req.ip) - 이메일 추적기:
IP:email형태 (예:127.0.0.1:user@example.com) storageService.increment()를 각 추적기에 대해 호출하여 제한을 검사합니다.- 제한 초과 시
ThrottlerException을 발생시키고Retry-After헤더를 설정합니다.
// 예시: libs/core/auth/src/lib/guards/email-throttler.guard.ts
// (이전 예시 코드와 유사하나, IP/Email 제한 통합 처리 강조)
import { ThrottlerGuard, ThrottlerOptions, ThrottlerException } from '@nestjs/throttler';
import { Injectable, ExecutionContext, Logger, Inject } from '@nestjs/common'; // Inject 추가
import { Reflector } from '@nestjs/core';
import { ThrottlerStorage } from '@nestjs/throttler/dist/throttler-storage.interface';
import { THROTTLER_OPTIONS } from '@nestjs/throttler/dist/throttler.constants'; // 실제 경로 확인 필요
@Injectable()
export class EmailThrottlerGuard extends ThrottlerGuard {
private readonly logger = new Logger(EmailThrottlerGuard.name);
// 생성자에서 기본 limit/ttl 주입 또는 직접 설정 가능
constructor(
@Inject(THROTTLER_OPTIONS) protected readonly options: ThrottlerOptions | ThrottlerOptions[],
protected readonly storageService: ThrottlerStorage,
protected readonly reflector: Reflector,
) {
super(options, storageService, reflector);
}
protected async handleRequest(
context: ExecutionContext,
// @SetMetadata 또는 기본값에서 limit/ttl 가져오기
limit: number = 60, // IP 기본 limit
ttl: number = 60000, // IP 기본 ttl (ms)
): Promise<boolean> {
const { req, res } = this.getRequestResponse(context);
const trackerIp = this.getTracker(req); // IP 주소
// IP 기준 제한 확인
const ipLimit = limit;
const ipTtlSec = ttl / 1000;
const ipTtlResult = await this.storageService.increment(trackerIp, ipTtlSec);
this.logger.debug(`IP Rate Limit: ${trackerIp} - Hits: ${ipTtlResult.totalHits}/${ipLimit}, TTL: ${ipTtlResult.timeToExpire}s`);
if (ipTtlResult.totalHits > ipLimit) {
const retryAfter = Math.ceil(ipTtlResult.timeToExpire);
res.header('Retry-After', retryAfter); // 응답 객체 직접 사용
throw new ThrottlerException();
}
// 이메일 기준 제한 확인
const emailLimit = 5;
const emailTtlSec = 600; // 10분 (초)
const email = req.body?.email;
if (email) {
const emailTracker = `${trackerIp}:${email}`;
const emailTtlResult = await this.storageService.increment(emailTracker, emailTtlSec);
this.logger.debug(`Email Rate Limit: ${emailTracker} - Hits: ${emailTtlResult.totalHits}/${emailLimit}, TTL: ${emailTtlResult.timeToExpire}s`);
if (emailTtlResult.totalHits > emailLimit) {
const retryAfter = Math.ceil(emailTtlResult.timeToExpire);
res.header('Retry-After', retryAfter);
throw new ThrottlerException('Too many email verification requests for this email address.');
}
} else {
this.logger.warn('Email field not found in request body for email throttling.');
}
return true; // 모든 제한 통과
}
}주의: 위 코드는 여전히 개념 예시입니다. NestJS 버전,
@nestjs/throttler버전에 따라THROTTLER_OPTIONS주입,handleRequest시그니처 등이 다를 수 있습니다.res.header()사용을 위해getRequestResponse()를 사용했습니다. - IP 추적기:
-
컨트롤러 적용:
RegistrationController의sendVerificationCode메서드에@UseGuards()만 적용하고@Throttle()데코레이터는 제거합니다.// apps/dta-wide-api/src/app/auth/controllers/registration.controller.ts
import { UseGuards } from '@nestjs/common';
import { EmailThrottlerGuard } from '@core/auth/guards'; // 경로 확인
// ...
@Post('email/verification-code')
@UseGuards(AppTokenGuard, EmailThrottlerGuard) // 커스텀 가드만 사용
async sendVerificationCode(
@Body() dto: SendVerificationCodeDto
): Promise<ApiResponse<EmailVerificationResultDto>> {
const result = await this.emailService.sendVerificationCode(dto.email);
// ...
}
-
- 참고: 상세 요구사항은 인증 도메인 요구사항 - 보안 섹션을 참조하세요.
- 목표: 서비스 거부 공격(DoS) 방지 및 시스템 가용성 보장을 위해 이메일 인증 코드 발송 API(
- 코드 유효 시간: 요구사항에 따라 5분으로 설정됩니다.
- 재시도 제한:
- 목표: 무차별 대입 공격(Brute-force)을 방지하기 위해 코드 확인 API(
POST /auth/email/registration-verify)에 대한 시도 횟수를 제한합니다. - 정책: 동일 이메일에 대해 최대 10회까지 코드 확인을 시도할 수 있습니다. 10회 실패 시 해당 코드는 즉시 무효화됩니다.
- 구현 방안:
- Redis 데이터 구조 변경: 인증 코드 저장 시 단순 문자열 대신 JSON 객체 사용 (위
4. 데이터 저장소섹션 참조).- 키:
email-verify:{email} - 값:
'{ "code": "123456", "attempts": 0 }'
- 키:
EmailService.verifyCode로직 수정:- Redis에서 값을 가져와 JSON 파싱.
- 코드 불일치 시
attempts값을 1 증가. attempts가MAX_VERIFICATION_ATTEMPTS(예: 10) 이상이면 Redis 키를 삭제하고 특정 오류(AuthErrorCode.INVALID_VERIFICATION_CODE또는 전용 코드) 발생.attempts가 제한 미만이면, 업데이트된attempts값으로 JSON 객체를 다시 Redis에 저장 (기존 TTL 유지 필요 -ttl()명령어 등으로 남은 시간 확인 후setex()사용).
- 참고: 위
3.2.3 EmailService.verifyCode 상세 로직예시 코드에 해당 로직이 반영되어 있습니다.
- Redis 데이터 구조 변경: 인증 코드 저장 시 단순 문자열 대신 JSON 객체 사용 (위
- 목표: 무차별 대입 공격(Brute-force)을 방지하기 위해 코드 확인 API(
- 이메일 발송 서비스 보안: API 키 등 민감 정보 안전하게 관리.
- 코드 확인 API Rate Limiting: 코드 확인 API(
POST /auth/email/registration-verify)에 대해서도 과도한 추측 공격을 방지하기 위해 IP 주소 기준 Rate Limiting 적용을 고려합니다. (예:@Throttle({ default: { limit: 10, ttl: 60000 } })- 1분당 10회)
6. 오류 코드 관리
이메일 인증 관련 주요 오류 코드는 다음과 같습니다. (Auth 도메인 공통 오류 코드 관리 방식 따름)
| 오류 코드 | 메시지 | HTTP 상태 코드 | 설명 |
|---|---|---|---|
| 2000 | SERVER_ERROR | 500 | 서버 내부 오류 (Redis 실패 등) |
| 2020 | EMAIL_ALREADY_EXISTS | 409 | 이미 가입된 이메일 주소 (선택적 검증) |
| 2017 | INVALID_VERIFICATION_CODE | 400 | 인증 코드가 일치하지 않음 |
| 2018 | VERIFICATION_ATTEMPTS_EXCEEDED | 400 | 인증 코드 확인 시도 횟수 초과 |
| 2014 | VERIFICATION_CODE_EXPIRED | 400/404 | 인증 코드가 존재하지 않거나 만료됨 |
| 2040 | RATE_LIMIT_EXCEEDED | 429 | 요청 횟수 제한 초과 (Rate Limiting) |
| 2000 | EMAIL_SEND_FAILED | 500 | 이메일 발송 서비스 실패 (SendGrid 오류 포함) |
오류 코드 관리 방식과 구현에 대한 자세한 내용은 오류 관리 가이드를 참조하세요.
7. 변경 이력
| 버전 | 날짜 | 작성자 | 변경 내용 |
|---|---|---|---|
| 0.1.0 | 2025-04-17 | bok@weltcorp.com | 최초 작성 |
| 0.2.0 | 2025-08-01 | bok@weltcorp.com | 오류 코드를 중앙 관리 가이드와 일치하도록 수정 (1055 -> 2014, 2017, 429 -> 2040) |
| 0.3.0 | 2025-08-01 | bok@weltcorp.com | NotificationService 대신 SendGrid 직접 사용하도록 수정 |
| 0.4.0 | 2025-08-01 | bok@weltcorp.com | SendGrid API 키 설정을 CoreConfigModule 책임으로 변경 |
| 0.5.0 | 2025-08-01 | bok@weltcorp.com | CoreConfigModule의 ConfigService 사용 명시 |