[NestJS] Implementing Scopes for Multiple Routes ( feat. JWT Auth )

DatQueue·2023년 6월 11일
1
post-thumbnail

시작하기에 앞서

어쩌면 되게 단순하지만 짚고 넘어가면 유용한 사용이 될 거 같기에 글을 남겨본다. 여태껏 nestjs에서 라우트 핸들러 함수를 작성할때 하나의 라우트 핸들러 함수안에 "하나의 Endpoint"만 두었었다.

다들 알다시피, 아래와 같은 형식이다.

import { Controller, Get } from '@nestjs/common';

@Controller('api')
export class AnimalController {
  @Get('cats')
  findAll(): string {
    return 'Hello Animal!!';
  }
}

우리는 위와 같은 GET요청을 수행하기위해 /api/cats라는 endpoint에 접근을 하면된다.

여기서 잠깐 생각해보자. 위와 같이 "Hello Animal!!"이란 응답을 받길 원하는 또다른 GET 요청의 라우트 핸들러 함수가 있다고 하자. 그리고 해당 라우트 함수들의 endpoint로는 "dogs, pigs, elephants ..." 로 다양할 수 있다.

그럼 단순하게 생각하면 아래와 같이 작성할 것이다.

@Controller('api')
export class AnimalController {
  @Get('cats')
  findAll(): string {
    return 'Hello Animal!!';
  }
  
  @Get('dogs')
  findAll(): string {
    return 'Hello Animal!!';
  }
  
  @Get('pigs')
  findAll(): string {
    return 'Hello Animal!!';
  }
  
  @Get('elephants')
  findAll(): string {
    return 'Hello Animal!!';
  }
  
  // ... ...
}

얼핏봐도 굉장히 비효율적이다. 가장 눈에 띄는 것은 "코드의 중복성"일 것이고, 더 나아가선 공통된 로직을 사용하는데 있어 "일관성""확장성"을 의심해볼 것이다. 물론 동일한 라우트 핸들러 함수를 사용하는 endpoints가 2개 이상으로 존재하는 상황이 많을진? 모르겠지만 어쨌든 위와 같이 작성하는건 비효율적이라 판단된다.

우리는 이런 상황에서 "Mutiple Routes"를 통해 여러개의 endpoints가 동일한 핸들러 함수를 공유할 수 있게끔 만들 수 있다.

@Controller('api')
export class AnimalController {
  @Get(['cats', 'dogs', 'pigs', 'elephants' ...])
  findAll(): string {
    return 'Hello Animal!!';
  }
}

nestjs에서 GET, POST, PUT, DELETE, ... 등의 MethodDecorator는 매개변수로 string | string[] 타입을 받게끔 한다. 즉, 우린 위와 같이 배열로 endpoints를 지정해줌으로써 효율적인 "Mutiple Routes"를 구현할 수 있게 된다.


💥 적용해보기 (Admin | Ambassador)

소셜 미디어 서비스 플랫폼(SNS) 혹은 이커머스 플랫폼(E-Commerce)을 예로 들어보자.

이커머스 플랫폼에서는 관리자(admin)가 제품/상품 관리, 주문 처리, 고객 관리 등의 작업을 수행하고, 대리인은 특정 브랜드의 제품을 홍보하고 판매하는 역할을 담당한다. 대리인은 제품에 대한 상세 정보를 제공하고, 고객과의 소통 및 마케팅 활동을 수행한다. 이에 따라 관리자와 대리인은 정해진 분할하에(percentage) 수익을 배분하여 가질 것이다.

대충 스토리는 위와 같고, 그렇다면 관리자와 대리인의 "인증(Authentication)" 과정을 별도로 두어야할까?

물론, 별도로 두어도 상관은 없겠지만 앞서 위에서도 언급하였듯이 중복된 라우트 핸들러 함수를 공유해야할 경우, "Mutilple Routes"를 통해 동일한 로직이 반복되는 것을 막을 수 있다.


> AuthController with Mutilple Routes

그렇담 기존에 관리자를 위한 로그인 및 인증 관련 컨트롤러 로직을 확인해보자. 이는 앞서 이전 포스팅들에서 진행한 "유저 인증 과정"과 동일하고, "2FA(Two-Factor-Authentication)"와 "OAuth"를 제외한 부분이다.

인증 관련 구현 로직에 관한 설명은 포스팅 진행 내용상 생략하겠다.

또한 관리자에 해당하는 endpoint는 "admin"으로 지정하고, 대리인(혹은 판매인)에 해당하는 endpoint는 "ambassador"로 지정한다.


※ 이전 포스팅

