NestJS 帶你飛! 시리즈 번역 31# 실전 응용(하)

2023. 7. 3. 03:39개발 문서 번역/NestJS

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

 

역할 권한 설계

앞서 말했던 것처럼 이번 시스템에서 설계할 역할은 총 3가지이며, Casbin으로 권한 인증을 진행하겠습니다.

 

알림: Authorization와 관련된 기술은 DAY25 - Authorization & RBAC를 참고해주세요.

 

모델과 정책

Casbin의 권한 인증은 접근 제어 모델과 정책 모델로 구성됩니다. 먼저 접근 제어 모델을 정의하겠습니다.

rbac 폴더 아래 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 == '*')

 

이어서 rbac 폴더 아래 policy.csv를 생성하고 아래의 조건에 맞게 구성해줍니다.

  • admin : /users, /todos 두 자원에 대해 모든 작업이 허용됩니다.
  • member : /todos/users의 자원에 read 권한이, /todos/:idupdate 작업이 허용됩니다.
  • manager : member의 권한을 상속받아 /todoscreate 작업과 /todos/:iddelete 작업이 허용됩니다.
p, role:admin, /api/users, *
p, role:admin, /api/users/:id, *
p, role:admin, /api/todos, *
p, role:admin, /api/todos/:id, *
p, role:manager, /api/todos, create
p, role:manager, /api/todos/:id, delete
p, role:member, /api/todos, read
p, role:member, /api/todos/:id, update
p, role:member, /api/users, read

g, role:manager, role:member

 

모델 래핑

Authorization 포스팅에서 Casbin을 모델로 래핑했던것 처럼 이곳에서도 똑같이 진행하겠습니다.

src/core/modules 폴더 아래 CLI를 통해 AuthorizationModuleAuthorizationService를 생성해줍니다.

$ nest generate module core/modules/authorization
$ nest generate service core/modules/authorization

 

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

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

src/core/modules/authorization/constants 폴더 아래 token.const.ts를 생성하여

enforcer에 주입할 token을 설계하겠습니다.

 

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

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

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

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

 

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

import { newEnforcer } from 'casbin';

import { AUTHORIZATION_ENFORCER } from './constants/token.const';
import { RegisterOptions } from './interfaces/option.interface';

import { AuthorizationService } from './authorization.service';

@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는 주로 권한 검사와 HttpMethodAuthorizationAction으로 변환해주는 역할을 합니다.

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

import { Enforcer } from 'casbin';

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

@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

다음으로는 역할에 대한 접근 권한을 검증하기 위해 RoleGuard를 구현하여야 합니다.

먼저 CLI로 src/core/guards 폴더 아래 RoleGuard를 생성해줍니다.

$ nest generate guard core/guards/role

RoldGuardAuthorizationService를 통해 역할 권한 검증을 진행하여 결과를 반환합니다.

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

import { Request } from 'express';
import { Observable } from 'rxjs';

import { AuthorizationService } from '../modules/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();
    const { user, path, method } = request;
    const action = this.authorizationService.mappingAction(method);

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

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

index.ts를 생성하여 내보내기 경로를 관리하겠습니다.

 

export { JwtAuthGuard } from './jwt-auth/jwt-auth.guard';
export { LocalAuthGuard } from './local-auth/local-auth.guard';
export { RoleGuard } from './role/role.guard';

 

시스템 통합

역할 기반 접근 제어 검증 기능이 완성되었다면 RoldGuardUserControllerTodoController에 적용시켜줍니다.

아래는 UserController에 적용한 후의 예제입니다. 중요한건 RoleGuard가 추가되었다는 점입니다.

 

import {
  Body,
  ConflictException,
  Controller,
  Delete,
  ForbiddenException,
  Get,
  Param,
  Patch,
  Post,
  Query,
  UseGuards,
} from '@nestjs/common';

import { SearchPipe } from '../../core/pipes';
import { JwtAuthGuard, RoleGuard } from '../../core/guards';
import { SearchDto } from '../../core/bases';

import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';

import { UserService } from './user.service';

@UseGuards(JwtAuthGuard, RoleGuard)
@Controller('users')
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Get()
  async getUsers(@Query(SearchPipe) query: SearchDto) {
    return this.userService.findUsers(query, '-password');
  }

  @Post()
  async createUser(@Body() dto: CreateUserDto) {
    const { username, email } = dto;
    const exist = await this.userService.existUser({
      $or: [{ username }, { email }],
    });

    if (exist) {
      throw new ConflictException('username or email is already exist.');
    }

    const user = await this.userService.createUser(dto);
    const { password, ...result } = user;
    return result;
  }

  @Delete(':id')
  async deleteUser(@Param('id') id: string) {
    const response = await this.userService.deleteUser(id);
    if (!response) {
      throw new ForbiddenException();
    }
    return response;
  }

  @Patch(':id')
  async updateUser(@Param('id') id: string, @Body() dto: UpdateUserDto) {
    const user = await this.userService.updateUser(id, dto, '-password');
    if (!user) {
      throw new ForbiddenException();
    }
    return user;
  }
}

 

TodoController에도 RoleGuard를 적용시켜줍니다.

import {
  Body,
  Controller,
  Delete,
  ForbiddenException,
  Get,
  Param,
  Patch,
  Post,
  Query,
  UseGuards,
} from '@nestjs/common';

