본문 바로가기

Backend/NestJS

NestJS - 대충 서비스 만들어보기 (12)

이번 포스팅에선 NestJS 에서 제공하는 Guard 기능을 사용해보겠습니다.

 

- 인증 vs 인가

 

인증은 Authentication, 인가는 Authorization 으로 의미하는 바는 좀 다릅니다.

인증은 유저의 신원을 검증하는 프로세스로, 자신이 누구인지를 증명하는 것입니다. 인가는 인증 이후의 프로세스로 인증된 사용자가

어떤 리소스에 접근할 수 있는지를 확인하는 것을 인가라고 합니다. NestJS 에선 Guard 를 사용해 인가를 구현할 수 있습니다.

 

좀 희한한건 일반적으로 응답 상태를 나타낼 때 인증이 실패하면 401, 인가가 실패하면 403 인데

401 - Unauthrozied / 403 - Forbidden 으로 되어있습니다. Forbidden 은 이해가 되지만 Authentication 실패 에러를

Unauthoized 라고 하는건 좀 헷갈리기 쉬울 것 같으니 주의가 필요합니다.

 

예전엔 인증이라고하면 세션인증을 많이 사용했지만 이제는 토큰인증 방식을 많이 사용하는 것 같습니다.

현재 팀에선 인증을 사용할 일이 없어서 쓸 일은 없지만 전회사에선 인증을 구현할 때 JWT 를 많이 사용했었고,

지금 NestJS 에서도 JWT 를 사용해 인증을 구현하려고 합니다. JWT 자세한 설명 보다는 링크만 남겨두도록 하겠습니다.

 

- use JWT

 

지금까지 만든 서비스에는 별도의 로그인은 없으니 JWT 를 단순 발급하는 용도의 API 를 하나 만들어서 사용하도록 하겠습니다.

대략의 시나리오는 해당 API 를 사용해 토큰을 발급받고 광고 생성 또는 조회 API 를 사용할 때 해당 JWT 로 인가를 획득하는 것입니다.

 

> npm install jsonwebtoken
> npm install @types/jsonwebtoken --save-dev
> mkdir src/auth

 

먼저 JWT 관련된 모듈들을 설치한 후 인증 관련된 파일들이 위치할 auth 디렉토리를 생성합니다.

 

> touch src/auth/auth.controller.ts src/auth/auth.guard.ts src/auth/auth.module.ts src/auth/auth.service.ts
> touch config/authConfig.ts

 

관련 파일들을 생성했습니다. 뭔가 하다보니 저는 하나씩 만들게 됐지만 nest-cli 를 사용해 auth 리소스를 생성하셔도 됩니다.

 

...

JWT_SECRET=test

 

JWT 토큰을 생성하기 위해선 secret key 가 필요한데, 일단 테스트용이니 단순하게 test 라는 string 을 사용할까 합니다.

그대로 하드코딩하지 않고 환경변수로 등록해 사용할 수 있도록 .development.env dotenv 파일에 추가해주었습니다.

이 환경변수를 config 로 주입해 사용할 수 있도록 authConfig 관련 작업을 먼저 해봅니다.

 

import { registerAs } from '@nestjs/config';

export const authConfig = registerAs('auth', () => ({
  secret: process.env.JWT_SECRET,
}));

 

이미 이전 포스팅에서 config 는 여러번 추가했었기 때문에.. config/authConfig.ts 파일을 위와 같이 추가해줬습니다.

app.module.ts 의 load 부분에 authConfig 를 추가하는건 굳이 코드 첨부까지 하진 않겠습니다.

 

import { Inject, Injectable } from '@nestjs/common';
import { ConfigType } from '@nestjs/config';
import * as jwt from 'jsonwebtoken';
import { authConfig } from '../config/authConfig';

@Injectable()
export class AuthService {
  constructor(
    @Inject(authConfig.KEY) private config: ConfigType<typeof authConfig>,
  ) {}

  create(email: string) {
    const payload = { email };

    return jwt.sign(payload, this.config.secret);
  }
}

 

import { Controller, Get, Param } from '@nestjs/common';
import { AuthService } from './auth.service';

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @Get()
  create(@Param('email') email: string) {
    return this.authService.create(email);
  }
}

 

auth.service.tsauth.controller.ts 를 위와 같이 작성해줍니다.

일단 추가한 코드는 auth api 를 email parameter 와 같이 호출하면 email 을 사용해 JWT 토큰을 생성해 반환하는 코드입니다.

jwt.sign 메서드의 세번째 인자로 여러 옵션을 추가할 수 있는데, 대표적으로 토큰의 유효기간인 expiresIn 을 추가할 수 있지만

저는 테스트용이니 한번 발급한 토큰을 영구히 사용하기 위해서 아무런 옵션을 넣지 않았습니다.

 

