Access Code 검증 구현 가이드
개요
이 문서는 사용자 등록 과정에서 Access Code를 검증하는 기능 구현에 대한 가이드라인을 제공합니다. Access Code는 회원가입 시 사용자의 접근 권한을 확인하는 데 사용되는 16자리 코드입니다. 검증 로직은 CQRS 패턴을 사용하여 구현되며, auth 도메인에서 access-code 도메인의 데이터를 QueryBus를 통해 조회합니다.
주요 구성 요소
Access Code 검증 서비스는 다음과 같은 구성 요소로 이루어져 있습니다:
- API Controller:
AccessCodeController(API 요청 처리) - 검증 서비스:
AccessCodeValidatorService(auth도메인, 유효성 검증 및 시도 횟수 제한) - 쿼리 (Query):
GetAccessCodeDetailsQuery(access-code도메인 또는 공유 라이브러리) - 쿼리 핸들러 (Query Handler):
GetAccessCodeDetailsHandler(access-code도메인) - 쿼리 버스 (Query Bus):
@nestjs/cqrs제공 - 리포지토리 (Repository):
AccessCodeRepository(access-code도메인,GetAccessCodeDetailsHandler내에서 사용) - 요청 제한 서비스:
RateLimiterService(Redis 기반 시도 횟수 제한)
구현 가이드
1. Access Code 정보 조회 (Query 사용)
auth 도메인에서는 사용자 가입 초기 단계 등에서 Access Code의 유효성을 확인하기 위해 access-code 도메인이 제공하는 GetAccessCodeDetailsQuery를 사용합니다. 이 Query는 Access Code 문자열을 받아 해당 코드의 상세 정보(상태, 만료일, 사용 여부 등) 조회를 요청합니다.
AccessCodeValidatorService는 @nestjs/cqrs의 QueryBus를 통해 이 Query를 실행하여 필요한 정보를 얻습니다.
GetAccessCodeDetailsQuery 및 이를 처리하는 GetAccessCodeDetailsHandler의 상세 구현은 Access Code 도메인 기술 문서를 참조하세요.
2. AccessCodeController 구현 (Auth 도메인)
// 파일 경로 예시: apps/dta-wide-api/src/app/auth/controllers/access-code.controller.ts
// ... (imports) ...
import { AccessCodeValidatorService } from '../../application/services/access-code-validator.service'; // Service 경로 수정
import { ValidateAccessCodeDto, AccessCodeValidationResult } from '../../application/dtos/validate-access-code.dto'; // DTO 경로 수정
import { ApiResponse } from '@shared/api-types';
import { AppTokenGuard } from '@core/auth/guards'; // Guard 경로 확인
import { AuthenticatedRequest } from '@core/auth/interfaces'; // Interface 경로 확인
@Controller('auth/access-code')
export class AccessCodeController {
constructor(
private readonly accessCodeValidatorService: AccessCodeValidatorService,
) {}
@Post('validate')
@UseGuards(AppTokenGuard) // AppToken에서 deviceId 추출 필요
async validateAccessCode(
@Body() dto: ValidateAccessCodeDto,
@Req() req: AuthenticatedRequest, // 타입 명시 (Guard에서 주입 가정)
): Promise<ApiResponse<AccessCodeValidationResult>> {
const deviceId = req.app?.deviceId ?? req.user?.deviceId; // Guard 구현에 따라 deviceId 추출 방식 상이
if (!deviceId) {
// 401 Unauthorized 대신 400 Bad Request가 더 적절할 수 있음 (토큰은 유효하나 필요한 정보 부재)
throw new BadRequestException('Device ID not found in token context');
}
const result = await this.accessCodeValidatorService.validate(dto.accessCode, deviceId);
// API 응답 컨벤션에 따라 data 객체 제거
return result;
}
}
3. DTO 정의 (Auth 도메인)
// 파일 경로 예시: libs/feature/auth/src/lib/application/dtos/validate-access-code.dto.ts
import { IsString, Length, Matches } from 'class-validator';
export class ValidateAccessCodeDto {
@IsString()
@Length(16, 16)
@Matches(/^[A-Za-z0-9]+$/, { message: 'Access Code는 영문 대소문자와 숫자로만 구성되어야 합니다.'})
accessCode: string;
}
// API 응답 형식 정의 (conventions.md 참고 - data 키 없음)
export interface AccessCodeValidationResult {
valid: boolean;
accessCodeId?: string;
// expiresAt?: Date; // Date 객체 대신 Unix Timestamp 사용
expiresInSeconds?: number; // 만료까지 남은 시간 (초) 또는 만료 시점 Timestamp
isUsed?: boolean;
metadata?: {
remainingAttempts?: number;
remainingLockoutSeconds?: number;
};
}
4. AccessCodeValidatorService 구현 (Auth 도메인)
// 파일 경로 예시: libs/feature/auth/src/lib/application/services/access-code-validator.service.ts
import { Injectable, BadRequestException, UnauthorizedException, HttpException, HttpStatus, Inject } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs'; // QueryBus 임포트
import { GetAccessCodeDetailsQuery, AccessCodeDetailsDto } from '@shared/queries/access-code'; // Query 및 결과 DTO 임포트 (AccessCode 엔티티 대신)
import { RATE_LIMITER_SERVICE, RateLimiterService } from '@core/rate-limiter'; // 공유 Rate Limiter 서비스 및 주입 토큰 임포트
import { AccessCodeValidationResult, ValidateAccessCodeDto } from '../dtos/validate-access-code.dto';
// import { AuthErrorFactory, AuthErrorCode } from '../../domain/errors'; // 필요 시 에러 처리용 임포트
import { LoggerService } from '@core/logging'; // Logger 추가
const RATE_LIMIT_KEY_PREFIX = 'rate_limit:access_code_attempt:'; // 키 접두사 정의
const MAX_ATTEMPTS = 10;
const LOCKOUT_TTL_SECONDS = 3600; // 1 hour
@Injectable()
export class AccessCodeValidatorService {
constructor(
private readonly queryBus: QueryBus, // QueryBus 주입
@Inject(RATE_LIMITER_SERVICE)
private readonly rateLimiter: RateLimiterService,
private readonly logger: LoggerService, // Logger 주입
) {
this.logger.setContext(AccessCodeValidatorService.name);
}
async validate(accessCode: string, deviceId: string): Promise<AccessCodeValidationResult> {
const rateLimitKey = `${RATE_LIMIT_KEY_PREFIX}${deviceId}`;
const limitCheck = await this.rateLimiter.checkLimit(rateLimitKey, MAX_ATTEMPTS);
if (!limitCheck.allowed) {
this.logger.warn(`Rate limit exceeded for device ${deviceId}. Lockout remaining: ${limitCheck.ttl} seconds`);
return {
valid: false,
metadata: {
remainingLockoutSeconds: limitCheck.ttl,
},
};
}
try {
// QueryBus를 사용하여 Access Code 정보 조회 (반환 타입: AccessCodeDetailsDto | null)
const codeDetails = await this.queryBus.execute<GetAccessCodeDetailsQuery, AccessCodeDetailsDto | null>(
new GetAccessCodeDetailsQuery(accessCode)
);
let failureReason = 'invalid or not found';
let isExpired = false;
if (codeDetails) {
// Determine the effective "now" based on TimeMachine
const effectiveNowMs = codeDetails.useTimeMachine && codeDetails.virtualTimeStartDate
? codeDetails.virtualTimeStartDate // Use virtual start date as "now"
: Date.now(); // Use system time otherwise
const expiresAtMs = codeDetails.expiresAt.getTime(); // Get expiration time in ms
isExpired = effectiveNowMs >= expiresAtMs; // Check expiration against effective "now"
if (codeDetails.useTimeMachine) {
this.logger.log(`TimeMachine active for code ${accessCode}. Effective 'now': ${new Date(effectiveNowMs).toISOString()}. ExpiresAt: ${codeDetails.expiresAt.toISOString()}. Expired check: ${isExpired}`);
}
}
// 조회된 DTO 확인 및 유효성 검증 로직 (만료 검사 포함)
if (!codeDetails || codeDetails.isUsed || isExpired) {
// 유효하지 않은 코드 처리 (Rate Limiter 시도 횟수 증가)
const newAttempts = await this.rateLimiter.incrementAttempts(rateLimitKey, LOCKOUT_TTL_SECONDS);
const newRemaining = Math.max(0, MAX_ATTEMPTS - newAttempts);
const metaForFailure = { remainingAttempts: newRemaining };
if (codeDetails) {
// isExpired는 위에서 TimeMachine을 고려하여 계산됨
failureReason = codeDetails.isUsed ? 'alreadyUsed' : (isExpired ? 'expired' : 'unknown');
}
this.logger.warn(`Access code validation failed for device ${deviceId}: ${failureReason}. Remaining attempts: ${newRemaining}`);
return {
valid: false,
metadata: metaForFailure,
};
}
// 검증 성공 시
this.logger.log(`Access code validated successfully for device ${deviceId}`);
// Calculate remaining time based on original effectiveNowMs and expiresAtMs (already calculated above)
// TimeMachine 활성화 여부에 따라 기준 시간 결정
const effectiveNowMs = codeDetails.useTimeMachine && codeDetails.virtualTimeStartDate
? codeDetails.virtualTimeStartDate
: Date.now();
const expiresAtMs = codeDetails.expiresAt.getTime();
const expiresInSeconds = Math.max(0, Math.floor((expiresAtMs - effectiveNowMs) / 1000));
// Log TimeMachine usage if applicable
if (codeDetails.useTimeMachine) {
this.logger.log(`TimeMachine active for code ${accessCode}. Remaining seconds based on effective now: ${expiresInSeconds}`);
}
return {
valid: true,
accessCodeId: codeDetails.id, // DTO에서 ID 사용
expiresInSeconds: expiresInSeconds, // 남은 시간(초) 반환
isUsed: codeDetails.isUsed, // DTO에서 사용 여부 확인
metadata: {
// 성공 시 남은 시도 횟수
remainingAttempts: limitCheck.remaining
}
};
} catch (error) {
this.logger.error(`Unexpected error during access code validation query for device ${deviceId}`, error.stack);
// Query 실행 중 에러 발생 시 (예: Access Code 모듈 통신 불가)
// 500 Internal Server Error 또는 적절한 오류 반환 필요
return {
valid: false,
// metadata: { errorCode: 'DEPENDENCY_FAILURE' } // 예시
};
}
}
}
5. 모듈 설정 (Auth 및 Access Code 도메인)
- Access Code 도메인 모듈 (
AccessCodeModule):GetAccessCodeDetailsHandler등 필요한 Query Handler를providers배열에 등록해야 합니다. 상세 구현은 Access Code 도메인 기술 문서를 참조하세요.AccessCodeRepository구현체를providers에 등록해야 합니다.
- Auth 도메인 모듈 (
AuthModule):@nestjs/cqrs의CqrsModule을imports배열에 추가해야 합니다.AccessCodeValidatorService를providers에 등록해야 합니다.RateLimiterService관련 설정을 확인합니다 (예:RateLimiterModuleimport).AccessCodeController를controllers에 등록해야 합니다.
// 파일 경로 예시: libs/feature/access-code/src/lib/access-code.module.ts (일부)
// ... (AccessCodeModule 설정은 access-code 도메인 문서 참조)
// 파일 경로 예시: libs/feature/auth/src/lib/auth.module.ts (일부)
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs'; // CQRS 모듈 import
import { AccessCodeValidatorService } from './application/services/access-code-validator.service';
import { AccessCodeController } from './controllers/access-code.controller'; // Controller 임포트 확인
import { RateLimiterModule } from '@core/rate-limiter'; // RateLimiter 모듈 확인
// ... 기타 import
@Module({
imports: [
CqrsModule, // CQRS 모듈 import
RateLimiterModule, // RateLimiter 모듈 확인
// ... 다른 모듈들
],
controllers: [AccessCodeController], // Controller 등록
providers: [
AccessCodeValidatorService,
// ... AuthService, EmailService 등 다른 서비스들
],
exports: [AccessCodeValidatorService /*, ... */]
})
export class AuthModule {}
보안 고려사항
- Access Code는 일회용으로 설계되어야 하며, 사용 후에는 재사용을 방지해야 합니다.
- 브루트 포스 공격을 방지하기 위해 Redis를 이용한 요청 제한(rate limiting)을 적용해야 합니다. (시간당 10회, 1시간 잠금)
- Access Code는 충분한 엔트로피를 가지도록 생성되어야 합니다.
- Controller 레벨에서 AppTokenGuard 등을 통해 요청 헤더에서
deviceId를 안전하게 추출하고 Service로 전달해야 합니다.
테스트 방법
- 유닛 테스트:
AccessCodeValidatorService(CQRS 목킹),GetAccessCodeDetailsHandler(Repository 목킹) - 통합 테스트:
AccessCodeValidatorService와 실제QueryBus연동, Redis 연동 및 Rate Limiting 로직 검증
변경 이력
| 버전 | 날짜 | 작성자 | 변경 사항 |
|---|---|---|---|
| 0.1.0 | 2025-04-10 | bok@weltcorp.com | 초기 버전 작성 |
| 0.2.0 | 2025-04-22 | bok@weltcorp.com | CQRS 패턴 적용 (QueryBus 사용), 관련 Query/Handler 정의 추가 |