[NestJS] Permission-Based Guard with Caching (advanced-part #1)

DatQueue·2023년 4월 2일
1

💥 시작하기에 앞서

이번 포스팅은 "NestJS에서 권한 부여를 어떻게 처리하는가" 에 대한 내용으로 구성해보고자한다.


"캐싱"에 관한 내용은 다음 포스팅에서 진행합니다!


일전에 NestJS에 대해 뭣도? 모르던 때에 RolesGuard에 대해 다룬적이 있었다. 당시에 공식문서를 보고 해당 설명을 위주로 따라해보며 정리하였다.
아래는 그 때 작성한 포스팅이다. ⬇⬇


Roles-Guard __Nest docs 번역 ✔

Roles-Guard __구현해보기 ✔


하지만 이 때는 단순한 RolesGuard 생성에 그쳤고, 정확히 왜? 사용하는가에 대한 의문을 깊게 가지지 못하였고 (사실 가졌지만 배경이 부족해 깊게 알 수 없었다...), 어떤 식으로 유용하게 쓰일 수 있는가에 대해 알지 못하였다.

물론 이번 포스팅에서 다룰 내용도 누군가에게 심화적이진 않을 수 있다. 초입자가 바라본 공식문서 내용보다는 조금 더 실용적인? 내용이라 생각하고 읽어주신다면 좋을 거 같다.

요즘은, NestJS에 대해 공부를 하며, 혹은 특정 기능을 구현해보며 "NestJS의 라이프 사이클과 관련된 여러 키워드들(Interceptor, Guard, Pipe ... )"에 포커스를 맞추는 시간을 가지고 있다.

이번 글도 권한 부여에 있어 "Guard(가드)"의 사용에 대해 자세히 알아볼 것이며, 동시에 조금 더 개선적인 구현법에 대해 경험해 볼 생각이다.

정말, 기본적인 내용은 생략하고 진행할 것입니다. 꼭 위 링크의 "Roles-Guard 공식문서 번역" 글 정도는 읽고 이 글을 보는 것을 추천합니다. 또한 "인증(Authentication)"과 관련된 세세한 부분은 생략할 것입니다. 오로지 권한 부여에 초점을 둔 포스팅입니다.


💥 Guard를 이용한 권한 부여하기

권한을 부여하는데 있어 단순 "User - Role"의 구조가 아닌, "User - Role -Permission"의 구조로 진행하게 된다. 특정 유저마다 권한을 바로 부여하는 방식이 아닌, 오로지 권한은 Role에 따라서 Permission(권한)을 다르게 가져갈 수 있고, UserRole이 가지고 있는 Permission에 따라 영향을 (즉, 권한부여를) 받게 된다.

"Role - Permission" 엔터티 및 관련된 여러 로직을 여기서 설명하긴 너무 글이 길어질 것이므로, 아래 시리즈 글을 꼭 참조하길 바랍니다. 아주 간단히 언급하자면 RolePermission"다대다[N:M]" 연관관계로써 중간 테이블인 RolePermission 테이블(엔터티)을 두어 이 둘을 매핑하게 됩니다.

이러한 구조와 배경을 바탕으로 진행하도록 한다.


※ 참조 : Role & Permission with ManyToMany relationship ✔ (벨로그 시리즈 포스팅)

※ 테이블 설계도 및 데이터



> 왜 가드(Guard)인가?

이전 포스팅에서 우린 "File-upload와 인터셉터(Interceptor)의 사용" 에 관해 다루며, "왜 파일을 업로드 하는데 있어 인터셉터를 사용하는가?" 를 고민해보았다.

인터셉터 역시 범용적으론 "미들웨어(Middleware)"라고 할 수 있지만, Express에서 사용하던 미들웨어와는 다르게 Nest에선 "인터셉터"라는 개념을 도입해 단순 미들웨어의 문제를 보완할 수 있었다.
코드 복잡도 증가, 가독성 저하 등과 같은 다양한 문제가 있을 수 있겠지만 가장 큰 문제는 미들웨어의 경우 "Execution Context(실행 컨텍스트)"에 접근할 수 없다는 것이다.

해당 문제는 "가드(Guard)"의 존재 이유에도 동일하게 적용된다.

NestJS 공식문서 중 Guard에 해당하는 글의 첫 시작에 모든 것을 설명하고 있다. 잠시 살펴보자.


"Guards have a single responsibility. They determine whether a given request will be handled by the route handler or not, depending on certain conditions (like permissions, roles, ACLs, etc.) present at run-time. This is often referred to as authorization. Authorization (and its cousin, authentication, with which it usually collaborates) has typically been handled by middleware in traditional Express applications. Middleware is a fine choice for authentication, since things like token validation and attaching properties to the request object are not strongly connected with a particular route context (and its metadata).

But middleware, by its nature, is dumb. It doesn't know which handler will be executed after calling the next() function. On the other hand, Guards have access to the ExecutionContext instance, and thus know exactly what's going to be executed next. They're designed, much like exception filters, pipes, and interceptors, to let you interpose processing logic at exactly the right point in the request/response cycle, and to do so declaratively. This helps keep your code DRY and declarative."

글의 내용 중 핵심을 정리해서 해석해보자. 기존의 Express에선 미들웨어를 사용하여 인증과 같은 작업을 처리하였다. 미들웨어는 토큰 유효성 검사 및 요청 객체에 속성 추가를 하는 것과 같은 "인증(Authentication)"의 처리에 있어선 좋은 선택이다. 특정 라우트 핸들러에 강하게 연결되어 있지 않기 때문이다. 즉, 어느 라우트에서든 인증에 대한 처리를 유연하게 할 수 있다.

하지만, 이는 역시 말 그대로 "어느 라우트"에서든, 즉 범용적인 인증을 처리하는데 있어선 좋은 선택이지만 특정 라우트에 관해 세세한 인증처리를 하는데 있어선 단점으로 작용한다. 위에서도 언급하였듯이 미들웨어는 ExecutionContext에 접근할 수 없으므로, next()함수를 호출한 후에 어떤 핸들러가 실행될지 모르기 때문이다.

우리는 특정 조건에 특정한 권한을 부여하는 작업을 흔히 "인가(Authorization)"이라 한다. 인가를 처리하는데 있어서 "특정 라우트 핸들러"에 접근하는것이 굉장히 중요하다. 다음에 실행될 내용을 정확하게 파악하고, 이를 기반으로 권한 부여와 같은 작업을 수행해야하기 때문이다.

결국, nestjs에선 이를 위해 "가드(Guard)"라는 좋은 기능을 제공해줌으로써 편리한 권한 부여를 제시한다.


💨 PermissionGuard 를 생성해보자

먼저 완성된 코드를 바로 확인해보자.


✔ Custom Decorator with Metadata

PermissionGuard를 생성하기 전, 해당 가드를 바로 주입시키기보다 nest에서 제안하는대로 조금 더 유연하게 주입시키기 위해 아래와 같은 커스텀 데코레이터(Custom Decorator)를 생성할 수 있다.

// permission.decorator.ts

import { SetMetadata } from "@nestjs/common";

export const HasPermission = (access: string) => SetMetadata('access', access);

해당 데코레이터는 SetMetadata로써 생성된다. SetMetadata를 통해 생성된 데코레이터는 아래에서 보게 될 가드 내의 Reflector를 통해 문자열 access를 공유하게 된다.

SetMetadata 함수를 사용하여 데코레이터에서 설정한 메타데이터는 가드의 Reflector 서비스를 통해 검색되는 것이다. 이렇게 함으로써 데코레이터에서 설정한 권한 엑세스를 가드 차원에서 검색하고 처리할 수 있게 되는것이다. 아래 PermissionGuard에서 다음과 같은 코드가 이를 나타낸다.

	const access = this.reflector.get<string>('access', context.getHandler());
    if (!access) {
      return true;
    }

여기서 한가지 짚고 넘어가면 좋을 포인트는 만약 실행 컨텍스트로부터 불러온 요청 핸들러에서 존재하는 access가 없을 때 true를 리턴한다는 것이다.

해당 true가 의미하는 것은 권한을 허용하고, 허용하지 않고를 판별하는 것이 아닌, 애초에 컨트롤러 또는 해당 컨트롤러의 요청 핸들러에서 HasPermission 데코레이터가 설정되어있냐 않느냐에 따라 만약에 해당 데코레이터가 설정되어있지 않다면 요청 처리를 그대로 진행 (즉, 허용)한다는 것을 의미한다.


✔ PermissionGuard with ExecutionContext

// permission.guard.ts

import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { AuthService } from "../../auth/auth.service";
import { Role } from "../../role/model/role.entity";
import { RoleService } from "../../role/role.service";
import { User } from "../../user/model/user.entity";
import { UserService } from "../../user/user.service";
import { Permission } from "../model/permission.entity";

@Injectable()
export class PermissionGuard implements CanActivate {
  constructor(
    private reflector: Reflector,
    private authService: AuthService,
    private userService: UserService,
    private roleService: RoleService  
  ) {}
  async canActivate(context: ExecutionContext): Promise<boolean> {
    
    // Reflector를 이용해 요청 핸들러에서 설정한 `access` 메타데이터 호출
    const access = this.reflector.get<string>('access', context.getHandler());
    if (!access) {
      return true;
    }
    
    // 실행 컨텍스트의 요청객체에 접근한다.
    const request = context.switchToHttp().getRequest();
    
    // authService를 통해 구현한 jwt 인증에 따라 user의 id값을 받아온다. 
    const id = await this.authService.userId(request);
    
    // findUserById 메서드를 통해 특정 id에 해당하는 유저를 찾는다.
    // 두 번째 파라미터인 relations를 통해 role테이블과 조인함을 명시한다.
    const user: User = await this.userService.findUserById(id, ['role']);
	
    // 조회된 특정 user에 따른 role 객체를 호출한다. 이때, 조인관계로써 rolePermissions 뿐아니라
    // rolePermissions.permission 역시 필수적으로 받아와야한다. 그렇게 함으로써 원하는 프로퍼티가 담겨진 permission 객체를 얻을 수 있다.
    const role: Role = await this.roleService.findOne(user.role.id, ['rolePermissions', 'rolePermissions.permission']);
    // 중간 매핑 테이블인 RolePermission 객체를 명시
    const rolePermissions = role.rolePermissions;
    // RolePermission 객체로부터 최종 Permission객체를 받아온다.
    const permissions: Permission[] = rolePermissions.map(rp => rp.permission);

    let isAccess: boolean;

    if (request.method === 'GET') {
      isAccess = permissions.some(
        p => (p.name === `view_${access}`)
        || (p.name === `edit_${access}`)
      );
      return isAccess;
    }

    isAccess = permissions.some(p => p.name === `edit_${access}`);
    return isAccess;
  }
}

최종 가드가 내보내게될 boolean값을 판별하는 로직을 제외한 나머지 코드들에 대한 설명은 모두 주석 으로 표시하였다. 결국 Permission 객체를 어떻게 불러올까에 대한 과정이고 이 과정 중 나름 중요한 부분은 jwt 토큰 검증에 따른 유저의 id값을 받아온다는 것이다. 해당 구현부에 대해 세세히 설명하긴 주제를 벗어나고 아래에서 간단히 보도록 하자.

// auth.service.ts

import { Injectable } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { Request } from "express";

@Injectable()
export class AuthService {

  constructor(private jwtService: JwtService) {}

  async userId(request: Request): Promise<number> {
    const cookie = request.cookies['jwt'];

    const data = await this.jwtService.verifyAsync(cookie);

    return data['id'];
  }
}

Http request에서 'jwt'란 이름을 가진 JWT 쿠키를 추출한다. 그 후, @nestjs/jwt에서 제공하는 JWTServiceverifyAsyncv() 메서드를 이용하여 해당 쿠키를 검증하고 데이터를 추출한다. 그 데이터는 유저 데이터가 될 것이고 해당 유저 데이터의 id값을 최종 리턴하는 것이다.


자, 이제 다시 돌아와 권한을 허용할 것인지 말 것인지에 대한 처리 로직을 알아볼 차례이다.

    let isAccess: boolean;

    if (request.method === 'GET') {
      isAccess = permissions.some(
        p => (p.name === `view_${access}`)
        || (p.name === `edit_${access}`)
      );
      return isAccess;
    }

    isAccess = permissions.some(p => p.name === `edit_${access}`);
    return isAccess;

가드가 리턴 할 불리언 값을 isAccess 변수로써 두고, 요청 메서드가 어떤 액션이냐에 따라 다르게 구성하도록 하였다.

우리의 경우, 특정 권한 카테고리 (user, role, order, goods(products))에 따라 view(조회), edit(수정) 의 작업을 구분지어 두었다. 하지만, 통상적으로 읽기에 해당하는 GET 요청의 경우엔 viewedit 둘 중 하나만 권한이 부여되었다 해도 가능하도록 하는 것이 일반적이다. 즉, 이에 따라 GET 요청의 경우 viewedit 권한 중 하나만 있어도 조회 가능하도록 하게끔 설정하였고, 나머지 요청 메서드의 경우는 두 권한이 모두 존재할때만 접근 가능하도록 설정하였다.


✔ (+) 추가 개선 - 권한 이름 정의 변경 (Hard-Cording to Enuum)

현재는 PermissionGuard에서 권한 이름을 하드 코딩으로 사용하고 있다. 하드 코딩은 변경이 어렵고 직접 접근하는 것은 좋지 못하다. 이에 따라 Enum을 사용하여 가독성과 유지 보수성을 높이자. (간단한 케이스지만 습관을 들이는 것은 좋다.)

// permission.enum.ts
// 리터럴 값은 db의 permission 테이블 레코드 값과 일치시키도록 한다.

export enum PermissionName {
  VIEW_USERS = 'view_users',
  EDIT_USERS = 'edit_users',
  VIEW_ROLES = 'view_roles',
  EDIT_ROLES = 'edit_roles',
  VIEW_PRODUCTS = 'view_products',
  EDIT_PRODUCTS = 'edit_products',
  VIEW_ORDERS = 'view_orders',
  EDIT_ORDERS = 'edit_orders'
}
// 수정 (Enum 적용)

    if (request.method === 'GET') {
      isAccess = permissions.some(
        p => (p.name === PermissionName[`VIEW_${access.toUpperCase()}`])
        || (p.name === PermissionName[`EDIT_${access.toUpperCase()}`])
      );
      return isAccess;
    }

    isAccess = permissions.some(p => p.name === PermissionName[`EDIT_${access.toUpperCase()}`]);

> 라우트 핸들러에 적용하기

Controller에서 PermissionGuard 주입하기

// user.controller.ts

import { BadRequestException, Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, Req, UseFilters, UseGuards, UseInterceptors } from '@nestjs/common';
import { Request } from 'express';
import { User } from './model/user.entity';
import { UserService } from './user.service';
import * as bcrypt from 'bcrypt'
import { UserCreateDto } from './model/user-create.dto';
import { AuthGuard } from '../common/guards/auth.guard';
import { ResponseSerializeInterceptor } from '../common/interceptors/serialize.interceptor';
import { UserUpdateDto } from './model/user-update.dto';
import { PageOptionsDto } from './pagination_model/page-options.dto';
import { PageDto } from './pagination_model/page.dto';
import { AuthService } from '../auth/auth.service';
import { HasPermission } from '../permission/decorator/has-permission.decorator';

@UseInterceptors(ResponseSerializeInterceptor)
@UseGuards(AuthGuard)
@Controller('users')
export class UserController {

  constructor(
    private userService: UserService,
    private authService: AuthService 
  ){}

  @Get()
  @HasPermission('users')
  async all(@Query() pageOptionsDto: PageOptionsDto): Promise<PageDto<User>>{
    return await this.userService.paginate(pageOptionsDto, ['role']);
  }

  @Post()
  @HasPermission('users')
  async create(@Body() body: UserCreateDto): Promise<User> {
    const password = await bcrypt.hash('1234', 12);

    const { role_id, ...data } = body;

    return await this.userService.userCreate({
      ...data,
      password,
      role: { id: role_id }
    })
  }

  
  @Get(':id')
  @HasPermission('users')
  async get(@Param('id') id: number) {
    const user = await this.userService.findUserById(id, ['role']);
    console.log(user);
    return user;
  }

  @Put('info')
  @HasPermission('users')
  async updateInfo(
    @Req() req: Request,
    @Body() body: UserUpdateDto
  ) {
    const id = await this.authService.userId(req)
    return await this.userService.updateUserInfo(id, body)
  }

  @Put('password')
  async updatePassword(
    @Req() req: Request,
    @Body('password') password: string,
    @Body('password_confirm') password_confirm: string,
  ) {
    if (password !== password_confirm) {
      throw new BadRequestException('Passwords do not match');
    }

    const id = await this.authService.userId(req);
    const hashed = await bcrypt.hash(password, 12);

    return await this.userService.updateUserInfo(id, {
      password: hashed
    })
  }

  @Put(':id')
  @HasPermission('users')
  async update(@Param('id') id: number, @Body() body: UserUpdateDto) {

    return await this.userService.updateUserInfo(id, body);
  }

  @Delete(':id')
  @HasPermission('users')
  async delete(@Param('id') id: number) {
    return await this.userService.deleteUser(id);
  }
}

우리는 가드 자체를 직접 주입할 필요는 없다. 이미 커스텀 데코레이터인 HasPermission 함수에서 메타데이터인 access를 전달 받았기 때문이다. 즉, @HasPermission(access)의 형태로써 컨트롤러 단위 혹은 개별 라우트 핸들러 단위에서 적용시켜 준다.

컨트롤러 자체에서 적용시켜줄 수도 있겠지만, 일반적으로 가드의 경우엔 각 요청 라우트 메서드 마다 권한이 다르게 부여될 수 있기 때문에 개별적으로 적용하는 것이 옳지 않을까 싶다.
roleController에서 permission변경하는 라우트 핸들러 함수는 허용된 유저에 한해서는 항상 요청이 허용되어야하므로 가드가 적용되서는 안된다. 이처럼 개별 책임이 때론 중요할 수도 있다.

orderController, productsController, roleController도 위의 userController와 마찬가지로 @HasPermission(order, products, roles)와 같은 형태로 적용시켜주면 된다.


💨 예외 필터를 통한 에러 핸들링 (+추가)

직접적으로 내용과 관련있는 부분은 아니지만 부가적으로 이번 글에 추가해주면 좋을거 같아서 작성해본다.

권한 부여, 즉 "인가(Authorization)""인증(Authentication)"을 바탕으로 진행된다. 우리의 과정으로 연관지어 본다면 유저 인증을 위한 jwt 토큰 생성이 이루어지지 않았을 시엔 애초에 유저 검증을 하지 못하므로 "인가"가 이루어지지 않는다.

즉, 인증을 위한 예외 처리 및 인가에 해당하는 예외 처리를 "커스텀 필터(Custom Filter)"를 통해 구현해보자. 사실, 아래와 같이 인가에 해당하는 예외 처리를 해당 필터에서 구현해주는 것이 바람직하진 않지만, 이런 식으로 처리해준다는 것만 알면 좋을 거 같다.

// exeption.filter.ts

import { ArgumentsHost, Catch, ExceptionFilter, ForbiddenException } from "@nestjs/common";
import { JsonWebTokenError } from "jsonwebtoken";

@Catch(Error) 
export class AuthExceptionHandler implements ExceptionFilter {
  catch(exception: any, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    const request = ctx.getRequest();

    const ex = handlingException(exception);

    response.status(ex.code).json({
      statusCode: ex.code,
      message: ex.message,
      timestamp: new Date().toISOString(),
      path: request.url,
    });
  }
}

interface ExceptionStatus {
  code: number;
  message: string;
}

const handlingException = (err: Error): ExceptionStatus => {
  if (err.name === 'TokenExpiredError') {
    return { code: 419, message: '토큰이 만료되었습니다.: ('}
  } else if (err instanceof JsonWebTokenError && err.message === 'jwt must be provided') {
    return { code: 401, message: '유효하지 않은 토큰입니다 (인증 토큰 필요).: ('}
  } else if (err instanceof ForbiddenException) {
    return { code: 403, message: 'You are not allowed to perform this action.: (' };
  } else {
    return { code: 500, message: '알 수 없는 오류가 발생했습니다.: ('};
  }
}

Controller에 주입하기

@UseInterceptors(ResponseSerializeInterceptor)
@UseGuards(AuthGuard)
@UseFilters(AuthExceptionHandler)   // 컨트롤러 단위에 주입
@Controller('users')
export class UserController {

  constructor(
    private userService: UserService,
    private authService: AuthService 
  ){}

  @Get()
  @HasPermission('users')
  async all(@Query() pageOptionsDto: PageOptionsDto): Promise<PageDto<User>>{
    return await this.userService.paginate(pageOptionsDto, ['role']);
  }
  
  // ...
  
}

> Postman을 통한 테스트

✔ 인증에 실패하였을 시 -- 로그인에 성공하지 못한 경우

/orders 뿐 아니라 /goods, /roles ... 모든 http 요청에 접근할 수 없다.


✔ 인증에 성공하였을 시 -- 로그인에 성공하였을 경우

  • ex) 유저 조회 및 수정에 대한 권한 부여

    위과 같이 permissionview_users, edit_users로 부여한 뒤 해당 권한과 관련된 경로로 요청을 날려본다.


    위와 같이 유저 데이터의 조회와 수정에 접근할 수 있는 것을 확인할 수 있다. 만약, edit_users 권한 부여만 하였더라도 GET 요청의 조회를 수행할 수 있다. 하지만, view_users 권한만 부여되었을 경우엔 PUT 요청에 대한 액션을 수행할 수 없다.
    그 외의 다른 모든 경로에 대해선 아래와 같이 "액션을 수행할 수 없다"는 에러를 띄운다.

