NestJS 帶你飛! 시리즈 번역 25# Authentication & RBAC

2023. 6. 29. 01:02개발 문서 번역/NestJS

728x90
이 포스팅은 「NestJS 기초실무 가이드 : 강력하고 쉬운 Node.js 웹 프레임워크로 웹사이트 만들기」
(서명: NestJS 基礎必學實務指南:使用強大且易擴展的 Node.js 框架打造網頁應用程式)
책이 출간되었습니다.

 

기업들은 관리 시스템을 통해 인력 자원을 관리하곤 하는데
이 관리 시스템은 일반적으로 권한 설계(Permission, 權限設計)를 통해 의도치 않게 높은 권한이 부여되어 발생할 수 있는
위험을 방지하는데 도움을 줍니다.

예를들면
우리에게 익숙한 YouTube는 YouTube Premium을 출시하여 돈을 지불한 회원에겐 광고를 제거해준다던지,
YouTube Music과 같은 기능을 제공해줍니다.
이 또한 권한 설계 중 한 방법이며, 권한 설계엔 다양한 방법들이 존재합니다.

본 포스팅에서는 가장 널리 쓰이는 권한 설계 방식인 역할 기반 접근 제어 (Role-based access control, 以角色為基礎的存取控制) 약칭 RBAC에 대해 설명하겠습니다.

 

RBAC

RBAC은 정말 간단한 개념입니다. 기업용 관리 시스템을 예로 들어보겠습니다.

사용자에게 역할(Role, 角色)을 부여합니다. (관리자, 직원 등 매 역할마다 권한이 각각 다르게 부여됨. ex: 관리자는 직원 삭제 가능하고... 직원은 관리자 삭제 불가능 하도록.. 하는 방식은 '역할'이 기반이되어 권한을 부여하는 방식이 바로 RBAC 입니다.)

일반적으로 RBAC를 설계할 때의 요구사항은 어떤 시스템을 설계하느냐에 따라 다릅니다.
물론 그에 대한 구현 난이도도 다를 것입니다.

대략적으로 나누어본다면 크게 2개로 나눌 수 있습니다.

 

정적 권한

권한, 역할이 임의로 변경되지 않는 시스템을 가리킵니다.

관리 시스템이 관리자, 직원으로 나뉘며, 해당 역할을 맡은 사람들의 작업이 임의로 변경되지 않는다면,

구현함에 있어 요구사항은 무척 간단해질것 입니다.

 

동적 권한

권한, 역할이 유저에 의해 정해지고 설계되는 시스템을 가리킵니다.

AWS를 사용해보셨다면 익숙하실것입니다.
AWS에는 각 사용자마다 권한을 부여할 수 있으며 권한들의 종류도 매우 다양하며 복잡합니다.

각기 역할을 체크 박스를 체크하거나 해제하는 구성 방식을 통해 설정할 수 있습니다. 

 

RBAC란 무엇인가요?

설계하고자 하는 시스템의 요구사항에 따라 구현 방식은 천차만별이지만

가장 널리 쓰이는 방법은 데이터베이스를 설계하여 사용자, 역할, 권한등의 데이터들을 관련짓는 방법 입니다.

이 RBAC를 지원하는 패키지들의 종류도 다양하지만, 이 포스팅에서는 Casbin이 배울 가치가 있다고 생각하여 Casbin을 통한 역할 기반 제어를 알려드리겠습니다.

 

Casbin

이미지 출처 : https://github.com/casbin/casbin

Casbin은 권한 설계를 전문적으로 처리해주는 라이브러리로서 ACL, RBAC, ABAC등의 권한 설계를 지원해줍니다.

로고를 보니 뭔가 익숙하지 않나요? 맞습니다. 이 라이브러리는 Golang과 밀접한 관련이 있습니다.

하지만 Casbin은 Golang에만 국한되지 않고 Node.js, PHP, Python등에도 모두 사용이 가능하며 최근에 엄청 환영받고 있는 라이브러리 입니다.

주의: Casbin은 초보자에게는 조금 어려울수도 있습니다.
그렇기 때문에 이번 포스팅에서는 최대한 간단하게 소개하도록 하겠습니다.

 

