1/18/2024

역할 기반 인가

발단

관리자만이 실행할 수 있는 기능이 필요하면 인가 기능을 반드시 구현해야 하는데 다른 기능 구현한다고 바빠서 못했는데 드디어 시간이 나서 기회가 왔다! 인가를 구현하는 방법은 2가지가 있는데 하나는 역할 기반 접근 제어(RBAC)으로 역할과 특권(privilege)을 중심으로 정의된 정책 중립적인 접근 제어 메커니즘이다. 다른 방법인 클레임(claim) 기반 권한 부여는 특정 역할을 확인하는 대신 권한(permission)을 비교해야 한다. 나는 기초적인 방법인 RBAC를 구현했다. NestJS 공식 문서를 읽고 구글링을 통해 배운점과 기능 구현에 대해 적어본다~ 


데코레이터

NestJS 공식 문서를 읽으면 @UseGuards() 데코레이터를 사용한 인증과 달리 인가는 @Roles() 데코레이터를 추가로 만든다. 왜 굳이 @Roles() 데코레이터를 만들까? NestJS 공식 문서의 OVERVIEW의 Guards의 Setting roles per handler에 따르면 가드의 가장 중요한 기능인 실행 컨텍스트를 활용하기 위해서다! 즉, 실행 컨텍스트에서 역할과 핸들러에 허용되는 역할과 같은 정보를 추출해야 하는데 여기서 사용자 정의 메타데이터가 등장한다. NestJS는 Reflector#createDecorato() 정적 메서드를 통해 생성된 데코레이터 또는 내장 @SetMetadata() 데코레이터를 통해 경로 핸들러에 사용자 정의 메타데이터를 첨부할 수 있는 기능을 제공한다. 두 가지 방법에 대한 설명은 공식 문서에 나와있다. 나는 SECURITY Authorization의 Basic RBAC implementation을 보면서 @SetMetadata() 데코레이터를 사용하기로 했다.
1
2
3
4
5
6
7
8
9
10
11
12
// role.enum.ts
export enum Role {
  USER = 'USER',
  MANAGER = 'MANAGER',
  ADMIN = 'ADMIN',
}
 
// roles.decorator.ts
// @SetMetadata() 데코레이터는 특정 자원에 접근하기 위한 역할이 무엇인지 지정한다.
// ROLES_KEY는 메타데이터 키, roles는 열거형 Role의 값.
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
cs


가드

이제 사용자에게 할당된 역할과 경로에서 필요한 실제 역할을 비교할 가드를 만들어야 한다. 가드 구현 역시 NestJS 공식 문서에 자세하게 나와있으며 필요한 부분만 추가 및 수정 했다. 경로에서 사용자 정의 메타데이터를 추출하려면 Reflector 클래스의 getAllAndOverride() 메서드 혹은 getAllAndMerge() 메서드 사용하면 되는데 컨트롤러의 메타데이터보다 메서드의 메타데이터를 우선하는 getAllAndOverride() 메서드를 사용하기로 했다. getAllAndMerge() 메서드는 두 개의 메타데이터를 병합해서 추출한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Injectable()
export class RolesGuard implements CanActivate {
  // 경로에 주어진 역할(즉, 사용자 정의 메타데이터)에 접근하기 위해 Reflector 클래스를 사용한다.
  // Reflector 클래스의 메서드는 컨트롤러와 메서드의 메타데이터를 동시에 추출하고 다양한 방식으로 병합한다.
  constructor(private readonly reflector: Reflector) {}
 
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    // 경로에 존재하는 역할(컨트롤러, 메서드)을 가져온다.
    const roles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
 
    if (!roles) {
      return true;
    }
 
    const request = context.switchToHttp().getRequest();
    // NodeJS에서는 일반적으로 인증된 사용자를 Request 객체에 첨부한다. 다시말해, 인증 가드에서 인증이 완료되면 사용자를 Request 객체에 첨부한다.
    const user = request.user as User;
 
    // 사용자, 사용자의 역할, 경로가 필요한 역할을 가지고 있는 경우만 허용한다.
    const authorized =
      user && user.roles && roles.some((role) => user.roles?.includes(role));
 
    if (authorized) {
      return true;
    }
 
    throw new ForbiddenException('역할 없음.');
  }
}
cs


적용

적용은 컨트롤러 혹은 메서드에 @Roles() 데코레이터에 특정 역할을 명시하고 @UseGuards() 데코레이터에 역할 가드를 삽입한다. 애플리케이션을를 실행시키고 역할인 USER인 사용자가 아래 코드의 API를 호출하면 다음과 같은 예외가 발생한다~
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 역할이 'admin'인 사용자만 컨트롤러의 API를 사용할 수 있다.
@Roles(Role.ADMIN)
@UseGuards(JwtAuthGuard, RolesGuard)
@Controller('admin')
export class AdminController {
  constructor(private readonly adminService: AdminService) {}
 
  @HttpCode(HttpStatus.OK)
  @Serialize(UsersDto)
  @Get('/users')
  async getUsers(@Query() paginationDto: PaginationDto) {
    return await this.adminService.findUsers(paginationDto);
  }
 
  @HttpCode(HttpStatus.OK)
  @Serialize(UserDto)
  @Patch('/users/:id')
  async updateUser(
    @Param('id', ParseIntPipe) id: number,
    @Body() updateUserDto: UpdateUserDto,
  ) {
    return await this.adminService.updateUser(id, updateUserDto);
  }
}
 
cs
역할 없음!

update: 2024.01.18

댓글 없음:

댓글 쓰기