안동민 개발노트 아이콘

안동민 개발노트

4장 : 인증과 권한 부여

역할 기반 접근 제어 (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): 이 메타데이터를 읽어서 현재 인증된 사용자가 해당 역할을 가지고 있는지 확인합니다.
단계 1: ConfigModuleJwtModule 설정 확인

이전 절에서 AuthModuleConfigModule을 임포트하고 JwtModule.registerAsync에서 ConfigService를 사용하여 JWT_SECRET을 가져왔습니다. RBAC를 위해서는 사용자의 roles 정보가 JWT 페이로드에 포함되어야 합니다.

auth.service.ts에서 JWT 페이로드에 roles 추가 확인
src/auth/auth.service.ts (변경 없음, 이전 절에서 이미 포함)
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 추출 확인
src/auth/strategies/jwt.strategy.ts (변경 없음, 이전 절에서 이미 포함)
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() 생성

라우트 핸들러에 필요한 역할을 메타데이터로 첨부하기 위한 데코레이터를 만듭니다.

src/common/decorators/roles.decorator.ts
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) 생성

RolesGuardROLES_KEY로 첨부된 메타데이터를 읽어 현재 사용자의 역할과 비교하여 접근을 허용하거나 거부합니다.

src/auth/guards/roles.guard.ts
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 응답으로 나누는 과정을 정리한 것입니다.

단계 4: AuthModuleRolesGuard 등록 (옵션)

RolesGuard는 특정 컨트롤러나 메서드에 @UseGuards(RolesGuard) 데코레이터로 적용할 수 있습니다. 하지만 모든 라우트에 항상 가드를 적용해야 한다면 전역 가드로 등록할 수도 있습니다. 일반적으로는 필요한 곳에만 명시적으로 적용하는 것이 권장됩니다.

src/auth/auth.module.ts (AuthModule 업데이트)
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() 데코레이터 사용
src/app.controller.ts (업데이트)
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을 복사합니다.
testuseradmin-dashboard 접근 시도
  • GET http://localhost:3000/admin-dashboard
  • Headers: Authorization: Bearer <testuser의 access_token>
  • 결과: 403 Forbidden 응답 (RolesGuard가 접근을 거부함).
testuseruser-content 접근 시도
  • GET http://localhost:3000/user-content
  • Headers: Authorization: Bearer <testuser의 access_token>
  • 결과: 200 OK 응답 (testuseruser 역할을 가지고 있으므로 접근 허용됨).
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 응답 (adminadmin 역할을 가지고 있으므로 접근 허용됨).
admin으로 user-content 접근 시도
  • GET http://localhost:3000/user-content
  • Headers: Authorization: Bearer <admin의 access_token>
  • 결과: 예시 코드 그대로라면 403 Forbidden 응답입니다. @Roles('user', 'editor')usereditor 중 하나를 요구하므로, admin도 접근시켜야 한다면 @Roles('admin', 'user', 'editor')처럼 명시하거나 역할 계층 정책을 별도로 구현해야 합니다.

RBAC는 라우트에 필요한 역할, JWT에 담긴 사용자 역할, 가드의 비교 규칙이 서로 어긋나지 않는지 확인하는 것이 핵심입니다.


RBAC 구현은 @Roles(), JWT payload의 roles, RolesGuard 비교 규칙이 같은 역할 모델을 바라볼 때 안정적으로 동작합니다.

다음 절에서는 외부 제공자 계정으로 로그인하는 OAuth 흐름을 연결합니다.