Casbin 개념

Casbin은 두개로 나눌 수 있습니다. 

 

접근 제어 모델 (Access Control Model, 存取控制模型)

접근 제어 모델이란 간단히 말하면 인증 규칙을 정의하는 곳입니다. Casbin에서 model.conf라는 설정 파일을 하나 만들어보겠습니다. 이 파일은 PERM 모델을 기반으로 추상화됩니다. 인증 규칙을 하나의 파일만으로 해결할 수 있습니다.

그렇다면 PERM 모델은 무엇일까요? 이는 요청(Request, 請求), 정책(Policy, 政策), 매처(Matcher,驗證器), 효과(Effect, 效果) 4개의 요소로 나뉩니다.

그러나 RBAC는 역할 정의(Role Definition, 角色定義)라는 요소가 하나 더 있습니다.

 

요청(Request, 請求)

인증을 정의할 때에 필요한 파라미터와 순서는

주체와 실체 (Subject,主題/實體), 대상과 자원(Object, 對象/資源), 동작과 방법(Action, 操作/方式)와 같이 순서가 보장되어야 합니다.

아래는 그 예시입니다.

[request_definition]
r = sub, obj, act

위의 예제에서 보았던 파라미터의 정의는 다음과 같습니다.

  • [request_definition]: 요청을 정의할 때 해당 구문을 맨 처음에 정의해야 합니다.
  • r: 변수 이름입니다. [request_definition]을 정의했기 때문에 해당 변수는 요청을 나타냅니다.
  • sub: 주체를 나타냅니다. 해당 주체는 사용자, 역할 등이 있을 수 있습니다.
  • obj: 대상을 나타냅니다. 해당 대상은 자원 등이 있을 수 있습니다.
  • act: 동작을 나타냅니다. 해당 동작은 어떤 자원에 행하는 모든 동작의 이름이 될 수 있습니다.

보다 간단한 용어로 설명하면 다음과 같습니다. 

요청(r)누가(sub) 어떤 대상(obj)에 대해 작업(act)을 하겠다. 라는 정보를 제공합니다.

그럼 다시 RBAC의 개념에 기반해 설명해보자면..

요청(r)역할(sub)대상 자원(obj)에 대해 특정 작업(act)을 진행 하겠다. 라는 정보를 제공합니다.

 

정책(Policy, 政策)

정책 모델의 구조를 정의하여 이후 정책 모델을 사전에 정의한 구조에 따라 작성할 수 있게 합니다.

아래는 정책의 예시입니다.

[policy_definition]
p = sub, obj, act, eft

위의 예제에서 보았던 파라미터의 정의는 다음과 같습니다.

  • [policy_definition]: 정책을 정의할 때 해당 구문을 맨 처음에 정의해야 합니다.
  • p: 변수 이름입니다. [policy_definition]을 정의했기 때문에 해당 변수는 정책을 나타냅니다.
  • sub: 주체를 나타냅니다. 
  • obj: 대상을 나타냅니다.
  • act: 동작을 나타냅니다. 
  • eft: 허용(allow, 允許)이나 거절(deny, 拒絕)을 나타냅니다. 선택항목이며 기본값은 allow입니다.

보다 간단한 용어로 설명하면 다음과 같습니다. 

정책(p)누가(sub) 어떤 것(obj)에 대해 동작(act)을 할 때 해당 동작을 허용할지 말지(eft) 를 나타냅니다.

그럼 다시 RBAC의 개념에 기반해 설명해보자면..

정책(p)역할(sub)대상 자원(obj)에 대해 특정 작업(act)을 할 때 해당 작업을 허용할지 말지(eft) 에 대한 규칙 설명을 제공해줍니다.

 

매처(Matcher, 驗證器)

요청이 가져온 정보들이 정책 모델에 정의된 규칙에 부합하는지를 확인하는 조건문이라고 할 수 있습니다.

인증 프로세스가 실행될 때 요청, 정책 모델의 값들을 가져와 인증을 수행합니다.

아래는 매처의 예시입니다.

[matchers]
m = r.sub == p.sub && r.act == p.act && r.obj == p.obj