nestjs - 유저 인증 과정 (심화) ✔

위의 포스팅들중 1, 2 포스팅의 코드를 참조해주시면 되겠습니다. Refresh Token 구현 코드까지 동일합니다.


그럼, 본격 구현 설명에 앞서 전체 컨트롤러 인증로직을 확인해보자.

✔ AuthController

import { BadRequestException, Body, Controller, Post, Get, Res, UnauthorizedException, Req, UseGuards, UseInterceptors, ClassSerializerInterceptor, Put } from '@nestjs/common';
import { Request, Response } from 'express';
import { LoginDto } from '../user/model/user-login.dto';
import { UserRegisterDto } from '../user/model/user-register.dto';
import { UserService } from '../user/user.service';
import { AuthService } from './auth.service';
import { RefreshTokenDto } from './utils/refreshToken.dto';
import { User } from '../user/model/user.entity';
import { JwtAccessAuthGuard } from './utils/guard/jwt-access.guard';
import { JwtRefreshGuard } from './utils/guard/jwt-refresh.guard';
import { UserUpdateDto } from '../user/model/user-update.dto';
import { JwtService } from '@nestjs/jwt';

@Controller()
export class AuthController {
  constructor(
    private readonly userService: UserService,
    private readonly authService: AuthService,
    private readonly jwtService: JwtService,
  ) {}

  @Post(['admin/register', 'ambassador/register'])
  async register(
    @Body() userRegisterDto: UserRegisterDto,
    @Req() request: Request,
  ) {
    if (userRegisterDto.password !== userRegisterDto.password_confirm) {
      throw new BadRequestException('Passwords do not match!');
    }
    const newUser = await this.userService.createUser(userRegisterDto, request);
    return newUser;
  }

  @Post(['admin/login', 'ambassador/login'])
  async login(
    @Body() loginDto: LoginDto,
    @Res({ passthrough: true }) res: Response,
    @Req() req: Request,
  ) {
    const user = await this.authService.validateUser(loginDto, req);
    const access_token = await this.authService.generateAccessToken(user, req);
    const refresh_token = await this.authService.generateRefreshToken(user);
    
    await this.userService.setCurrentRefreshToken(refresh_token,user.id);
    res.setHeader('Authorization', 'Bearer ' + [access_token, refresh_token]);
    res.cookie('access_token_1', access_token, {
      httpOnly: true,
    });
    res.cookie('refresh_token_1', refresh_token, {
      httpOnly: true,
    });
    
    return {
      message: 'login success',
      access_token: access_token,
      refresh_token: refresh_token,
    };
  }

  @Post(['admin/refresh', 'ambassador/user'])
  async refresh(
    @Body() refreshTokenDto: RefreshTokenDto,
    @Res({ passthrough: true }) res: Response,
    @Req() req: Request,
  ) {
    try {
      const newAccessToken = (await this.authService.refresh(refreshTokenDto, req)).accessToken;
      res.setHeader('Authorization', 'Bearer ' + newAccessToken);
      res.cookie('access_token_1', newAccessToken, {
        httpOnly: true,
      });
      res.send({newAccessToken});
    } catch(err) {
      throw new UnauthorizedException('Invalid refresh-token');
    }
  }

  @UseInterceptors(ClassSerializerInterceptor)
  @Get(['admin/authenticate', 'ambassador/authenticate'])
  @UseGuards(JwtAccessAuthGuard)
  async user(@Req() req: Request): Promise<User> {
    const cookie = req.cookies['access_token_1'];
    const { id } = await this.jwtService.verifyAsync(cookie); 
    const verifiedUser: User = await this.userService.findUserById(id);
    return verifiedUser;
  }

  @Post(['admin/logout', 'ambassador/logout'])
  @UseGuards(JwtRefreshGuard)
  async logout(@Req() req: any, @Res({ passthrough: true }) res: Response): Promise<any> {
    await this.userService.removeRefreshToken(req.user.id);
    res.clearCookie('access_token_1');
    res.clearCookie('refresh_token_1');
    return {
      message: 'logout success'
    };
  }

  @UseGuards(JwtAccessAuthGuard)
  @Put(['admin/users/info', 'ambassador/users/info'])
  async updateInfo(
    @Req() request: Request,
    @Body() userUpdateDto: UserUpdateDto,
  ) {
    const cookie = request.cookies['access_token_1'];
    const { id } = await this.jwtService.verifyAsync(cookie);

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

  @UseGuards(JwtAccessAuthGuard)
  @Put(['admin/users/password', 'ambassador/users/password'])
  async updatePassword(
    @Req() request: Request,
    @Body('password') password: string,
    @Body('password_confirm') password_confirm: string,
  ) {

    if (password !== password_confirm) {
      throw new BadRequestException('Passwords do not match');
    }

    const cookie = request.cookies['access_token_1'];
    const { id } = await this.jwtService.verifyAsync(cookie);

    return await this.userService.updateUserPassword(id, password);
  }
}

> 1) Register users with Mutiple Routes

