역할 기반 접근 제어 (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 구현하기
NestJS에서 RBAC를 구현하는 가장 효율적인 방법은 가드(Guards) 와 커스텀 데코레이터(Custom Decorators) 를 조합하는 것입니다.
- 커스텀 데코레이터 (
@Roles()
): 라우트 핸들러(컨트롤러 메서드)나 컨트롤러 클래스에 필요한 역할을 메타데이터로 첨부합니다. - 가드 (
RolesGuard
): 이 메타데이터를 읽어서 현재 인증된 사용자가 해당 역할을 가지고 있는지 확인합니다.
단계 1: 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 };
}
}
단계 2: 커스텀 데코레이터 @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
배열은 해당 키에 저장될 값입니다.
단계 3: 권한 부여 가드 (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
배열이 있다고 가정하고, 필요한 역할 중 하나라도 사용자가 가지고 있는지 확인합니다.
단계 4: 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 {}
단계 5: 컨트롤러에서 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 획득
POST
http://localhost:3000/auth/login
- Body:
{"username": "testuser", "password": "password123"}
- 응답에서
access_token
을 복사합니다.
testuser
로 admin-dashboard
접근 시도
GET
http://localhost:3000/admin-dashboard
- Headers:
Authorization: Bearer <testuser의 access_token>
- 결과:
403 Forbidden
응답 (RolesGuard
가 접근을 거부함).
testuser
로 user-content
접근 시도
GET
http://localhost:3000/user-content
- Headers:
Authorization: Bearer <testuser의 access_token>
- 결과:
200 OK
응답 (testuser
는user
역할을 가지고 있으므로 접근 허용됨).
admin
(role: admin
)으로 로그인하여 JWT 획득
POST
http://localhost:3000/auth/login
- Body:
{"username": "admin", "password": "adminpassword"}
- 응답에서
access_token
을 복사합니다.
admin
으로 admin-dashboard
접근 시도
GET
http://localhost:3000/admin-dashboard
- Headers:
Authorization: Bearer <admin의 access_token>
- 결과:
200 OK
응답 (admin
은admin
역할을 가지고 있으므로 접근 허용됨).
admin
으로 user-content
접근 시도
GET
http://localhost:3000/user-content
- Headers:
Authorization: Bearer <admin의 access_token>
- 결과:
200 OK
응답 (admin
은admin
역할을 가지고 있지만, 예시RolesGuard
는some
을 사용하므로user
나editor
역할이 아니더라도admin
역할이므로 접근이 허용됩니다. 만약roles
가 정확히 일치해야 한다면every
나 다른 로직을 사용해야 합니다.)
NestJS의 가드와 커스텀 데코레이터를 활용하면 역할 기반 접근 제어(RBAC)를 매우 깔끔하고 효율적으로 구현할 수 있습니다. 이는 복잡한 권한 관리 로직을 비즈니스 로직과 분리하여 코드의 가독성과 유지보수성을 크게 향상시킵니다.