import { JwtAuthGuard, RoleGuard } from '../../core/guards';
import { SearchPipe } from '../../core/pipes';
import { SearchDto } from '../../core/bases';

import { CreateTodoDto } from './dto/create-todo.dto';
import { UpdateTodoDto } from './dto/update-todo.dto';

import { TodoService } from './todo.service';

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

  @Get()
  async getTodos(@Query(SearchPipe) query: SearchDto) {
    return this.todoService.findTodos(query);
  }

  @Post()
  async createTodo(@Body() dto: CreateTodoDto) {
    return this.todoService.createTodo(dto);
  }

  @Delete(':id')
  async deleteTodo(@Param('id') id: string) {
    const response = await this.todoService.deleteTodo(id);
    if (!response) {
      throw new ForbiddenException();
    }
    return response;
  }

  @Patch(':id')
  async updateTodo(@Param('id') id: string, @Body() dto: UpdateTodoDto) {
    const todo = await this.todoService.updateTodo(id, dto);
    if (!todo) {
      throw new ForbiddenException();
    }
    return todo;
  }
}

 

마지막으로 AppModule을 수정해줍니다. 방금 제작한 다이나믹 모듈을 등록해주어야 합니다.

    AuthorizationModule.register({
      //윈도우 dirname 이슈, src 경로로 변경
      modelPath: join('src/rbac/model.conf'),
      policyAdapter: join('src/rbac/policy.csv'),
      global: true,
    }),
import { Module, ValidationPipe } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';
import mongoConfigFactory from './configs/mongo.config';
import secretConfigFactory from './configs/secret.config';
import { APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
import { ResponseInterceptor } from './core/interceptors/response';
import { UserModule } from './features/user';
import { AuthModule } from './features/auth';
import { TodoModule } from './features/todo/todo.module';
import { AuthorizationModule } from './core/guards/modules/authorization';
import { join } from 'path';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      load: [mongoConfigFactory, secretConfigFactory],
    }),
    MongooseModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        uri: config.get<string>('mongo.uri'),
        //자신의 Mongoose 버전이 6 이상이라면, useFindAndModify 옵션을 지워주어야 오류가 없습니다.
        // Mongoose 6 이상에서는 디폴트로 해당 값을 지원합니다.
        // useFindAndModify: false,
      }),
    }),
    UserModule,
    AuthModule,
    TodoModule,
    AuthorizationModule.register({
      //윈도우 dirname 이슈, src 경로로 변경
      modelPath: join('src/rbac/model.conf'),
      policyAdapter: join('src/rbac/policy.csv'),
      global: true,
    }),
  ],
  providers: [
    {
      provide: APP_INTERCEPTOR,
      useClass: ResponseInterceptor,
    },
    {
      provide: APP_PIPE,
      useClass: ValidationPipe,
    },
  ],
})
export class AppModule {}

시스템 사용 시나리오

해당 시스템의 구현이 끝났습니다.

먼저 완성된 시스템을 간단한 검증를 통해 시스템이 어떻게 구축되었는가를 보기로 합시다.

먼저 명령어를 사용해 Nest App을 실행합니다.

$ npm run start:dev

 

로그인과 회원가입

서버를 킨 후 [POST] /api/auth/signup 으로 사용자 회원가입이 가능합니다.

 

만약 중복으로 회원가입을 하려고 시도하면 403 오류가 발생할 것입니다.

 

[POST] /api/auth/signin 경로로 로그인을 진행할 수 있습니다.

 

사용자

로그인 API로 token을 취득했으면 해당 token을 Header에 포함시켜 다른 API를 사용할 수 있게 해야합니다.

admin 역할이 있는 계정으로 3명의 사용자를 생성하겠습니다.
각 사용자의 역할은 manager, membermember로 생성하며, [POST] /api/users로 생성합니다.

usernamemanager1manager:

usernamemember1member:

usernamemember2member:

 

[PATCH] /api/users/:id 에 요청을 보내 member2 역할을 manager로 변경해보겠습니다.

 

[DELETE] /api/users/:id 에 요청을 보내 member2를 삭제 시켜보겠습니다.

 

[GET] /api/users 에 요청을 보내 member2가 삭제되었는지 확인 하겠습니다.

 

마지막으로 manager1로 로그인하여 사용자를 생성해보겠습니다.
해당 작업은 admin만이 생성,삭제,수정의 권한이 있기 때문에 403 오류가 발생할 것입니다.

 

투두 리스트

manager1으로 [POST] /api/todos 에 투두 리스트를 생성하겠습니다.

 

[PATCH] /api/todos/:id 에 요청을 보내 투두 리스트의 completedtrue로 바꾸겠습니다.

[GET] /api/todos로 투두 리스트의 리스트를 받아오겠습니다.

[DELETE] /api/todos/:id 로 해당 투두 리스트를 삭제해보겠습니다.

member1으로 투두 리스트를 생성하려고 하면 403 오류를 반환합니다.

이는 member는 투두 리스트에 대해 생성, 삭제의 동작이 불가능하기 때문입니다.

 

마치며

드디어 시스템 구축이 완료되었습니다!

31일의 긴 대장정 끝에 여러분이 Nest라는 프레임워크에 대해 한발 더 나아갔으리라 생각합니다!