1. JWT와 Oauth 2.0
https://stack501.tistory.com/120
Authentication - 로직 구현
1. Auth Resource 생성nest g resource// 다음 'Auth' 로 생성2. JWT 패키지 설치yarn add @nestjs/jwt bcrypt// 혹은npm i @nestjs/jwt bcryptjwt와 bcrypt 패키지를 설치한다.3. Token 발급 메서드signToken(user: Pick, isRefreshToken: boole
stack501.tistory.com
https://stack501.tistory.com/160
[NestJS] Google Oauth2.0 Passport
1. Passport 주요 흐름1.1. 클라이언트의 구글 로그인 요청클라이언트는 Google 로그인 엔드포인트(예: /auth/google/login)에 접근한다.1.2. 가드 적용GoogleAuthGuard 가드 실행1.3. Passport의 AuthGuard 호출GoogleAuthG
stack501.tistory.com
가장 중요한 JWT와 Oauth2.0은 이미 앞서 구현하였다.
JWT와 Oatuh2.0 둘다 사용자 인증 처리를 위한 기술들이며, 이는 보안에 있어서 필수불가결한 것들이다.
2. RBAC (Role Base Access Controll)
https://stack501.tistory.com/146
[NestJS] RBAC (Role Base Access Controll)
1. RBAC 란?RBAC(Role-Based Access Control)은 사용자의 역할(Role)에 따라 시스템 내에서의 접근 권한을 제어하는 보안 모델이다.예를 들어, 글을 쓰거나 수정하거나 삭제하거나 등의 작업에서 역할을 부여
stack501.tistory.com
import { CanActivate, ExecutionContext, ForbiddenException, Injectable, UnauthorizedException } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { GqlExecutionContext } from "@nestjs/graphql"; // GraphQL 실행 컨텍스트 임포트
import { ROLES_KEY } from "../decorator/roles.decorator";
@Injectable()
export class RolesGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
) {}
canActivate(context: ExecutionContext): boolean {
const requiredRole = this.reflector.getAllAndOverride(
ROLES_KEY,
[
context.getHandler(),
context.getClass(),
]
);
// Roles Annotation이 등록되어있지 않음
if (!requiredRole) {
return true;
}
let request;
// HTTP 요청과 GraphQL 요청 모두 처리
if (context.getType() === 'http') {
// REST API 요청인 경우
request = context.switchToHttp().getRequest();
} else {
// GraphQL 요청인 경우
const gqlContext = GqlExecutionContext.create(context);
request = gqlContext.getContext().req;
}
// 요청 객체가 없는 경우 (예상치 못한 컨텍스트)
if (!request) {
throw new UnauthorizedException('요청 컨텍스트에 접근할 수 없습니다.');
}
const { user } = request;
if (!user) {
throw new UnauthorizedException(
`토큰을 제공해주세요.`
);
}
if (user.role !== requiredRole) {
throw new ForbiddenException(
`이 작업을 수행할 권한이 없습니다. ${requiredRole} 권한이 필요합니다.`
);
}
return true;
}
}
RBAC는 사용자마다 Role을 부여하여 해당하는 Role의 사용자만 로직이 실행되도록 하는 기술이다.
RBAC 또한 앞서 구현했었는데, GraphQL에도 동작할 수 있도록 수정하여야 한다.
3. Rate Limiting
https://stack501.tistory.com/167
[NestJS] Redis 적용하기 - Rate Limiting
1. Rate Limiting 이란?모든 사용자들이 API에 제한없이 계속해서 호출할 수 있다면 CPU, 메모리, DB 등의 리소스가 부족해진다.결국 과도한 서버비용이라는 최악의 결말을 보게 된다.이러한 DDOS 등을
stack501.tistory.com
import { BadRequestException, CanActivate, ExecutionContext, Inject, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { GqlExecutionContext } from '@nestjs/graphql'; // GraphQL 컨텍스트 임포트
import Redis from 'ioredis';
import { IORedisToken } from 'src/redis/redis.constants';
import { RATE_LIMITER_KEY } from '../decorator/rate-limiter.decorator';
@Injectable()
export class RateLimiterGuard implements CanActivate {
private readonly script = `
-- 속도 제한 구현 Lua 스크립트
-- 토큰 버켓 알고리즘을 사용합니다
-- 키 및 매개변수 설정
local key = KEYS[1]
local max_tokens = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2]) -- 초당 리필되는 토큰 수
local requested = tonumber(ARGV[3]) -- 요청에 필요한 토큰 수
local ttl = tonumber(ARGV[4]) -- 버킷 만료 시간(초)
local now = tonumber(ARGV[5]) -- 현재 시간(초)
-- 현재 버킷 상태 조회
local exists = redis.call("EXISTS", key)
local tokens, last_refill
if exists == 1 then
-- 기존 버킷 데이터 가져오기
tokens = tonumber(redis.call("HGET", key, "tokens"))
last_refill = tonumber(redis.call("HGET", key, "last_refill"))
else
-- 새 버킷 생성
tokens = max_tokens
last_refill = now
end
-- 토큰 리필 계산
local elapsed = now - last_refill
if elapsed > 0 then
-- 경과 시간에 따라 토큰 추가
local new_tokens = math.min(max_tokens, tokens + elapsed * refill_rate)
tokens = new_tokens
end
-- 요청 처리 (토큰이 충분한지 확인)
if tokens < requested then
-- 토큰 부족: 상태만 업데이트하고 거부
redis.call("HMSET", key, "tokens", tokens, "last_refill", now)
redis.call("EXPIRE", key, ttl)
return 0
else
-- 토큰 소모: 상태 업데이트 후 허용
tokens = tokens - requested
redis.call("HMSET", key, "tokens", tokens, "last_refill", now)
redis.call("EXPIRE", key, ttl)
return 1
end
`;
constructor(
private readonly reflector: Reflector,
@Inject(IORedisToken)
private readonly redis: Redis,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
// HTTP 요청과 GraphQL 요청 모두 지원하기 위해 요청 객체 가져오기
let req;
let isGraphQL = false;
if (context.getType() === 'http') {
// REST API 요청인 경우
req = context.switchToHttp().getRequest();
} else {
// GraphQL 요청인 경우
const gqlContext = GqlExecutionContext.create(context);
req = gqlContext.getContext().req;
isGraphQL = true;
}
// 요청 객체가 없는 경우
if (!req) {
console.warn('Rate Limiter Guard: 요청 컨텍스트에 접근할 수 없습니다.');
return true; // 안전하게 통과 (또는 필요에 따라 예외를 던질 수도 있음)
}
// 1) 키 전략: 인증된 사용자면 user:{id}, 아니면 ip:{ip}
const identifier = req.user?.id
? `user:${req.user.id}`
: `ip:${req.ip}`;
// GraphQL 작업 정보 추가 (GraphQL 요청인 경우)
let operationKey = '';
if (isGraphQL) {
const gqlCtx = GqlExecutionContext.create(context);
const info = gqlCtx.getInfo();
// 쿼리/뮤테이션 타입과 필드 이름을 기준으로 키 생성 (예: "Query:getUser")
if (info && info.parentType && info.fieldName) {
operationKey = `${info.parentType}:${info.fieldName}`;
}
} else {
// REST API 요청인 경우 메소드와 경로를 기준으로 키 생성
operationKey = `${req.method}:${req.route?.path || req.path}`;
}
// 최종 키는 사용자/IP + 작업 유형을 조합하여 생성
const key = `rate_limit:${identifier}:${operationKey}`;
// 2) 레이트 리미트 설정값 가져오기
const rateLimit = this.reflector.getAllAndOverride(RATE_LIMITER_KEY, [
context.getHandler(),
context.getClass(),
]) ?? {
// 기본값: 초당 10개 요청, 버스트는 최대 30개까지
refillRate: 10,
capacity: 30,
requested: 1,
ttl: 60,
};
const { refillRate, capacity, requested, ttl } = rateLimit;
// 3) Redis Lua 스크립트를 사용하여 레이트 리미트 적용
const now = Math.floor(Date.now() / 1000); // 현재 시간(초)
const result = await this.redis.eval(
this.script,
1, // 키 개수
key, // 키
capacity, // 최대 토큰 수
refillRate, // 초당 리필되는 토큰 수
requested, // 요청당 필요한 토큰 수
ttl, // 버킷 만료 시간(초)
now, // 현재 시간(초)
);
if (result === 0) {
// 토큰 부족: 레이트 리미트 초과
throw new BadRequestException('너무 많은 요청을 보냈습니다. 잠시 후 다시 시도해주세요.');
}
return true;
}
}
Rate Limiting은 토큰과 시간차를 이용하여 사용자가 많은 요청을 한번에 진행하지 못하도록 하는 기술이다.
Rate Limiting 역시 GraphQL에도 동작할 수 있도록 수정하여야 한다.
4. 쿼리 복잡성 제한 (DDoS 보호)
GraphQL의 유연성은 큰 장점이지만, 악의적인 사용자가 극도로 복잡하거나 깊은 중첩 쿼리를 만들어 서버에 과부하를 줄 수 있는 보안 취약점도 된다. 쿼리 복잡성 제한은 이러한 공격으로부터 API를 보호하는 중요한 방법이다.
4.1. 패키지 설치
npm install graphql-depth-limit graphql-query-complexity
4.2. GraphQL 모듈 설정 (app.module.ts)
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { join } from 'path';
import depthLimit from 'graphql-depth-limit';
import { createComplexityRule, simpleEstimator } from 'graphql-query-complexity';
import { GraphQLError } from 'graphql';
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
sortSchema: true,
playground: true,
debug: true,
validationRules: [
// 1) 쿼리 깊이 제한
depthLimit(5),
// 2) 쿼리 복잡도 제한
createComplexityRule({
estimators: [
simpleEstimator({ defaultComplexity: 1 }), // 필드당 기본 복잡도 1점
],
maximumComplexity: 100, // 최대 복잡도
onComplete: (c) => console.log('Query 복잡도:', c),
createError: (max, actual) =>
new GraphQLError(
`쿼리가 너무 복잡합니다: ${actual}. 허용 최대 복잡도: ${max}`,
{ extensions: { code: 'GRAPHQL_COMPLEXITY_LIMIT' } },
),
}),
],
}),
/* …다른 모듈들… */
],
})
export class AppModule {}
validationRules 내에 1) 쿼리 깊이 제한, 2) 쿼리 복잡도 제한 을 구성한다.
4.3. 필드별 복잡성 설정하기
import { Field, FieldOptions } from '@nestjs/graphql';
export interface ComplexityFieldOptions extends FieldOptions {
complexity?: number | ((args: any, childComplexity: number) => number);
}
export function ComplexityField(
typeOrOptions?: any | ComplexityFieldOptions,
options?: ComplexityFieldOptions,
): PropertyDecorator {
return Field(typeOrOptions, options);
}
위와 같이 필드를 제한할 수 있는 데코레이터를 구현할 수도 있다.
엔티티에서 사용 예시:
import { ObjectType } from '@nestjs/graphql';
import { ComplexityField } from 'src/common/decorator/field-complexity.decorator';
import { Entity } from 'typeorm';
import { BaseModel } from 'src/common/entity/base.entity';
import { UsersModel } from 'src/users/entity/users.entity';
@ObjectType()
@Entity()
export class PostsModel extends BaseModel {
// 기본 복잡성(1)을 가진 일반 필드
@ComplexityField()
title: string;
@ComplexityField()
content: string;
// 고정 복잡성(2)을 가진 필드
@ComplexityField(() => UsersModel, { complexity: 2 })
author: UsersModel;
// 동적 복잡성을 가진 필드 (페이지네이션 등에 유용)
@ComplexityField(() => [CommentsModel], {
complexity: (args, childComplexity) => {
// limit이 지정되지 않으면 기본값 10 사용
const limit = args.limit || 10;
// 각 항목의 복잡성 * 항목 수
return childComplexity * limit;
}
})
comments(
@Args('limit', { type: () => Int, defaultValue: 10 }) limit: number,
@Args('offset', { type: () => Int, defaultValue: 0 }) offset: number,
): CommentsModel[];
}
기존 @Field() 대신 만든 @ComplexityField()를 사용하여 쿼리 제한을 둘 수 있다.
복잡한 쿼리 예시:
# 이 쿼리는 5단계 중첩과 많은 필드를 포함하여 복잡성이 높음
query ComplexQuery {
getPosts(limit: 20) { # 20 x (기본 복잡성) = 20
id
title
content
author { # 각 포스트마다 author(복잡성 2) = 20 x 2 = 40
id
nickname
email
posts(limit: 5) { # 각 작성자마다 5개 posts = 20 x 5 = 100
id
title
comments(limit: 3) { # 각 포스트마다 3개 댓글 = 20 x 5 x 3 = 300
id
content
author { # 각 댓글마다 author = 20 x 5 x 3 x 2 = 600
id
nickname
}
}
}
}
}
}
위와 같이 쿼리 중첩에 복잡성을 계산할 수 있으며, 이를 통해 어느정도 깊이까지 허용할 것인지를 구성할 수 있다.
5. HTTP 보안 헤더 추가 (웹)
HTTP 보안 헤더를 Helmet 이라는 패키지를 통해 강화할 수 있는데, 각종 다양한 공격들을 방지할 수 있다.
다만 아래와 같이 순수 '앱' 애플리케이션에는 기능이 제한적일 수 있다.
- 순수 네이티브 모바일 앱 API만 제공하는 백엔드
- Helmet은 대부분 불필요하고 다른 보안 조치가 더 중요
- 웹과 모바일을 모두 지원하는 백엔드
- Helmet을 사용하되 용도에 맞게 구성
- 하이브리드 앱
- 웹뷰 사용 정도에 따라 일부 헤더가 유용할 수 있음
- 보안은 다층적
- HTTP 헤더는 전체 보안 전략의 일부일 뿐임
Helmet 의 다양한 헤더 옵션들
5.1. Content-Security-Policy (CSP)
브라우저가 외부 리소스(스크립트·스타일·이미지 등)를 어디서 로드할지 엄격히 제한해 줌으로써 XSS 공격을 방지한다.
XSS (Cross-Site Scripting) 공격은 공격자가 악성 스크립트를 웹사이트에 삽입하여 사용자의 브라우저에서 실행되도록 유도하는 보안 취약점이다. 이 스크립트는 사용자의 개인 정보 탈취, 악성 사이트로 유도, 웹사이트 기능 조작 등 다양한 방식으로 피해를 줄 수 있다.
만약 페이지에서 악성 스크립트를 주입해도, CSP에 없는 출처이므로 브라우저가 차단한다.
5.2. X-XSS-Protection
오래된 브라우저(IE, 구형 WebKit 등)에 내장된 XSS 필터를 활성화하고, 의심스러운 코드를 감지했을 때 요청 자체를 차단하도록 설정한다.
5.3. X-Content-Type-Options
브라우저의 MIME 타입 스니핑(추측)을 막고, 서버가 지정해 준 Content-Type 대로만 처리하게 강제한다.
서버가 Content-Type: application/json을 보냈는데, 스니핑으로 스크립트처럼 실행하는 것을 차단한다.
5.3. X-Frame-Options
클릭재킹(clickjacking) 방지를 위해, 해당 페이지를 <iframe> 등에 포함할 수 있는 출처을 제한한다.
5.4. Strict-Transport-Security (HSTS)
HTTPS 사용을 강제해, 중간자 공격(MITM)과 프로토콜 다운그레이드를 방지한다.
5.5. X-Download-Options
구형 Internet Explorer에서, 파일 다운로드 후 바로 열지 않고 “저장”만 하도록 강제한다.
5.6. X-DNS-Prefetch-Control
브라우저의 DNS 프리페치(prefetch) 동작을 켜거나 끔으로써, 개인정보(방문 기록) 유출을 제어한다.
5.7. Referrer-Policy
HTTP 요청 시 Referer 헤더에 어떤 정보를 담아 보낼지 결정한다. 예를 들어 외부로 나가는 링크 클릭 시 URL 전체가 노출되는 것을 방지할 수 있다.
5.8. Cross-Origin-*
여기서 말하는 “Cross-Origin-*”는 리소스 임베딩 정책 관련 헤더들이다.
- 리소스를 <img>, <script> 등으로 다른 출처에서 불러오는 것까지 제어
- same-origin: 동일한 출처에서만 로드 허용
- cross-origin: 모든 출처 허용
- same-site: 같은 사이트(포트 제외)만 허용
Helmet 예시:
import helmet from 'helmet';
import depthLimit from 'graphql-depth-limit';
import { createComplexityRule, simpleEstimator } from 'graphql-query-complexity';
import { GraphQLError } from 'graphql';
app.use(helmet());
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", 'apis.google.com'],
styleSrc: ["'self"', "'unsafe-inline'"],
imgSrc: ["'self'", 'data:'],
},
},
xssFilter: true,
noSniff: true,
frameguard: { action: 'sameorigin' },
hsts: { maxAge: 15552000, includeSubDomains: true },
ieNoOpen: true,
dnsPrefetchControl: { allow: false },
referrerPolicy: { policy: 'no-referrer' },
crossOriginResourcePolicy: { policy: 'same-origin' },
}),
);
6. CSRF 보호 (웹)
CSRF는 인증된 사용자의 브라우저를 속여 원치 않는 요청을 웹 애플리케이션에 전송하게 하는 공격이다.
작동 방식
- 사용자가 A 사이트에 로그인하고 인증 쿠키를 받음
- 사용자가 악의적인 B 사이트를 방문
- B 사이트는 사용자 모르게 A 사이트로 요청 전송 (예: 계정 비밀번호 변경, 자금 이체 등)
- 브라우저는 자동으로 A 사이트 쿠키를 포함하여 요청
- A 사이트는 인증된 요청으로 받아들여 실행
CSRF 보호의 필요성
- CSRF 보호는 주로 쿠키 기반 인증을 사용하는 웹 애플리케이션에서 중요합니다:
중요: 브라우저에서 쿠키를 자동 전송할 때 (세션 쿠키 방식)
덜 중요: Bearer 토큰 인증 API (헤더에 명시적으로 토큰 전송)
6.1. 패키지 설치
npm install csurf cookie-parser
# 또는
yarn add csurf cookie-parser
6.2. 기본 CSRF 보호 설정 (전역)
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import * as cookieParser from 'cookie-parser';
import * as csurf from 'csurf';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 쿠키 파서 미들웨어 설정 (CSRF 전에 필요)
app.use(cookieParser());
// CSRF 보호 미들웨어 설정
app.use(csurf({
cookie: {
httpOnly: true, // JavaScript에서 쿠키에 접근 불가
secure: process.env.NODE_ENV === 'production', // HTTPS에서만 전송 (프로덕션)
sameSite: 'strict' // 동일 사이트 요청에만 쿠키 전송
}
}));
await app.listen(3000);
}
bootstrap();
6.3. 라우트별로 선택적 적용 (더 유연한 방식)
csrf.middleware.ts 파일 생성:
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import * as csurf from 'csurf';
@Injectable()
export class CsrfMiddleware implements NestMiddleware {
private csrf = csurf({
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict'
}
});
use(req: Request, res: Response, next: NextFunction) {
this.csrf(req, res, next);
}
}
app.module.ts에서 미들웨어 적용:
import { Module, NestModule, MiddlewareConsumer, RequestMethod } from '@nestjs/common';
import { CsrfMiddleware } from './common/middleware/csrf.middleware';
import { AppController } from './app.controller';
import { AppService } from './app.service';
// 다른 import들...
@Module({
imports: [/* ... */],
controllers: [AppController],
providers: [AppService],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
// 특정 경로에만 CSRF 미들웨어 적용
consumer
.apply(CsrfMiddleware)
.exclude(
{ path: 'api/graphql', method: RequestMethod.ALL }, // GraphQL 제외
{ path: 'api/public*', method: RequestMethod.ALL } // 공개 API 제외
)
.forRoutes(
{ path: 'api/user*', method: RequestMethod.ALL }, // 사용자 관련 엔드포인트
{ path: 'api/admin*', method: RequestMethod.ALL } // 관리자 엔드포인트
);
}
}
6.4. CSRF 토큰 프론트엔드에 제공 및 검증
import { Controller, Get, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';
@Controller('auth')
export class AuthController {
@Get('csrf-token')
getCsrfToken(@Req() req: Request, @Res() res: Response) {
// CSRF 토큰 전송
return res.json({ csrfToken: req.csrfToken() });
}
// 다른 메소드들...
}
6.5. GraphQL과 함께 사용하기
GraphQL API를 사용하는 경우 CSRF 보호를 적용하는 방법:
// GraphQL 컨텍스트에 CSRF 토큰 함수 추가
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: true,
context: ({ req, res }) => ({
req,
res,
csrfToken: () => req.csrfToken?.() || null
}),
}),
// 다른 모듈들...
],
})
export class AppModule {}
GraphQL 리졸버에서 CSRF 검증:
import { Resolver, Query, Mutation, Context } from '@nestjs/graphql';
import { ForbiddenException } from '@nestjs/common';
@Resolver()
export class UsersResolver {
@Mutation(() => UserModel)
async updateUser(
@Args('input') input: UpdateUserInput,
@Context() context: any,
) {
// CSRF 토큰 검증 (컨텍스트에서 req 객체를 통해)
try {
// Express request의 _csrf 객체를 통해 검증 가능 (내부 구현에 따라 다름)
// 또는 클라이언트로부터 헤더로 받은 토큰을 검증
if (!context.req.headers['csrf-token']) {
throw new ForbiddenException('CSRF 토큰이 필요합니다');
}
// 로직 진행...
} catch (error) {
// CSRF 검증 실패 또는 다른 오류
throw new ForbiddenException('유효하지 않은 CSRF 토큰');
}
}
}
7. 로깅 및 모니터링
추후 구현
'NestJS' 카테고리의 다른 글
[NestJS] 단위테스트 (1) | 2025.06.04 |
---|---|
[NestJS] 자동 문서화 가이드 (Compodoc) (1) | 2025.05.30 |
[NestJS] GraphQL 적용하기 (0) | 2025.05.26 |
[NestJS] DB 인덱싱 (0) | 2025.05.21 |
[NestJS] 데이터 시딩 - 50만건 더미 데이터 추가하기 (0) | 2025.05.08 |