위의 예제에서 보았던 파라미터의 정의는 다음과 같습니다.

  • [matchers]: 매처를 정의할 때 해당 구문을 맨 처음에 정의해야 합니다.
  • m: 변수 이름입니다. [matchers]를 정의했기 때문에 해당 변수는 매처를 나타냅니다.
  • r: 변수 이름입니다. 앞의 [request_definition]에 의해 이미 요청으로써 선언되었습니다.
  • p: 변수 이름입니다. 앞의 [policy_definition]에 의해 이미 정책으로써 선언되었습니다.
  • r.sub: 요청의 주체를 나타냅니다.
  • p.sub: 정책의 주체를 나타냅니다.
  • r.obj: 요청의 대상을 나타냅니다.
  • p.obj: 정책의 대상을 나타냅니다.
  • r.act: 요청에 대한 작업을 나타냅니다.
  • p.act: 정책에 대한 작업을 나타냅니다.

위의 예제로 좀더 간단하게 설명해보자면

요청 주체(r.sub)정책 주체(p.sub)와 일치해야 하며,
요청 작업(r.act)정책 작업(p.act)와 일치해야 하고
요청 대상(r.obj)정책 대상(p.obj)과 일치해야 한다.

라고 할 수 있겠습니다.

 

효과(Effect, 效果)

인증 결과에 대한 추가적인 검증을 수행할 수 있습니다.

아래는 효과의 예제입니다.

[policy_effect]
e = some(where (p.eft == allow))

위의 예제에서 보았던 파라미터의 정의는 다음과 같습니다.

  • [policy_effect]: 효과를 정의할 때 해당 구문을 맨 처음에 정의해야 합니다.
  • e: 변수 이름입니다.  [policy_effect]를 정의했기 때문에 해당 변수는 효과를 나타냅니다.
  • p.eft: 정책의 허용값입니다.
  • allow: eft의 결과중 하나입니다.

위의 예제로 좀더 간단하게 설명해보자면

인증 결과중 정책의 허용값이 allow인 결과만이 해당 검증을 통과하였음을 의미합니다.

역할 정의(Role Definition, 角色定義)

역할 정의를 구현해보겠습니다. 이는 필수 항목은 아닙니다.

아래는 해당 예제입니다.

[role_definition]
g = _, _

 

  • [role_definition]: 역할을 정의할 때  해당 구문을 맨 처음에 정의해야 합니다.
  • g: 변수 이름입니다. [role_definition]을 정의했기 때문에 해당 변수는 역할 정의를 나타냅니다.

예제에서는 _, _ 로 구성하였습니다.

이 의미는 앞의 역할이 뒤의 역할의 권한을 상속받는다는 의미이며,

이 방법을 사용해 역할과 자원 사이의 관계를 바인딩 할 수 있습니다.
후에 이 부분에 대해 더 완벽히 구현한 예제와 설명을 제공하겠습니다. 

 

정책 모델 (Policy Model, 政策模型)

정책 모델은 역할과 자원이 엑세스하는 곳입니다.
다시 말해 어떤 역할이 어떤 자원에 대한 어떤 작업을 할지 명확하게 정책 모델에서 정의해줍니다.

Casbin에선 policy.csv파일을 생성해 가장 간단하게 정책 모델을 구현할 수 있습니다.

본 포스팅에서도 csv파일을 사용해 정책 모델을 구현하고, 소개하겠습니다.

 

모델 정의

모델을 정의하는 방법은 간단합니다. 앞에서 정의했던 정책이 pp = sub, obj, act라는걸 기억하고 계신가요?

해당 구조를 따라 정의해주면 됩니다. 주의해야 할 사항으로는 반드시 지정했던 정책 변수 이름을 입력해주어야 합니다.

아래는 해당 예제입니다.

p, role:staff /todos read

 

정책을 사용한 p가 정책 모델이며, 해당 모델의 subrole:staff, obj/todos, actread라는것을 알 수 있습니다.

sub는 역할, obj는 자원, act는 자원에 대한 동작과 완전히 일치합니다.

 

역할 상속

role:manager, role:staff라는 두개의 역할이 있다고 가정해봅시다.

