인증 & 보안 시스템
JWT 기반 인증부터 SQL 인젝션 방지까지
다양한 보안 기능을 학습하고 구현했습니다.
인증 시스템
JWT, API Key, OAuth
권한 관리
RBAC, 역할 기반
Rate Limiting
요청 제한, DDoS 방지
데이터 보안
SQL 인젝션, 입력 검증
JWT 기반 인증 시스템
구현 내용
회원가입 프로세스
- • 이메일 중복 확인
- • 패스워드 복잡성 검증
- • bcrypt.hash(password, 10) 암호화
- • 사용자 정보 생성
- • JWT 토큰 발급
로그인 프로세스
- • 이메일/패스워드 검증
- • bcrypt.compare(password, hashedPassword) 확인
- • 사용자 정보 조회
- • JWT Payload 생성
- • Access Token과 Refresh Token 발급
토큰 구조
- • Access Token: 1시간 만료
- • Refresh Token: 15일 만료
- • 별도 시크릿 키 사용
- • 데이터베이스에 Refresh Token 저장
비밀번호 보안
- • bcrypt 해시 알고리즘 사용
- • Salt Round 10 적용
- • 원본 비밀번호는 저장하지 않음
- • 해시된 비밀번호만 DB에 저장
API 테스트
테스트 방법
- • 이메일: 아무 이메일 형식으로 넣어주세요
- • 패스워드: 소문자, 대문자, 숫자, 특수문자, 10글자 이상
테스트
@ApiTags('auth')
@Controller('auth')
export class AuthController {
@Post('register')
@ApiResponseEntity({
type: AuthSignUserOutDto,
summary: '회원가입 및 로그인',
})
async register(
@Body() authSignUserInDto: AuthSignUserInDto,
): Promise<ResponseEntity<AuthSignUserOutDto>> {
const authSignUserOutDto =
await this.authService.register(authSignUserInDto);
return ResponseEntity.ok().body(authSignUserOutDto);
}
@Get('jwt-validator')
@ApiResponseEntity({ type: UserDto, summary: 'jwt 유효성 검증' })
@ApiBearerAuth('jwt')
@UseGuards(JwtAuthGuard)
async jwtValidator(@CurrentUser() user: JwtPayload): Promise<ResponseEntity<JwtPayload>> {
return ResponseEntity.ok().body(user);
}
@Post('refresh')
@ApiResponseEntity({ type: AuthSignUserOutDto, summary: '토큰 갱신' })
@ApiBearerAuth('jwt-refresh')
@UseGuards(JwtRefreshAuthGuard)
async refreshToken(
@CurrentUser() user: User,
): Promise<ResponseEntity<AuthSignUserOutDto>> {
const authSignUserOutDto = await this.authService.refreshToken(user);
return ResponseEntity.ok().body(authSignUserOutDto);
}
}Session 기반 인증 시스템
구현 내용
세션 생성 프로세스
- • UUID 기반 Session ID 생성
- • Redis Multi를 사용한 트랜잭션 처리
- • 사용자 정보 (userId, gameDbId, nid) 저장
- • 환경별 TTL 설정 (TEST: 무제한, PROD: 설정값)
- • ContextProvider에 세션 정보 저장
Redis 트랜잭션
- • Multi 명령: 원자적 세션 생성
- • DB 동기화: TypeORM과 Redis 일관성
- • TTL 설정: 자동 만료 처리
- • 성능 최적화: 파이프라인 명령 처리
세션 구조
- • Session ID: UUID로 생성된 고유 식별자
- • User ID: 사용자 식별자
- • Game DB ID: 게임 데이터베이스 식별자
- • NID: 네트워크 식별자
보안 강화
- • Redis 기반: 서버 세션 저장
- • TTL 관리: 자동 만료 처리
- • 트랜잭션: 원자적 세션 생성
- • 상태 관리: 서버에서 세션 상태 추적
API 테스트
테스트 방법
- • 이메일: 아무 이메일 형식으로 넣어주세요
- • 패스워드: 소문자, 대문자, 숫자, 특수문자, 10글자 이상
테스트
@Controller('auth')
export class AuthController {
@Post('register')
@ApiResponseEntity({
type: AuthSignUserOutDto,
summary: '회원가입 및 로그인',
})
async register(
@Body() authSignUserInDto: AuthSignUserInDto,
): Promise<ResponseEntity<AuthSignUserOutDto>> {
const result = await this.authService.register(authSignUserInDto);
return ResponseEntity.ok().body(result);
}
/**
* Session 인증
*/
@Auth()
@Post('session')
@ApiResponseEntity({ type: Session, summary: 'Session 유효성 검증' })
async session(): Promise<ResponseEntity<Session>> {
return ResponseEntity.ok().body(ContextProvider.getSession());
}
}@Injectable()
export class AuthService {
@TransactionalEx(DB_NAME.COMMON)
async register(
authSignUserInDto: AuthSignUserInDto,
): Promise<AuthSignUserOutDto> {
const { email, password } = authSignUserInDto;
let user = await this.userService.login(email, password);
if (!user) {
user = await this.userService.signup(email, password);
}
return await this._handleLogin(user);
}
/**
* 로그인 후 공통 처리 메서드
*/
@TransactionalEx(DB_NAME.COMMON)
private async _handleLogin(user: User): Promise<AuthSignUserOutDto> {
// gameDbId 설정 및 UserDetail 생성
if (!user.gameDbId) {
const gameDbId = 100 + (user.id % getGameShardDatabases.length);
user.gameDbId = gameDbId;
await this.usersRepository.updateById(user.id, { gameDbId });
await this.userService.createUserDetail(user);
}
// JWT 토큰 생성
const payload: JwtPayload = {
id: user.id,
email: user.email ?? '',
roles: user.roles,
gameDbId: user.gameDbId || 0,
database: user.gameDbId ? getDatabaseByGameDbId(user.gameDbId) : '',
};
const accessToken = this._getAuthToken(payload);
const refreshToken = this._getRefreshToken(payload);
// 세션 생성 (Redis)
const session = await this._createSession(user);
// DB 업데이트
await this.usersRepository.updateById(user.id, {
refreshToken: refreshToken,
});
return AuthSignUserOutDto.of(user)
.setAuthToken(accessToken)
.setRefreshToken(refreshToken)
.setSessionId(session.id);
}
/**
* 세션 생성
*/
private async _createSession(user: User): Promise<Session> {
const session = Session.create({
userId: user.id,
nid: user.nid,
gameDbId: user.gameDbId,
});
await this.sessionRepository.setSession(
user.id,
session,
['', ENVIRONMENT.TEST].includes(NODE_ENV) ? 0 : SESSION_EXPIRED_TIME,
);
return session;
}
}export class Session {
id?: string;
nid: string;
userId: number;
gameDbId: number;
database?: string;
timeOffset?: number;
block?: boolean;
sequence?: number;
sequenceResult?: string;
static create(session: Session): Session {
return Object.assign(new this(), {
id: randomUUID(),
sequence: 0,
...session,
});
}
}@Injectable()
export class SessionRepository extends AbstractRedisRepository {
protected readonly dbNumber = RedisDbNumber.SESSION;
async getSession(userId: number): Promise<Session> {
const result = JSON.parse(await this.redis.get(userId.toString()));
return plainToInstance(Session, result);
}
async setSession(userId: number, session: Session, ttl = 0): Promise<void> {
const key = userId.toString();
const multi = this.multi;
ttl > 0
? multi.set(key, JSON.stringify(session), 'EX', ttl)
: multi.set(key, JSON.stringify(session));
ContextProvider.setSession(session);
ContextProvider.setRedisMulti(this.dbNumber, multi);
}
/**
* 세션 정보 업데이트 - 세션이 존재하는 경우에만 사용, ttl을 변경하지 않음.
*/
async updateSession(userId: number, session: Session): Promise<string> {
const result = this.redis.set(
userId.toString(),
JSON.stringify(session),
'KEEPTTL',
);
ContextProvider.setSession(session);
return result;
}
}@Injectable()
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const authHeader = request.headers.authorization;
if (!authHeader || !authHeader.startsWith('Basic ')) {
throw new UnauthorizedException('Basic Authentication required');
}
try {
// Basic Auth 헤더에서 credentials 추출
const credentials = authHeader.substring(6); // "Basic " 제거
const decoded = Buffer.from(credentials, 'base64').toString('utf-8');
const [userId, sessionId] = decoded.split(':');
if (!userId || !sessionId) {
throw new UnauthorizedException('Invalid credentials format');
}
// 세션 검증 로직
const session = this.validateSession(Number(userId), sessionId);
if (!session) {
throw new UnauthorizedException('Invalid session');
}
// Context에 세션 정보 저장
ContextProvider.setSession(session);
return true;
} catch (error) {
throw new UnauthorizedException('Authentication failed');
}
}
private async validateSession(userId: number, sessionId: string): Promise<Session | null> {
// Redis에서 세션 조회 및 검증
const session = await this.sessionRepository.getSession(userId);
if (!session || session.id !== sessionId) {
return null;
}
return session;
}
}API Key 인증 시스템
구현 내용
API Key 발급
- • 1분간 유효한 임시 API 키 생성
- • 보안을 위한 짧은 만료 시간
- • 서버 측에서 안전한 키 생성
- • 클라이언트에 즉시 전달
보안 특징
- • 짧은 만료 시간으로 보안 강화
- • 서버 측 키 생성으로 안전성 확보
- • HTTPS를 통한 안전한 전송
- • 키 재사용 방지
사용 사례
- • 임시 API 접근 권한 부여
- • 테스트 환경에서의 API 키 발급
- • 개발자 도구에서의 임시 인증
- • 데모 목적의 API 키 생성
API 테스트
테스트 방법
- • “POST auth/api-key 호출” 버튼을 클릭
- • 1분간 유효한 API 키를 발급받습니다
- • 발급받은 키는 자동으로 검증 탭에 설정됩니다
테스트
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
/**
* API 인증
* */
@Post('api-key')
@ApiResponseEntity({ type: GetApiKeyOutDto, summary: '1분 api key 발급' })
async getApiKey(): Promise<ResponseEntity<GetApiKeyOutDto>> {
const getApiKeyOutDto = this.authService.getApiKey();
return ResponseEntity.ok().body(getApiKeyOutDto);
}
@Post('api-key-validator')
@ApiResponseEntity({ type: GetApiKeyOutDto, summary: '1분 api key 발급' })
@ApiSecurity('apiKey')
@UseGuards(ApiKeyAuthGuard)
async apiKeyValidator(): Promise<ResponseEntity<string>> {
return ResponseEntity.ok().body('유효한 api key 확인 완료');
}
}
RBAC (Role-Based Access Control) 시스템
구현 내용
RBAC 시스템
- • JWT 토큰 기반 인증
- • 역할 기반 접근 제어
- • 동적 권한 변경
- • 권한 변경 시 토큰 자동 갱신
사용자 역할
- • ADMIN: 모든 권한
- • MANAGER: 관리 권한
- • DEVELOPER: 개발 권한
- • GUEST: 읽기 권한
보안 기능
- • JWT 서명 검증
- • 토큰 만료 시간 관리
- • 권한 정보 토큰에 포함
- • 실시간 권한 검증
API 테스트
테스트 방법
- • 회원가입/로그인: 이메일과 비밀번호로 계정 생성
- • 권한 변경: 원하는 역할을 선택하여 변경
- • 토큰 갱신: 권한 변경 시 새로운 토큰이 자동 발급
회원가입/로그인
권한 변경
@ApiTags('auth')
@Controller('auth')
export class AuthController {
/**
* RBAC 권한 관리
*/
@Put('role')
@ApiResponseEntity({ type: UserDto, summary: '관리자 권한 변경' })
@ApiBearerAuth('jwt')
@UseGuards(JwtAuthGuard)
async updateRole(
@Body() updateRoleUserDto: UpdateRoleUserDto,
): Promise<ResponseEntity<AuthSignUserOutDto>> {
const authSignUserOutDto = await this.authService.updateRole(updateRoleUserDto);
return ResponseEntity.ok().body(authSignUserOutDto);
}
}Throttler (Rate Limiting) 시스템
구현 내용
Rate Limiting
- • IP 기반 요청 제한
- • Redis 기반 분산 저장소
- • 다중 Pod 환경 지원
- • 동적 제한 정책
Throttler 설정
- • 기본 제한: 1분에 10번 요청
- • 엄격 제한: 1분에 3번 요청
- • 짧은 간격: 10초에 5번 요청
- • 제한 없음: @SkipThrottle()
Redis Storage
- • 분산 환경 지원
- • 실시간 제한 추적
- • 자동 만료 처리
- • 다중 서버 동기화
API 테스트
테스트 방법
- • 기본 제한: 15번 요청 (1분에 10번 제한)
- • 엄격 제한: 10번 요청 (1분에 3번 제한)
- • 제한 없음: 20번 요청 (@SkipThrottle)
- • 짧은 간격: 10번 요청 (10초에 5번 제한)
Rate Limiting 테스트
@Module({
imports: [
ThrottlerModule.forRoot({
throttlers: [
{
ttl: 60000, // 1분
limit: 10, // 10번 요청
},
],
storage: new RedisThrottlerStorage(),
}),
providers: [
// { provide: APP_GUARD, useClass: ThrottlerGuard }, // 개별 적용으로 변경
],
],
})
export class ApiModule {}요청 검증 시스템
구현 내용
Validation (유효성 검사)
- • 데이터 타입 검증 (@IsString, @IsNumber, @IsBoolean)
- • 선택적 필드 처리 (@IsOptional)
- • 형식 검증 (@IsEmail, @IsUrl, @IsDateString)
- • 범위 검증 (@Length, @Min, @Max, @IsPositive)
- • 열거형 검증 (@IsIn)
- • 상수 열거형 검증 (@Validate, ConstantsValidator)
복합 데이터 검증
- • 배열 검증 (@IsArray, @IsString)
- • 중첩 객체 검증 (@ValidateNested, @Type)
- • 객체 배열 검증 (@ValidateNested)
- • 재귀적 검증 구조
XSS 방지 효과
- • 입력 길이 제한으로 악성 스크립트 차단
- • 타입 검증으로 HTML 태그 필터링
- • 형식 검증으로 잘못된 URL/이메일 차단
- • 열거형 검증으로 허용되지 않은 값 차단
API 테스트
테스트 방법
- • 직접 입력: 각 필드에 원하는 값을 직접 입력
- • 유효한 데이터: 모든 검증 규칙을 만족하는 값 입력
- • 유효하지 않은 데이터: 검증 규칙을 위반하는 값 입력
- • XSS 공격 시도: 악성 스크립트나 HTML 태그 입력
- • 복합 데이터: 배열, 객체, 객체 배열 형태로 입력
사용자 직접 입력
범위: 1 ~ 100
허용값: true, false
허용값: USER, ADMIN, GUEST
허용값: 0 (None), 1 (Test), 2 (Live)
형식: YYYY-MM-DD (예: 2024-01-01)
export class NestedObjectDto {
@ApiProperty()
@IsString()
@Length(1, 50)
string: string;
@ApiProperty()
@IsNumber()
@Min(0)
@Max(100)
number: number;
}
export const VALIDATION_CONST_ENUM = {
None: 0,
Test: 1,
Live: 2,
};
export type ValidationConstEnum =
(typeof VALIDATION_CONST_ENUM)[keyof typeof VALIDATION_CONST_ENUM];
export class ValidationInDto {
// 기본 문자열 검증
@ApiProperty({ description: '기본 문자열 (1-50자)' })
@IsString()
@IsOptional()
@Length(1, 50)
string?: string;
// 이메일 검증
@ApiProperty({ description: '이메일 형식' })
@IsEmail()
@IsOptional()
email?: string;
// 숫자 검증
@ApiProperty({ description: '양수 (1-100)' })
@IsNumber()
@IsOptional()
@Min(1)
@Max(100)
number?: number;
// 불린 검증
@ApiProperty({ description: '불린 값' })
@IsBoolean()
@IsOptional()
boolean?: boolean;
// URL 검증
@ApiProperty({ description: 'URL 형식', required: false })
@IsOptional()
@IsUrl()
url?: string | null;
// 날짜 문자열 검증
@ApiProperty({ description: '날짜 문자열 (ISO)', required: false })
@IsOptional()
@IsDateString()
date?: string | null;
// 열거형 검증
@ApiProperty({ description: '역할 선택', enum: ['ADMIN', 'USER', 'GUEST'] })
@IsIn(['ADMIN', 'USER', 'GUEST'])
@IsOptional()
in?: string;
// 상수 열거형 검증
@ApiProperty({
description: constToString(VALIDATION_CONST_ENUM, 'VALIDATION_CONST_ENUM'),
})
@IsNumber()
@IsOptional()
@Validate(ConstantsValidator, [VALIDATION_CONST_ENUM])
constEnum?: ValidationConstEnum;
// 배열 검증
@ApiProperty({ description: '문자열 배열', type: [String] })
@IsArray()
@IsOptional()
@IsString({ each: true })
@Length(1, 20, { each: true })
array?: string[];
// 중첩 객체 검증
@ApiProperty({ description: '중첩 객체', type: NestedObjectDto })
@IsObject()
@IsOptional()
@ValidateNested()
@Type(() => NestedObjectDto)
object?: NestedObjectDto;
// 중첩 객체 배열 검증
@ApiProperty({ description: '중첩 객체 배열', type: [NestedObjectDto] })
@IsArray()
@IsOptional()
@ValidateNested({ each: true })
@Type(() => NestedObjectDto)
objectArray?: NestedObjectDto[];
}SQL 인젝션 방지 시스템
구현 내용
TypeORM 보안 기능
- • Query Builder 사용으로 자동 이스케이프
- • 파라미터화된 쿼리 (Prepared Statements)
- • Repository 패턴으로 안전한 DB 접근
- • 악성 SQL 코드를 단순한 문자열 데이터로 처리
방어 원리
- • SQL과 데이터가 완전히 분리됨
- • ? 플레이스홀더로 파라미터 전달
- • DROP TABLE 등 악성 명령어가 검색어로 처리
- • 데이터베이스 레벨에서 안전하게 보호
SQL 인젝션 테스트
테스트 방법
- • 악성 SQL 코드를 입력하여 공격 시도
- • TypeORM이 어떻게 방어하는지 확인
- • 실제 실행되는 안전한 쿼리 구조 확인
악성 입력 테스트
예시: '; DROP TABLE users; --, admin' OR '1'='1, UNION SELECT * FROM users
@Controller('auth')
export class AuthController {
/**
* sql injection
*/
@Post('sql-injection')
@ApiResponseEntity({ summary: 'sql-injection' })
async sqlInjection(
@Body() sqlInjectionInDto: SqlInjectionInDto,
): Promise<ResponseEntity<unknown>> {
await this.authService.sqlInjection(sqlInjectionInDto.query);
return ResponseEntity.ok().build();
}
}
소셜 로그인 시스템
구현 내용
OAuth 2.0 프로세스
보안 기능
사용자 정보
API 테스트
테스트 방법
테스트