이제 localhost:3000/auth 를 GET method 로 호출해 토큰을 발급받아봅니다.

e.. 로 시작하는 토큰이 생성되어 해당 요청에 대한 응답으로 반환되었을 것입니다.

 

- use Guard

 

이제 광고 API 에 JWT 를 통한 Guard 작업을 추가해보겠습니다.

 

import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigType } from '@nestjs/config';
import * as jwt from 'jsonwebtoken';
import { authConfig } from '../config/authConfig';

@Injectable()
export class AuthService {
  ...

  verify(token: string) {
    try {
      const { email } = jwt.verify(token, this.config.secret) as (
        | jwt.JwtPayload
        | string
      ) & { email: string };

      return { email };
    } catch (e) {
      throw new UnauthorizedException();
    }
  }
}

 

JWT 토큰을 이용해 해당 토큰이 유효한지를 인증하는 auth.service.ts 의 verify 메서드를 추가해줬습니다.

verify 를 할 땐 sign 할 때와 마찬가지로 동일한 secret 을 사용해야 합니다.

verify 에 성공하면 사실 여기선 사용할 일은 없지만 토큰에 심어진 email 을 반환하고,

실패하면 Unauthorized 에러를 뱉도록 구현했습니다.

 

import { Request } from 'express';
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Observable } from 'rxjs';
import { AuthService } from './auth.service';

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(private authService: AuthService) {}
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const request = context.switchToHttp().getRequest();
    const email = this.parseRequest(request);
    request.decoded = { email };

    return true;
  }

  private parseRequest(request: Request) {
    const [, token] = request.headers.authorization.split('Bearer ');
    const { email } = this.authService.verify(token);

    return email;
  }
}

 

위에서 작성한 auth.service 의 verify 를 사용한 auth.guard.ts 코드입니다.

가드는 위와 같이 CanActivate 인터페이스를 구현해야 합니다. 약간의 설명을 하자면 이 메서드는 ExecutionContext 라는 실행 컨텍스트 인스턴스를 인자로 받는데, 여기에 요청과 응답에 대한 정보를 갖고 있습니다.

이 인터페이스에서 제공하는 함수 중 switchToHttp 메서드를 이용해 필요한 요청 또는 응답에 대한 정보를 가져올 수 있습니다.

 

이렇게 가져온 정보로 parseRequest 라는 내부 메서드를 통해 canActivate 에서 인가를 진행합니다.

이 부분을 인가라고 하는 이유는 구현하는 canActivate 메서드가 true, false 중 무엇을 반환하느냐에 따라 Guard 를 적용한 요청이 인가를 얻어 다음 프로세스로 진행이 되는지, 또는 403 Forbidden 에러로 빠지는지 결정되기 때문입니다.

전 일단 인증이 되었으면 email 정보만 심고 인가된 사용자로 간주할 것이기 때문에 return true 를 넣었지만 인가된 사용자를 무엇으로

할지에 대한 요구사항에 따라 이 부분을 다르게 구현하시면 될 것 같습니다.

 

...
import {
  ...,
  UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard';

@UseGuards(AuthGuard)
@Controller('ads')
export class AdsController {
  ...
}

 

각 엔드포인트에도 별도로 @UseGuards 데코레이터를 통해 적용할 수 있지만 저는 ads controller 에 전체 적용을 했습니다.

이제 의도한대로 인증 - 인가가 적용되는지 확인해보겠습니다.

 

 

아까 위의 auth.guard.ts 에서 언급하지 못한 내용이 있다면 구현 내용을 보셔도 아시겠지만 JWT 토큰의 경우

요청 헤더에 Authorization 키로 Bearer {token} <- 과 같이 요청해야 합니다.

먼저 생성했던 토큰이 아닌 아무런 키나 집어넣은 토큰을 넣고 요청을 보내보면, 401 Unauthoized 에러가 발생합니다.

 

 

아까 발급받았던 토큰을 넣고 다시 요청을 보내보면, 200 응답이 정상적으로 떨어지는 것을 확인할 수 있습니다.

저는 토큰에 집어 넣었던 email 정보가 다른 API 에서 필요 없기 때문에 request.decoded 에 넣어놓고 쓰질 않지만

API 를 어떻게 만드느냐에 따라 충분히 활용할 요소가 있습니다.

 

NestJS 가 아닌 Express 를 사용하더라도 JWT 를 통한 인증 - 인가 부분은 미들웨어로 보통 빼기 때문에 이 부분에 의해서

코드가 지저분해질 일은 잘 없긴 하지만, NestJS 가 제공하는 Guard 기능을 통해 좀 더 괜찮은 AOP 를 할 수 있게 되었습니다.