역할 기반 접근 제어 (RBAC) 구현
지난 절에서는 JWT 기반 인증 시스템으로 사용자의 신원을 확인하는 방법을 알아봤습니다.
이제는 인증된 사용자가 특정 리소스나 기능에 접근할 수 있는지 결정하는 권한 부여(Authorization)를 다룹니다.
그중 가장 널리 쓰이는 역할 기반 접근 제어(Role-Based Access Control, RBAC)를 NestJS에 구현하는 방법을 살펴보겠습니다.
RBAC는 사용자에게 권한을 직접 부여하지 않고, 특정 역할(Role)을 부여한 뒤 그 역할에 미리 정의된 권한을 할당하는 방식입니다.
예를 들어 관리자는 게시물 생성/조회/수정/삭제 권한을, 일반 사용자는 게시물 조회/생성 권한만 가질 수 있습니다.
이 방식은 사용자별 권한을 일일이 관리하지 않아도 되므로 권한 관리가 훨씬 효율적이고 유연해집니다.
역할 기반 접근 제어(RBAC)란?
RBAC의 핵심 개념은 다음과 같습니다.
- 사용자(User): 시스템을 이용하는 개체입니다. (예:
John,Jane) - 역할(Role): 특정 직무 또는 직책에 해당하는 권한들의 집합입니다. (예:
Admin,Editor,Viewer) - 권한(Permission): 특정 리소스에 대한 특정 행위를 허용하거나 금지하는 가장 기본적인 단위입니다. (예:
create:post,read:post,update:post,delete:post)
RBAC는 User -> Role -> Permission 구조를 가집니다. 사용자는 하나 이상의 역할을 가질 수 있고, 각 역할은 하나 이상의 권한을 가집니다.
RBAC의 장점- 관리의 용이성: 사용자 수가 많아져도 권한 관리가 용이합니다. 사용자별 권한을 일일이 설정하는 대신 역할만 부여하면 됩니다.
- 유연성: 새로운 역할을 추가하거나 기존 역할의 권한을 변경하기 쉽습니다.
- 보안성: 권한이 명확하게 정의되고 분리되어 보안 취약점을 줄일 수 있습니다.
- 가독성: 코드 상에서 어떤 역할이 어떤 작업을 수행할 수 있는지 쉽게 파악할 수 있습니다.
NestJS에서 RBAC 구현하기
RBAC 구현은 역할 모델, guard 위치, decorator 메타데이터, 거부 응답 기준으로 읽습니다.
NestJS에서 RBAC를 구현하는 가장 효율적인 방법은 가드(Guards)와 커스텀 데코레이터(Custom Decorators)를 조합하는 것입니다.
- 커스텀 데코레이터 (
@Roles()): 라우트 핸들러(컨트롤러 메서드)나 컨트롤러 클래스에 필요한 역할을 메타데이터로 첨부합니다. - 가드 (
RolesGuard): 이 메타데이터를 읽어서 현재 인증된 사용자가 해당 역할을 가지고 있는지 확인합니다.
ConfigModule과 JwtModule 설정 확인
이전 절에서 AuthModule에 ConfigModule을 임포트하고 JwtModule.registerAsync에서 ConfigService를 사용하여 JWT_SECRET을 가져왔습니다. RBAC를 위해서는 사용자의 roles 정보가 JWT 페이로드에 포함되어야 합니다.
auth.service.ts에서 JWT 페이로드에 roles 추가 확인
import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class AuthService {
constructor(
private usersService: UsersService,
private jwtService: JwtService,
) {}
async validateUser(username: string, pass: string): Promise<any> {
const user = await this.usersService.findOne(username);
if (user && user.password === pass) {
const { password, ...result } = user;
return result;
}
return null;
}
async login(user: any) {
// JWT 페이로드에 'roles' 포함
const payload = { username: user.username, sub: user.userId, roles: user.roles };
return {
access_token: this.jwtService.sign(payload),
};
}
}jwt.strategy.ts에서 페이로드에서 roles 추출 확인
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>('JWT_SECRET'),
});
}
async validate(payload: any) {
// 반환되는 객체는 req.user에 주입됩니다. roles가 포함되어야 합니다.
return { userId: payload.sub, username: payload.username, roles: payload.roles };
}
}@Roles() 생성
라우트 핸들러에 필요한 역할을 메타데이터로 첨부하기 위한 데코레이터를 만듭니다.
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles'; // 메타데이터 키를 상수로 정의하여 오타 방지 및 재사용성 향상
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);SetMetadata(key, value): NestJS에서 메타데이터를 클래스나 메서드에 첨부할 때 사용하는 함수입니다.ROLES_KEY는 메타데이터를 저장할 키이며,roles배열은 해당 키에 저장될 값입니다.
RolesGuard) 생성
RolesGuard는 ROLES_KEY로 첨부된 메타데이터를 읽어 현재 사용자의 역할과 비교하여 접근을 허용하거나 거부합니다.
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; // 메타데이터를 읽기 위한 Reflector 임포트
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {} // Reflector 주입
canActivate(context: ExecutionContext): boolean {
// 1. @Roles() 데코레이터로부터 필요한 역할(roles) 메타데이터를 가져옵니다.
// getAllAndOverride는 현재 핸들러(메서드)에 붙은 메타데이터가 있으면 그것을, 없으면 클래스에 붙은 메타데이터를 가져옵니다.
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(), // 메서드 레벨
context.getClass(), // 클래스 레벨
]);
// 2. 만약 @Roles() 데코레이터가 붙어있지 않다면 (requiredRoles가 undefined),
// 해당 라우트는 역할 제한이 없으므로 접근을 허용합니다.
if (!requiredRoles) {
return true;
}
// 3. 현재 요청의 사용자 객체를 가져옵니다.
// 이 user 객체는 JwtStrategy.validate()에서 반환되어 req.user에 주입된 것입니다.
const { user } = context.switchToHttp().getRequest();
// 4. 사용자의 역할(user.roles)이 필요한 역할(requiredRoles) 중 하나라도 포함하는지 확인합니다.
// 예를 들어, user.roles = ['user', 'admin'], requiredRoles = ['admin'] 이면 true
// user.roles = ['user'], requiredRoles = ['admin'] 이면 false
return requiredRoles.some((role) => user.roles.includes(role));
}
}Reflector: NestJS의 코어 유틸리티로,@SetMetadata()나@Roles()와 같은 데코레이터를 통해 저장된 메타데이터를 읽을 때 사용합니다.getAllAndOverride(): 메서드 레벨과 클래스 레벨에 동일한 메타데이터 키가 있을 때, 메서드 레벨의 메타데이터가 우선하도록 합니다.user.roles.includes(role): 실제 사용자 객체에roles배열이 있다고 가정하고, 필요한 역할 중 하나라도 사용자가 가지고 있는지 확인합니다.
위 기준표처럼 requiredRoles가 없으면 통과하고, 값이 있으면 requiredRoles.some(...) 결과가 true일 때만 접근이 허용됩니다.
아래 흐름은 AuthGuard('jwt')가 먼저 req.user를 만든 뒤, RolesGuard가 @Roles() 메타데이터와 사용자 역할을 비교해 허용 또는 403 응답으로 나누는 과정을 정리한 것입니다.
AuthModule에 RolesGuard 등록 (옵션)
RolesGuard는 특정 컨트롤러나 메서드에 @UseGuards(RolesGuard) 데코레이터로 적용할 수 있습니다. 하지만 모든 라우트에 항상 가드를 적용해야 한다면 전역 가드로 등록할 수도 있습니다. 일반적으로는 필요한 곳에만 명시적으로 적용하는 것이 권장됩니다.
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LocalStrategy } from './strategies/local.strategy';
import { JwtStrategy } from './strategies/jwt.strategy';
import { UsersModule } from '../users/users.module';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { AuthController } from './auth.controller';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core'; // 전역 가드 등록을 위해 필요
import { RolesGuard } from './guards/roles.guard'; // RolesGuard 임포트
@Module({
imports: [
UsersModule,
PassportModule,
ConfigModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'),
signOptions: { expiresIn: '600s' }, // 테스트를 위해 10분으로 늘림
}),
inject: [ConfigService],
}),
],
providers: [
AuthService,
LocalStrategy,
JwtStrategy,
// { // 이 부분을 주석 처리하면 개별 라우트에 @UseGuards로 명시적으로 적용해야 합니다.
// provide: APP_GUARD, // 전역 가드로 등록 (필요에 따라 주석 해제)
// useClass: RolesGuard,
// },
],
controllers: [AuthController],
exports: [AuthService],
})
export class AuthModule {}RolesGuard와 @Roles() 데코레이터 사용
import { Controller, Get, UseGuards, Request } from '@nestjs/common';
import { AppService } from './app.service';
import { AuthGuard } from '@nestjs/passport'; // JWT 인증 가드
import { RolesGuard } from './auth/guards/roles.guard'; // 권한 부여 가드
import { Roles } from './common/decorators/roles.decorator'; // 커스텀 데코레이터
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
// JWT 인증만 필요한 라우트 (누구나 로그인하면 접근 가능)
@UseGuards(AuthGuard('jwt'))
@Get('profile')
getProfile(@Request() req) {
return req.user; // JwtStrategy에서 반환된 사용자 정보 (roles 포함)
}
// 'admin' 역할만 접근 가능한 라우트
@UseGuards(AuthGuard('jwt'), RolesGuard) // JWT 인증 후 RolesGuard로 권한 확인
@Roles('admin') // 이 라우트에 접근하려면 'admin' 역할을 가지고 있어야 합니다.
@Get('admin-dashboard')
getAdminDashboard(@Request() req) {
return `Welcome, ${req.user.username}! You are an admin and can access this dashboard.`;
}
// 'user' 또는 'editor' 역할만 접근 가능한 라우트
@UseGuards(AuthGuard('jwt'), RolesGuard)
@Roles('user', 'editor') // 이 라우트에 접근하려면 'user' 또는 'editor' 역할 중 하나를 가지고 있어야 합니다.
@Get('user-content')
getUserContent(@Request() req) {
return `Hello, ${req.user.username}! You can view user content.`;
}
}@UseGuards(AuthGuard('jwt'), RolesGuard): 여러 가드를 사용할 경우, 배열로 전달하면 순서대로 실행됩니다. 먼저AuthGuard('jwt')가 JWT를 검증하고req.user에 사용자 정보를 주입한 후,RolesGuard가 실행되어req.user.roles를 기반으로 권한을 확인합니다.
RBAC 시스템 테스트해보기
애플리케이션을 실행합니다. npm run start:dev
testuser (role: user)로 로그인하여 JWT 획득POSThttp://localhost:3000/auth/login- Body:
{"username": "testuser", "password": "password123"} - 응답에서
access_token을 복사합니다.
testuser로 admin-dashboard 접근 시도GEThttp://localhost:3000/admin-dashboard- Headers:
Authorization: Bearer <testuser의 access_token> - 결과:
403 Forbidden응답 (RolesGuard가 접근을 거부함).
testuser로 user-content 접근 시도GEThttp://localhost:3000/user-content- Headers:
Authorization: Bearer <testuser의 access_token> - 결과:
200 OK응답 (testuser는user역할을 가지고 있으므로 접근 허용됨).
admin (role: admin)으로 로그인하여 JWT 획득POSThttp://localhost:3000/auth/login- Body:
{"username": "admin", "password": "adminpassword"} - 응답에서
access_token을 복사합니다.
admin으로 admin-dashboard 접근 시도GEThttp://localhost:3000/admin-dashboard- Headers:
Authorization: Bearer <admin의 access_token> - 결과:
200 OK응답 (admin은admin역할을 가지고 있으므로 접근 허용됨).
admin으로 user-content 접근 시도GEThttp://localhost:3000/user-content- Headers:
Authorization: Bearer <admin의 access_token> - 결과: 예시 코드 그대로라면
403 Forbidden응답입니다.@Roles('user', 'editor')는user나editor중 하나를 요구하므로,admin도 접근시켜야 한다면@Roles('admin', 'user', 'editor')처럼 명시하거나 역할 계층 정책을 별도로 구현해야 합니다.
RBAC는 라우트에 필요한 역할, JWT에 담긴 사용자 역할, 가드의 비교 규칙이 서로 어긋나지 않는지 확인하는 것이 핵심입니다.
RBAC 구현은 @Roles(), JWT payload의 roles, RolesGuard 비교 규칙이 같은 역할 모델을 바라볼 때 안정적으로 동작합니다.
다음 절에서는 외부 제공자 계정으로 로그인하는 OAuth 흐름을 연결합니다.