먼저 회원가입이다. 우리는 유저를 등록하는데 있어 아래와 같이 엔터티에 ambassador 인지 아닌지를 판별하는 필드를 설정하였다.

// user.entity.ts

@Entity('users')
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  // ... 

  @Column({ default: true })
  is_ambassador: boolean;
}

컨트롤러의 라우트 핸들러 함수 register()는 아래와 같이 작성한다.

// auth.controller.ts

  @Post(['admin/register', 'ambassador/register'])
  async register(
    @Body() userRegisterDto: UserRegisterDto,
    @Req() request: Request,
  ) {
    if (userRegisterDto.password !== userRegisterDto.password_confirm) {
      throw new BadRequestException('Passwords do not match!');
    }
    const newUser = await this.userService.createUser(userRegisterDto, request);
    return newUser;
  }

@Post의 매개변수로 배열을 받고, 해당 배열안에 'admin/register', 'ambassador/register'를 요소로 가짐으로써 두 경로(path)에 대한 multiple routes를 구현한다.

그럼 유저 회원가입에 대한 비즈니스로직인 서비스로직(createUser())을 알아보자.

// user.service.ts

  async createUser(newUser: UserRegisterDto, request): Promise<User> {
    const userFind: User = await this.findOne({
      where: {
        email: newUser.email,
      }
    });
    if (userFind) {
      throw new HttpException('UserEmail already used!', HttpStatus.BAD_REQUEST);
    }

    const saltOrRounds = 12;
    const hashedPassword = await this.hashPassword(newUser.password, saltOrRounds);
    const newHashedUser = await this.save({
      ...newUser,
      password: hashedPassword,
      password_confirm: hashedPassword,
      // ambassador 가입 경로에 대해 is_ambassador를 true로 설정한다.
      is_ambassador: request.path === '/api/ambassador/register',
    });
    return newHashedUser;
  }

(다른 부분에 대한 설명은 생략)
save() 메서드를 통해 유저 등록을 할 시 admin 경로로 가입 요청을 한 유저에 대해선 is_ambassador === false로 해주어야 할 것이고, ambassador 경로로 요청한 유저에 대해선 is_ambassador === true로 해주어야 할 것이다.

우리는 createUser()의 매개변수로 컨트롤러의 register() 함수로 부터 request: Request를 받아올 수 있고, 이를 통해 path에 접근할 수 있다.

즉, '/api/ambassador/register' path로 가입요청을 한 유저에겐(ambassador) is_ambassador === true를 부여한다.


✔ Test (Postman)

-- admin

-- ambassador


> 2) Login with Mutiple Routes

다음으로 로그인 단계이다. 로그인 단계에서 중요한 것은 admin은 모든 로그인에 접근이 허용되어야 하지만, ambassadoradmin 경로의 로그인엔 접근할 수 없어야 한다. 즉, 이에 대한 예외처리가 필요할 것이다.

✔ login - AuthController

// auth.controller.ts

  @Post(['admin/login', 'ambassador/login'])
  async login(
    @Body() loginDto: LoginDto,
    @Res({ passthrough: true }) res: Response,
    @Req() req: Request,
  ) {
    const user = await this.authService.validateUser(loginDto, req);
    const access_token = await this.authService.generateAccessToken(user, req);
    const refresh_token = await this.authService.generateRefreshToken(user);
    
    await this.userService.setCurrentRefreshToken(refresh_token,user.id);
    res.setHeader('Authorization', 'Bearer ' + [access_token, refresh_token]);
    res.cookie('access_token_1', access_token, {
      httpOnly: true,
    });
    res.cookie('refresh_token_1', refresh_token, {
      httpOnly: true,
    });
    
    return {
      message: 'login success',
      access_token: access_token,
      refresh_token: refresh_token,
    };
  }

위 로직에 대한 설명은 생략하겠다. user 객체를 불러오기 위한 검증 로직인 AuthServicevalidateUser() 함수에서 예외처리를 구현해주면 된다.

