횡단 관심사를 이용한 NestJS 인가

Jeerryy·2025년 4월 24일
0

NestJS

목록 보기
4/4
post-thumbnail

서비스를 개발하면서 권한에 따른 접근을 제한하거나 일부 데이터만 접근하도록 설정이 필요했습니다.

유저에 대한 인가 정보는 Firebase의 Custom Claims를 사용했습니다.

custom claims 내용은 아래와 같습니다.

{ userId: 1, companyId: 12, authorityId: 2, name: 'admin', type: 'Company'}

다만 요구사항에 따라 추가적인 작업이 필요했고 이는 프로젝트 전반적으로 설정이 되어야 했습니다.

이를 해결하기 위해 횡단 관심사를 이용했습니다.

횡단 관심사

레이어드 아키텍쳐(Layered Architecture)

NestJS는 Controller(Resolver) - Service - Data처럼 수직적인 구조를 가진 레이어드 아키텍쳐(Layered Architecture)를 사용합니다.
횡단 관심사(Cross-cutting Concerns)

하지만 이 구조에서 공통적으로 사용하는 기능이 존재하고 이를 횡단 관심사(Cross-cutting Concerns)라고 표현합니다. 예시로는 인증&인가, 로깅, 캐시, 트랜잭션 등이 있습니다.

횡단 관심사를 효과적으로 처리 하기 위해 NestJS에서는 Custom Decorators, Guards, Interceptors 기능을 제공하고 있습니다.

프로젝트에는 기본적인 guard는 설정되어 있었기 때문에 custom decorator와 Intercetor를 추가하는 작업을 했습니다.

요구사항

권한 레벨

  • SYSTEM_ADMIN

  • COMPANY_ADMIN

  • COMPANY_USER

  • CONSIGNOR

  • SITE

요구 사항은 다음과 같습니다.

API마다 특정 권한 혹은 그 이상의 권한만 접근이 가능해야 한다.

API에 접근이 가능하더라도 해당 유저가 접근이 가능한 정보만 획득할 수 있어야 한다.

권한 레벨과 요구사항을 대입하자면

SYSTEM_ADMIN = 무조건 통과

COMPANY_ADMIN = 본인이 속한 companyId만 데이터 허용

그 외 본인이 속한 companyId와 본인이 접근 가능한 siteId만 데이터 허용

해결 방안

1번의 경우 custom decorator와 guard를 이용하여 해결할 수 있었습니다.

api마다 접근 가능한 권한을 표기할 수 있는AllowCode 데코레이터를 생성해준 뒤 guard를 통해 요청이 들어올 때마다 claims를 비교해줍니다.

Custom Decorator

export const AllowCode = Reflector.createDecorator<EnumAuthorityCode[]>();

Guard

async canActivate(context: ExecutionContext): Promise<boolean> {
  ...

  // 해당 api에 접근 가능한 codeList
  const allowCodeList = this.reflector.get<string[]>(AllowCode, context.getHandler());
  
  // DB를 통해서 사용자의 권한 데이터 조회
  const authority = await this.authorityService.get(authorityId);
  
  // 권한 코드
  const code = authority.code;
  
   // 허용된 CodeList 가 존재하고 내 Code가 포함되어 있지 않을 때 거부
    if (allowedCodeList?.length && !allowedCodeList.includes(code)) {
      this.logger.warn(`API 를 조회할 수 있는 권한이 없습니다. 요구되는 권한은 [${allowedCodeList.join(' || ')}] 이고, 사용자의 권한은 ${code} 입니다.`);
      return false;
    }
    return true;
    ...
}

Resolver

@AllowCode([EnumAuthorityCode.COMPANY_ADMIN])
@Mutation(() => Post, {
  name: 'Post_create',
  description: '게시물 생성',
})
createPost(@CurrentUser() user: CustomClaims, @Args('input') input: CreatePostInput) {
  return this.postService.createPost(user, input);
}

2번의 경우 custom decorator와 interceptor를 이용하여 해결할 수 있었습니다.

interceptor를 통해 유저 정보를 이용하여 유저가 접근 가능한 참여고객 id 목록을 request에 추가하고

custom parameter decorator를 통해 해당 데이터를 받아와서 사용합니다.

Interceptor

Interceptor의 경우 요청과 응답 모두 적용이 가능한데요.

코드에서 보자면 next.handle()전후로 요청과 응답 영역으로 나눌 수 있습니다.

그리고 UseInterceptors 를 사용하여 복수개의 interceptor를 적용할 수 있는데 이 경우 index가 가장 낮은 순부터 적용됩니다.

예를 들어 아래 코드로 이루어진 A,B interceptor가 있고 A,B 순으로 적용했다고 가정했을 경우

// A 인터셉터
@Injectable()
export class AInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    console.log('A 인터셉터 시작');
    return next.handle().pipe(
      tap(() => console.log('A 인터셉터 종료'))
    );
  }
}

// B 인터셉터
@Injectable()
export class BInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    console.log('B 인터셉터 시작');
    return next.handle().pipe(
      tap(() => console.log('B 인터셉터 종료'))
    );
  }
}

// 컨트롤러에 적용
@Controller()
@UseInterceptors(AInterceptor, BInterceptor)
export class TestController {
  @Get()
  test() {
    console.log('라우트 핸들러 실행');
    return 'Hello';
  }
}

콘솔에 출력되는 로그는 아래와 같습니다.

A 인터셉터 시작
B 인터셉터 시작
라우트 핸들러 실행
B 인터셉터 종료
A 인터셉터 종료