이런 상황에서는 어떻게 역할에 대한 상속을 할 수 있을까요?
바로 앞전에 언급했던 역할 정의를 통해 상속을 할 수 있습니다.

g로 시작해 상속할 역할을 앞쪽에, 상속 받고자 하는 역할을 뒤쪽에 작성해주면 됩니다.

아래는 해당 예제입니다.

p, role:staff /todos read
p, role:manager /todos write

g, role:manager role:staff

위와 같이 작성해주면 정책 모델에서 상속 관계가 형성됩니다.
하지만 아직 한곳을 더 수정해야 하는데 바로 앞전에 얘기했던 매처 부분입니다.
매처도 반드시 sub에 대한 매칭을 진행하기 위해 g를 반드시 호출해주어야 합니다.

m = g(r.sub, p.sub) && r.act == p.act && r.obj == p.obj

 

RBAC 구현

 

npm을 통해 node-casbin을 설치해줍시다.

$ npm install casbin

 

규칙 정의

설치가 완료되었으면 프로젝트의 폴더 아래 casbin이라는 폴더와 model.confpolicy.csv를 생성해줍시다.

아래는 model.conf의 예제입니다.

[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[role_definition]
g = _, _

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && (r.act == p.act || p.act == '*')

간단히 매처의 규칙에 대해 설명하겠습니다.
keyMatch2 함수가 보입니다.
이 함수는 주로 라우팅 자원의 매칭에 사용됩니다. 무척 유용한 기능중 하나입니다.
게다가 p.act === '*'는 모든 동작 권한을 가지고있음을 의미합니다.

정책 모델에서 해당 역할의 act*일시 요청에서 어떠한 act를 가지고 오더라도 해당 역할과 자원이 맞다면
어떠한 요청도 허용합니다.

주의: 더 많은 함수들은 공식 문서를 참고해주세요.

 

아래는 policy.csv의 예제입니다.

p, role:admin, /todos, *
p, role:admin, /todos/:id, *
p, role:staff, /todos, read
p, role:staff, /todos/:id, read
p, role:manager, /todos, create
p, role:manager, /todos/:id, update

g, role:manager, role:staff

 

role:admin/todo/todos/:id의 모든 작업을 수행할 수 있습니다.

role:staff/todos/todos/:idread 작업만을

role:managerrold:staff를 상속받아 read를 포함한 create, update 기능을 수행할 수 있습니다.

 

모듈 제작

node-casbin은 Nest Module을 제공하고 있지 않기 때문에 해당 패키지를 래핑하여 사용해야합니다.

CLI를 통해 AuthorizationModuleAuthorizationService를 생성하겠습니다.

$ nest generate module common/authorization
$ nest generate service common/authorization

 

Casbin은 어떤 플랫폼이던 model.confpolicy.csv를 적용하여 enforcer를 구성하여야 합니다.

이런 서드 파티 객체는 커스텀 Provider의 방법으로 처리하기에 매우 적합합니다. 

src/core/modules/authorization/constants 폴더 아래 token.const.ts를 생성하여 enforcer에 주입할 token을 설계하겠습니다.

export const AUTHORIZATION_ENFORCER = 'authorization_enforcer';

model.confpolicy.csv의 경로가 모듈 외부로 제공되게끔 하고싶습니다.

먼저 interface를 하나 만들어 입력값을 지정합니다.
src/common/authorization 아래 models 폴더와 option.model.ts을 생성해줍니다.

export interface RegisterOptions {
  modelPath: string;
  policyAdapter: any;
  global?: boolean;
}

modelPathmodel.conf의 경로입니다. 여기서 주의해야 할 점은 policyAdapter입니다.

Casbin은 정책 모델을 관리하기 위한 데이터베이스를 지원하기 때문에 enforcerpolicy는 데이터베이스의 Adapter를 통해 연결이 가능합니다.

당연하게도 policy.csv 파일의 경로를 직접 제공할 수도 있습니다.

 

이어서 AuthorizationModuleDynamicModule로 만들어 enforcerAuthorizationService를 내보내겠습니다.

import { DynamicModule, Module } from '@nestjs/common';

import { newEnforcer } from 'casbin';

import { AuthorizationService } from './authorization.service';
import { AUTHORIZATION_ENFORCER } from './constants/token.const';
import { RegisterOptions } from './models/option.model';

@Module({})
export class AuthorizationModule {
  static register(options: RegisterOptions): DynamicModule {
    const { modelPath, policyAdapter, global = false } = options;
    const providers = [
      {
        provide: AUTHORIZATION_ENFORCER,
        useFactory: async () => {
          const enforcer = await newEnforcer(modelPath, policyAdapter);
          return enforcer;
        },
      },
      AuthorizationService,
    ];

    return {
      global,
      providers,
      module: AuthorizationModule,
      exports: [...providers],
    };
  }
}

 

이제 역할의 자원에 대한 동작을 매칭시키기 위해서는 enum을 하나 설계해야 합니다. 

src/common/authorization 경로 types 폴더 아래 action.type.ts를 생성하겠습니다.

export enum AuthorizationAction {
  CREATE = 'create',
  READ = 'read',
  UPDATE = 'update',
  DELETE = 'delete',
  NONE = 'none',
}

AuthorizationService는 주로 권한 검사와 HttpMethod를 AuthorizationAction으로 변환해주는 역할을 합니다.

중요한 것은 enforcerenforce 메서드에 파라미터가 model.conf의 요청과 일치한다는 것입니다.

import { Inject, Injectable } from '@nestjs/common';

import { Enforcer } from 'casbin';

import { AUTHORIZATION_ENFORCER } from './constants/token.const';
import { AuthorizationAction } from './types/action.type';

@Injectable()
export class AuthorizationService {
  constructor(
    @Inject(AUTHORIZATION_ENFORCER) private readonly enforcer: Enforcer,
  ) {}

  public checkPermission(subject: string, object: string, action: string) {
    return this.enforcer.enforce(subject, object, action);
  }

  public mappingAction(method: string): AuthorizationAction {
    switch (method.toUpperCase()) {
      case 'GET':
        return AuthorizationAction.READ;
      case 'POST':
        return AuthorizationAction.CREATE;
      case 'PATCH':
      case 'PUT':
        return AuthorizationAction.UPDATE;
      case 'DELETE':
        return AuthorizationAction.DELETE;
      default:
        return AuthorizationAction.NONE;
    }
  }
}

 

마지막으로 외부에서 사용될 수도 있는 컴포넌트들을 index.ts에서 통합하여 내보내겠습니다.

export { AuthorizationModule } from './authorization.module';
export { AuthorizationService } from './authorization.service';
export { AUTHORIZATION_ENFORCER } from './constants/token.const';

 

Guard 구현

일반적으로 권한 설계와 인증은 밀접한 관계를 갖습니다.

passport가 인증이 완료되면  관련있는 데이터들을 요청객체의 user 속성 안에 삽입되던 것 기억 하시나요?

이런 방식을 활용해 사용자의 역할을 가져와 RoleGuard에서 역할 권한을 검증할 수 있습니다.

CLI를 통해 RoleGuard를 생성해 보겠습니다.

$ nest generate guard common/guards/role

 

하지만 여기선 인증기능을 구현하지 않았기 때문에 더미 데이터를 사용해 passport 인증 후의 상황을 모방하겠습니다.

import {
  CanActivate,
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';

import { Request } from 'express';
import { Observable } from 'rxjs';
import { AuthorizationService } from 'src/common/authorization';



@Injectable()
export class RoleGuard implements CanActivate {
  constructor(private readonly authorizationService: AuthorizationService) {}

  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const request: Request = context.switchToHttp().getRequest();
    (request as any).user = { role: 'manager' }; // 더미데이터로 인증 기능 실측
    const { user, path, method } = request as any;
    const action = this.authorizationService.mappingAction(method);

    if (!user) {
      throw new UnauthorizedException();
    }

    return this.authorizationService.checkPermission(
      `role:${user.role}`,
      path,
      action,
    );
  }
}

 

실제 테스트 결과

TodoModule을 생성해 몇개의 API를 통한 테스트를 진행하겠습니다.

$ nest generate module features/todo
$ nest generate controller features/todo
$ nest generate service features/todo

 

TodoService에 배열을 통해 데이터를 생성한 후 해당 데이터들에 대한 검색, 업데이트와 삭제 기능을 추가하겠습니다.

import { Injectable } from '@nestjs/common';

@Injectable()
export class TodoService {
  todos = [
    {
      id: 1,
      title: 'Ironman 13th',
      completed: false,
    },
    {
      id: 2,
      title: 'Study NestJS',
      completed: true,
    },
  ];

  findById(id: string) {
    return this.todos.find((todo) => todo.id === Number(id));
  }

  updateById(id: string, data: any) {
    const todo = this.findById(id);
    return Object.assign(todo, data);
  }

  removeById(id: string) {
    const idx = this.todos.findIndex((todo) => todo.id === Number(id));
    this.todos.splice(idx, 1);
  }
}

3개의 API를 설계한 후 TodoController를 수정해줍니다.

import {
  Body,
  Controller,
  Delete,
  Get,
  Param,
  Patch,
  UseGuards,
} from '@nestjs/common';
import { RoleGuard } from 'src/common/guards/role/role.guard';
import { TodoService } from './todo.service';

@Controller('todos')
export class TodoController {
  constructor(private readonly todoService: TodoService) {}

  @UseGuards(RoleGuard)
  @Get(':id')
  getTodo(@Param('id') id: string) {
    return this.todoService.findById(id);
  }

  @UseGuards(RoleGuard)
  @Patch(':id')
  updateTodo(@Param('id') id: string, @Body() body: any) {
    return this.todoService.updateById(id, body);
  }

  @UseGuards(RoleGuard)
  @Delete(':id')
  removeTodo(@Param('id') id: string) {
    this.todoService.removeById(id);
    return this.todoService.todos;
  }
}

 

마지막으로 AppModule에서 사전에 만들었던 AuthorizationModule을 사용하겠습니다.

import { Module } from '@nestjs/common';

import { join } from 'path';

import { AuthorizationModule } from './common/authorization/authorization.module';

import { TodoModule } from './features/todo/todo.module';

import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [
    AuthorizationModule.register({
      modelPath: join(__dirname, '../casbin/model.conf'),
      policyAdapter: join(__dirname, '../casbin/policy.csv'),
      global: true,
    }),
    TodoModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

 

현재 테스트하려는 역할은 role:manager 입니다. role:staff를 상속하였기 때문에 읽기 권한에도 접근할 수 있습니다.

 

role:manager는 업데이트 권한이 있기때문에, 수정도 가능합니다. 

하지만 role:manager는 자원에 대한 삭제 권한이 없기때문에 아래와 같이 403 에러를 반환합니다.

 

마치며

권한 설계는 매우 보편적인 기능중 하나입니다. 배울만한 가치가 있는 영역이며

권한 설계의 방법은 여러 종류가 있습니다. 이번 포스팅에서는 RBAC, 가장 널리 쓰이는 설계 방식을 배워보았습니다.

이 포스팅을 보았다면 다들 이 권한 설계에 한발짝 더 다가갔다고 할 수 있겠습니다.

아래는 오늘 학습의 요약본입니다.

 

1. RBAC는 역할을 기반으로 다른 권한을 부여함을 의미합니다.

2. Casbin은 권한 설계를 전문적으로 다루는 라이브러리입니다.
ACL, RBAC, ABAC등의 다양한 권한 부여 메커니즘을 설계하는데 쓰입니다.

3. Casbin은 접근 제어 모델과 정책 모델로 구성됩니다.

4. 접근 제어 모델은 4개의 요소로 나뉩니다. 요청, 정책, 매처, 효과  RBAC에서는 역할 정의 요소가 추가됩니다.

5. Casbin은 주체, 대상과 작업 세 가지 요소를 중심으로 구성됩니다.

6. node-casbin은 Nest Module을 제공하지 않기 때문에 따로 수동으로 래핑해주어야 합니다.

7. Casbin은 enforcer 객체로 modelpolicy를 불러옵니다.

8. policy는 간단하게 csv로 구현하거나 Adapter의 방식으로 데이터베이스와 연동하여 구현할 수 있습니다.