// auth.service.ts

  async validateUser(loginDto: LoginDto, request: Request): Promise<User> {
    // request.path를 통해 `admin` 로그인 경로 불러옴
    const adminLogin = request.path === '/api/admin/login';
    const user = await this.userService.findUserByEmail(loginDto.email);

    if (!user) {
      throw new NotFoundException('User not found!');
    }

    if (!await bcrypt.compare(loginDto.password, user.password)) {
      throw new BadRequestException('Invalid credentials!');
    }
	
	// is_ambassador === true이면서 동시에 admin 로그인 경로로 접근한 유저에 관해 접근 권한 예외처리
    if (user.is_ambassador && adminLogin) {
      throw new UnauthorizedException();
    }

    return user;
  } 

✔ Test (Postman)


> 3) Guard with Scopes (with JWT)

회원가입과 로그인을 수행한 유저를 바탕으로 admin일 경우와 ambassador일 경우에 대한 서로 다른 인증 처리를 해주어야 할 것이다. 우린 기존에 작성하였던 JwtAccessGuard를 수정해 줄 필요가 있다.



✔ 기존 JwtAccessGuard

// jwt-access.guard.ts

import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";

@Injectable()
export class JwtAccessAuthGuard implements CanActivate {
  constructor(
    private jwtService: JwtService,
  ) {}
  async canActivate(
    context: ExecutionContext,
  ): Promise<any> {
    const request = context.switchToHttp().getRequest();
    try {
      const access_token = request.cookies['access_token_1'];
      const user = await this.jwtService.verify(access_token);
      request.user = user;
      return user;
    } catch(err) {
      return false;
    }
  }
}

여기서 중요한 점은 현재 "JWT"로 생성한 "access_token(액세스 토큰)"을 통해 인증을 구현하고 있고, 이를 토대로 "Authentication Guard"를 생성하였다는 것이다.

즉, 다시 말해 우리가 adminambassador에 따른 서로 다른 인증을 처리하는 "하나의 가드" 구현하고자 한다면, 우리의 액세스 토큰 역시 이에 대한 정보를 알고 있어야 한다는 것이다.

그것을 우리는 "Scope(범위)"라는 데이터를 통해 해결할 수 있다.
(scope는 토큰 정보에 포함되어야 할 것이다)


✔ Payload

액세스 토큰 정보에 해당하는 Payload interface에 scope 속성을 추가한다.

// payload.interface.ts
export interface Payload {
  id: number;
  email: string;
  first_name: string;
  last_name: string;
  scope: string;  // scope 추가
  iat?: string;
  exp?: string;
}

✔ 액세스 토큰 생성 로직 - AuthService

액세스 토큰 생성 로직에서 scope에 대한 처리를 해준다.

// auth.service.ts 

  async generateAccessToken(user: User, request: Request): Promise<string> {
    // adminLogin에 해당하는 path는 많이 사용됨으로, enum 객체로 빼도 될 것이다.
    const adminLogin = request.path === '/api/admin/login';

    const payload: Payload = {
      id: user.id,
      email: user.email,
      first_name: user.first_name,
      last_name: user.last_name,
      scope: adminLogin ? 'admin' : 'ambassador',
    }
    return this.jwtService.signAsync(payload);
  }

만약 요청 경로(request.path)가 /api/admin/login일 경우 액세스 토큰은 admin이란 데이터를 scope에 포함시키고, 그렇지 않을 경우 ambassador란 데이터를 scope에 포함시키도록 한다.


✔ 수정 - JwtAccessGuard

// jwt-access.guard.ts

import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";

@Injectable()
export class JwtAccessAuthGuard implements CanActivate {
  constructor(
    private jwtService: JwtService,
  ) {}
  async canActivate(
    context: ExecutionContext,
  ): Promise<any> {
    const request = context.switchToHttp().getRequest();
    try {
      const access_token = request.cookies['access_token_1'];
      const { scope } = await this.jwtService.verify(access_token);

      const is_ambassador = request.path.toString().indexOf('api/ambassador') >= 0;

      return is_ambassador && scope === 'ambassador' || !is_ambassador && scope === 'admin';
    } catch(err) {
      return false;
    }
  }
}

아래와 같이 진행된다.

1) 생성한 액세스 토큰(로그인시 생성한 cookie를 통해 받아온다)에서 scope를 추출한다.

const { scope } = await this.jwtService.verify(access_token);

2) 경로(path)를 통한 is_ambassador 값 설정 (indexOf 사용)

const is_ambassador = request.path.toString().indexOf('api/ambassador') >= 0;

해당 부분은 현재 요청 경로를 toString()을 통해 문자열로 변환한 후 , 'api/ambassador'가 포함되어있는지 확인하게 된다. indexOf() 메서드는 문자열 내에서 특정 문자열의 위치를 찾고, 찾은 위치의 인덱스를 반환한다.