Request Interceptor

요청에 의한 비지니스 로직을 실행 하기 전 필요한 데이터를 Request Interceptor를 이용해 구성했습니다.

아래는 사용자 별 접근 가능한 데이터 목록 조회 코드

@Injectable()
export class AccessibleDataIdListInterceptor implements NestInterceptor {
  constructor(private readonly userService: UserService) {}
  async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
    const ctx = GqlExecutionContext.create(context);
    const info = ctx.getInfo();
    const req = ctx.getContext().req;
    const args = ctx.getArgs();
    const user: CustomClaims = req.user;
    const code = user.code;

    ...

    const dataIdList: number[] = info.fieldName === 'data' && args?.id ? [args.id] : args.dataIdList ?? (args?.dataId ? [args.dataId] : null);

    // 접근 가능한 데이터 리스트 조회
    const accessibleDataIdList = await this.userService.getDataIdListByUser(user);

    if (!dataIdList || dataIdList.length === 0) {
      // 요청한 데이터가 없을 경우 접근 가능 데이터 모두
      req['accessibleDataIdList'] = accessibleDataIdList;
      return next.handle();
    }

    // 요청한 데이터 중 접근 가능한 데이터 필터링
    const filteredDataIdList = dataIdList.filter((dataId) => {
      return accessibleDataIdList.includes(dataId);
    });

    // 접근 가능한 데이터 외 요청 거부
    if (filteredDataIdList.length < dataIdList.length) {
      throw new ForbiddenException('해당 데이터에 대한 접근 권한이 없습니다.');
    }

    req['accessibleDataIdList'] = filteredDataIdList;
    return next.handle();
  }
}

Response Interceptor

추가적으로 데이터를 모두 받아온 상태에서 일부 데이터를 재계산 해야할 경우 Interceptor의 pipe를 이용하여 처리했습니다.

export class DrInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler<IDr | DrList>): Observable<IDr | DrList> {
    const ctx = GqlExecutionContext.create(context);
    const req = ctx.getContext().req;
    const user: CustomClaims = req.user;
    const code = user.code;

    return next.handle().pipe(
      tap((data) => {
        if (!data) {
          throw new ForbiddenException('해당 데이터에 대한 접근 권한이 없습니다.');
        }

        // 시스템 관리자는 skip
        if (EnumAuthorityCode.SYSTEM_ADMIN === code) {
          return;
        }

        // 목록 조회
        if ('totalCount' in data) {
          data.items.forEach((item) => {
            // capacity 재계산
            this.reCalculateCapacity(item, user.companyId, code);
          });
          return;
        }
        // capacity 재계산
        this.reCalculateCapacity(data, user.companyId, code);
      }),
    );
  }

Custom Decorator

// 접근 가능한 데이터 정보 가져오기
export const AccessibleDataIdList = createParamDecorator((data: unknown, context: ExecutionContext) => {
  const ctx = GqlExecutionContext.create(context);
  return ctx.getContext().req['accessibleDataIdList'];
});

Resolver

실제로 위 클래스들을 사용하는 코드입니다.

@AllowCode([EnumAuthorityCode.COMPANY_ADMIN, EnumAuthorityCode.COMPANY_USER, EnumAuthorityCode.CONSIGNOR, EnumAuthorityCode.SITE])
  @UseInterceptors(AccessibleDataIdListInterceptor, EventInterceptor)
  @Query(() => EventList, {
    name: 'eventList',
    nullable: true,
    description: '이벤트 목록 조회',
  })
  getList(@CurrentUser() user: CustomClaims, @Args() args?: EventListArgs, @AccessibleDataIdList() dataIdList?: number[]): Promise<EventList> {
    return this.eventService.getList(user, args, dataIdList);
  }

AccessibleDataIdListInterceptor를 통해 접근 가능한 dataIdList를 조회하고 @AccessibleDataIdList()를 이용하여 가져와 비즈니스 로직에 사용합니다.

Why?

이러한 구조를 짜게 된 이유는 관심사 분리로 함축할 수 있습니다.

관심사 분리를 통해서 얻을 수 있는 이 점은 아래와 같습니다.

  1. 각 비즈니스 로직은 인가에 관련된 로직을 알 필요가 없다.
  • 인가에 관련된 로직이 분리 되었기 때문에 비즈니스 로직에는 관련 코드가 필요가 없게 됩니다.
  1. 불필요한 module injection이 없어집니다.
  • 로직이 분리되었기 때문에 필요한 module만 주입하면 됩니다.
  1. 수정이 필요할 경우 확인해야할 곳이 명확해진다.
  • 애플리케이션 전반적으로 동일한 인가 프로세스를 가지기 때문에 수정할 부분이 적어져 side-effect를 줄일 수 있습니다.

물론 도메인 별로 비즈니스 로직의 수정이 필요할 수도 있고 side-effect 가 절대 없다고 볼 순 없지만 유지보수 차원에서는 해당 방법이 더 좋다고 생각했어요.

정리

eims 에서는 COMPANY_ADMINaccessibleDataIdList 를 null 값으로 받아 companyId를 사용했고 그 이하accessibleDataIdList 데이터를 받아 사용할 수 있게 처리하여 결과적으로 인가 기능을 Custom Claims와 dataIdList를 조합하여 요청사항을 처리했습니다.

참고

이미지

profile
다양한 경험을 해보고자 하는 Backend-Engineer.

0개의 댓글