rolepermission = rolePermission을 어떻게 가져가느냐에 따라 유동적으로 권한 부여를 처리할 수 있는 것을 확인할 수 있다.





다음 포스팅 예고 ...

이렇게 "Permission-Based"의 권한 부여 인가작업을 수행할 수 있었다. 물론 권한 부여에 해당하는 작업은 여기서 마무리 짓지만 다음 포스팅에 이번 과정을 베이스로 하여 "캐싱 작업"을 추가할 예정이다.

최근 궁금해서 공부하게 된 "캐싱"을 실용적 예시에 적용해보고 싶었다. 그럼 다음 포스팅에 해당 내용을 이어서 보도록 하자.

profile
You better cool it off before you burn it out / 티스토리(Kotlin, Android): https://nemoo-dev.tistory.com

1개의 댓글

comment-user-thumbnail
2023년 6월 7일

Reflector를 사용해서 자바의 리플렉션처럼 실행 컨텍스트에 접근할 수 있군요~ nestjs를 공부하면서 궁금했던 여러 가지 개념을 잘 설명해주셔서 감사합니다! 고민이 잘 녹아있는 좋은 글이네요 👍

Http request에서 'jwt'란 이름을 가진 JWT 쿠키를 추출한다. 그 후, @nestjs/jwt에서 제공하는 JWTService의 verifyAsyncv() 메서드를 이용하여 해당 쿠키를 검증하고 데이터를 추출한다.

사소하지만,, verifyAsync() 오타 난 것 같습니다!

답글 달기