String.prototype.indexOf() - mdn web docs


만약, indexOf()를 통해 반환된 값이 0이상일 경우 'api/ambassador' 문자열을 포함하는 것으로 간주하기 때문에 이때, is_ambassador의 값은 true가 될 것이다.

3) 접근 허용 여부 결정

return is_ambassador && scope === 'ambassador' || !is_ambassador && scope === 'admin';

해당 반환부를 잘 생각해보아야한다. ambassador에 관해서도 접근을 허락하고, 동시에 admin인 경우에도 접근을 허락해야한다. 이때 우린 토큰을 통해 받아온 scope를 활용하여 조정해줄 수 있다.

작성한 코드를 설명하자면 is_ambassdortrue이고, scopeambassador인 경우에 접근을 허용한다. 또는 is_ambassadorfalse이고, scopeadmin인 경우에도 접근을 허용한다.

이것이 의미하는게 무엇일까?

'api/ambassador' 경로에 대해서는 'ambassador' 스코프를 가져야만 접근이 허용되고, 그 외의 경로에 대해서는 'admin' 스코프를 가져야 접근이 허용된다는 것이다.

조금 더 잘 이해하기 위해 JwtAccessGuard를 사용하여 유저 권한을 확인하는 라우트 핸들러 함수를 살펴보자.

// auth.controller.ts

  @UseInterceptors(ClassSerializerInterceptor)
  @Get(['admin/authenticate', 'ambassador/authenticate'])
  @UseGuards(JwtAccessAuthGuard)
  async user(@Req() req: Request): Promise<User> {
    const cookie = req.cookies['access_token_1'];
    const { id } = await this.jwtService.verifyAsync(cookie); 
    const verifiedUser: User = await this.userService.findUserById(id);
    return verifiedUser;
  }

즉, JwtAccessAuthGuard를 사용하는 핸들러 함수인 user() 메서드에서는 'api/ambassador' 경로에 대해서는 'ambassador' 스코프를 가져야만 접근이 허용되고, 그 외의 경로에 대해서는 'admin' 스코프를 가져야 접근이 허용된다. 이를 위해 JWT 토큰의 scope 값을 검증하고, 요청의 경로에 따라 is_ambassador 변수를 설정하여 접근을 제어하게 된다.

그럼 아래의 테스트 과정을 통해 확인해보자.


✔ Test (Postman)

1) admin 로그인 유저

먼저, is_ambassador가 false인 admin 유저의 로그인을 진행한다.

해당 유저에 대한 권한 확인을 '/admin/authenticate' 경로 요청으로 확인해본다.

잘 검증이 된 것을 확인할 수 있다.

그렇다면, 해당 admin 유저에 대한 권한 확인 요청을 '/ambassador/authenticate'로 수행하면 어떻게 될까?

우리가 설정한 가드를통해 접근할 수 없다는 응답을 받을 수 있다.


2) ambassador 유저

is_ambassador가 true인 ambassador 유저의 로그인을 진행한다.

해당 유저에 대한 권한 확인을 '/ambassador/authenticate' 경로 요청으로 확인해본다.

잘 검증이 된 것을 확인할 수 있고, 앞서와 마찬가지로 'admin/authenticate'로 요청을 날리게 되면 접근에러 응답을 받게 될 것이다.


생각정리

이번 포스팅에선 "Multiple Routes"를 통해 서로 다른 endpoint가 어떻게 동일한 라우트 핸들러 함수를 공유할 수 있는지에 대해 알아보았고, 동시에 "하나의 인증가드"를 사용하는데 있어서 어떠한 과정으로 서로 다른 endpoint에 대한 권한 인증처리를 할 수 있는지 역시 알아볼 수 있었다.

더 좋은 예시가 있을 수 있겠지만 (학교 홈페이지에서 교수와 학생 인증을 분리하는 것 또한 예시가 될 수 있을 것이다) adminambassador라는 일련의 예시를 통해 Multiple Routes 과정을 알아볼 수 있었고, 더불어 "Scope"라는 속성을 인증 토큰(액세스 토큰)에 포함시킴으로써 서로 다른 경로의 인증 요청에 대한 유연한 검증을 처리할 수 있었다.

더 좋은 방법이 있는지, 혹은 수정할 사항이 있는지에 대해 더 고민해보기로 하고 여기서 포스팅을 마치겠습니다...

(해당 포스팅또한 진행중이였던 nestjs 인증 시리즈에 추가하도록 하겠습니다)


nestjs - Authentication (advanced part)


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

0개의 댓글