NestJS - Guard, JWT


이 포스트는 NestJS 의 Guard 를 이용한 인가에 대해 알아본다.

소스는 example, user-service 에 있습니다.

인증, JWT, 세션, 토큰에 대한 상세한 내용은 인증, JWT, Sliding Session, Refresh Token 를 참고하세요.


1. Guard

Guard

NestJS - Middleware1. Middleware 에서 인증(Authentication) 을 Middleware 로 구현하는 것이 좋은 예시라고 하였다. 최근에는 매 요청마다 헤더에 JWT 토큰을 실어보내서 해당 토큰을 통해 인증하는 방식을 많이 사용한다.

인가(Authorization) 는 인증을 통과한 유저가 요청 기능을 사용할 권한이 있는지 판별하는 것으로 Middleware 가 아닌 Guard 를 이용하여 구현하는 것이 좋다.
인가는 permission, role, access control list 개념을 사용하여 유저가 갖고 있는 속성으로 리소스 사용 허용 여부를 판단한다.

인증(Authentication) 실패 시 - 401 Unauthorized
인가(Authorization) 실패 시 - 403 Forbidden

인가를 인증처럼 Middleware 로 구현할 수 없는 이유는 아래와 같다.

  • Middleware 는 실행 콘텍스트(ExecutionContext) 에 접근 불가
  • 자신의 일만 수행하고 next() 호출하기 때문에 다음에 어떤 핸들러가 실행될 지 알 수 없음
  • 반면, Guard 는 실행 콘텍스트 인스턴스에 접근이 가능하기 때문에 다음에 실행될 작업을 알고 있음

2. Guard 를 이용한 인가

$ nest new ch10

GuardCanActivate 인터페이스를 구현해야 한다.

/src/auth.guard.ts

import { CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';

export class AuthGuard implements CanActivate {
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const request = context.switchToHttp().getRequest();
    return this.validateRequest(request);
  }

  // 얻은 정보(request) 를 내부 규칙으로 평가 진행
  private validateRequest(request: any) {
    // 편의상 true 리턴
    // false 로 리턴 시 403 Forbidden 에러 발생함
    // 다른 에러 응답을 원하면 직접 다른 예외 생성해서 던지면 됨
    return true;
  }
}

2.1. 실행 콘텍스트

CanActivate 메서드, ExecuteContext 인터페이스, ArgumentsHost 인터페이스, HttpArgumentsHost 인터페이스의 각 시그니처

canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean>;

export interface ExecutionContext extends ArgumentsHost {
  getClass<T = any>(): Type<T>;
  getHandler(): Function;
}

export interface ArgumentsHost {
  getArgs<T extends Array<any> = any[]>(): T;
  getArgByIndex<T = any>(index: number): T;
  switchToRpc(): RpcArgumentsHost;
  switchToHttp(): HttpArgumentsHost;
  switchToWs(): WsArgumentsHost;
  getType<TContext extends string = ContextType>(): TContext;
}

export interface HttpArgumentsHost {
  getRequest<T = any>(): T;
  getResponse<T = any>(): T;
  getNext<T = any>(): T;
}

canActivate 함수는 ExecutionContext 인스턴스를 인수로 받고, ExecutionContextArgumentsHost 를 상속받으며 ArgumentsHost 는 요청과 응답에 대한 정보를 갖고 있다.
이 포스트에선 HTTP 로 기능을 제공하므로 HttpArgumentsHost 인터페이스에서 제공하는 switchToHttp() 함수로 정보를 가져올 수 있다.


2.2. Guard 적용

2.2.1. 컨트롤러 혹은 메서드 범위로 Guard 적용: @UseGuards

여러 개의 Guard 적용 시엔 쉼표로 이어 선언한다.

app.controller.ts

import { Controller, Get, UseGuards } from '@nestjs/common';
import { AppService } from './app.service';
import { AuthGuard } from './auth.guard';

@UseGuards(AuthGuard) // 클래스에 가드 적용
@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @UseGuards(AuthGuard) // 메서드에 가드 적용
  @Get()
  getHello(): string {
    return this.appService.getHello();
  }
}

2.2.2. 전역으로 Guard 적용

main.ts

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { AuthGuard } from './auth.guard';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalGuards(new AuthGuard()); // 전역 가드 적용
  await app.listen(3000);
}
bootstrap();

2.2.3. Guard 에 종속성 주입을 사용해서 다른 Provider 를 주입해서 사용

Custom Provider 로 선언하여 사용한다.

Custom Provider 는 NestJS - Provider4. Custom Provider 를 참고하세요.

app.module.ts

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { APP_GUARD } from '@nestjs/core';
import { AuthGuard } from './auth.guard';

@Module({
  imports: [],
  controllers: [AppController],
  providers: [
    AppService,
    {
      provide: APP_GUARD,
      useClass: AuthGuard,
    },
  ],
})
export class AppModule {}

인증, JWT 와 함께 보시면 도움이 됩니다.


3. 유저 서비스

위에서 한 내용과 NestJS - 동적 모듈로 환경변수 구성 참고하여 AuthService 를 AuthModule 에서 제공하도록 하고, AuthService 에서 사용할 JWT secret 을 환경변수로 등록한다.

$ nest g mo Auth    -- auth.module.ts 생성
$ nest g s Auth -- auth.service.ts 생성

동적으로 ConfigModule 등록

/src/config/authConfig.ts

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

export default registerAs('auth', () => ({
  jwtSecret: process.env.JWT_SECRET,
}));

/src/config/env/.local.env

...
JWT_SECRET=test

AppModule 에 ConfigModule 을 동적 모듈로 등록
app.module.ts

@Module({
  imports: [
    UsersModule,
    ConfigModule.forRoot({
      envFilePath: [`${__dirname}/config/env/.${process.env.NODE_ENV}.env`],
      load: [emailConfig, authConfig], // ConfigFactory 지정
      isGlobal: true, // 전역으로 등록해서 어느 모듈에서나 사용 가능
      validationSchema, // 환경 변수 값에 대해 유효성 검사 수행
    }),
...

/src/auth/auth.module.ts

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';

@Module({
  providers: [AuthService],
  exports: [AuthService],
})
export class AuthModule {}

/src/users/users.module.ts

@Module({
  imports: [EmailModule, TypeOrmModule.forFeature([UserEntity]), AuthModule], // UsersModule 에 forFeature() 로 유저 모듈 내에서 사용할 저장소 등록
  controllers: [UsersController],
  providers: [UsersService],
})
export class UsersModule {}

3.1. JWT 발급: 이메일 인증

회원 가입 요청 시 발송된 이메일 인증을 통해 회원 가입 완료 후 응답으로 토큰을 발급하여 로그인 상태가 되도록 해본다.

가입 시 아래와 같은 인증 링크가 포함되는데 “가입 확인” 버튼 클릭 시 요청에 대한 응답으로 JWT 문자열을 돌려준다.

const url = `${baseUrl}/users/email-verify?signupVerifyToken=${signupVerifyToken}`;

/src/users/users.service.ts (기존)

  // 이메일 인증
async verifyEmail(signupVerifyToken: string): Promise<string> {
  // DB 에 signupVerifyToken 으로 회원 가입 처리중인 유저가 있는지 조회 후 없다면 에러 처리
  const user = await this.userRepository.findOne({
    where: { signupVerifyToken },
  });

  if (!user) {
    throw new NotFoundException('존재하지 않는 유저');
  }
  
  // TODO: 바로 로그인 상태가 되도록 JWT 발급
  
  throw new Error('아직 미구현된 로직');
}

로그인 상태가 되도록 하는 로직은 AuthService 가 처리하도록 하기 한다.
JWT 토큰 발급 & 검증을 위해 jsonwebtoken 패키지를 사용한다.

$ npm i jsonwebtoken
$ npm i -D @types/jsonwebtoken

이제 AuthService 에서 로그인 처리를 한다.

/src/auth/auth.service.ts

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

interface User {
  id: string;
  name: string;
  email: string;
}

@Injectable()
export class AuthService {
  constructor(
          // 주입받을 때 @Inject 데커레이터의 토큰을 앞에서 만든 ConfigFactory 의 KEY 인 `auth` 문자열로 넣어준다.
          @Inject(authConfig.KEY) private config: ConfigType<typeof authConfig>,
  ) {}

  // 로그인 처리
  login(user: User) {
    // Private claim
    const payload = { ...user };

    // Registered claim
    return jwt.sign(payload, this.config.jwtSecret, {
      expiresIn: '1d',
      audience: 'test.com',
      issuer: 'test.com',
    });
  }
}

/src/users/users.service.ts

// 이메일 인증
async verifyEmail(signupVerifyToken: string): Promise<string> {
  // DB 에 signupVerifyToken 으로 회원 가입 처리중인 유저가 있는지 조회 후 없다면 에러 처리
  const user = await this.userRepository.findOne({
    where: { signupVerifyToken },
  });

  if (!user) {
    throw new NotFoundException('존재하지 않는 유저');
  }

  // 바로 로그인 상태가 되도록 JWT 발급
  return this.authService.login({
    id: user.id,
    name: user.name,
    email: user.email,
  });
}

이제 회원 가입 시 받은 이메일 버튼을 눌러서 응답 JWT 를 jwt.io/ 에 넣어 확인해본다.

jwt.io

JWT 발급에 사용한 Private claim (id, name, email) 과 함께 Registered claim (exp, aud, iss) 이 포함되어 있는 것을 확인할 수 있다. (iat 는 자동 생성)


3.2. JWT 발급: 로그인

전달받은 이메일과 패스워드로 유저 조회 후 유효한 유저이면 JWT 를 발급한다.

/src/users/users.service.ts

// 로그인
async login(email: string, password: string): Promise<string> {
  // DB 에 email, password 가진 유저 존재 여부 조회 후 없다면 에러 처리
  const user = await this.userRepository.findOne({
    where: { email, password },
  });

  if (!user) {
    throw new NotFoundException('존재하지 않는 유저');
  }

  // JWT 발급
  return this.authService.login({
    id: user.id,
    name: user.name,
    email: user.email,
  });
}

이제 로그인 요청을 하면 JWT 토큰이 전달되는 것을 알 수 있다.

$ curl --location 'http://localhost:3000/users/login' \
--header 'Content-Type: application/json' \
--data-raw '{
    "email": "test@test.com",
    "password": "test1234"
}'

3.3. JWT 인증: 회원 정보 조회

이제 매 요청마다 로그인 후 받은 토큰으로 인증을 처리하도록 한다.

API Spec

GET /users/:id
Authorization: Bearer <token>

Header 로 전달하는 JWT 의 Private claim 에 유저의 ID 가 포함되어 있지만 REST 형식의 API 명세를 맞추기 위해 id 를 경로 매개변수로 다시 전달받는다.

/src/auth/auth.service.ts

// jwt 검증
verify(jwtString: string) {
  try {
    // 외부에 노출되지 않는 secret 을 사용하기 때문에 이 토큰이 유효한지 검증 가능
    const payload = jwt.verify(jwtString, this.config.jwtSecret) as (jwt.JwtPayload | string) & User;
    const { id, email } = payload;

    return {
      userId: id,
      email,
    };
  } catch (e) {
    throw new UnauthorizedException();
  }
}

/src/users/users.controller.ts

import { Headers } from '@nestjs/common';
import { AuthService } from '../auth/auth.service';

@Controller('users')
export class UsersController {
  // UsersService 를 컨트롤러에 주입
  constructor(
    private readonly usersService: UsersService,
    private readonly authService: AuthService,
  ) {}

  // 유저 정보 조회
  @Get(':id')
  async getUserInfo(
    @Headers() headers: any,
    @Param('id') userId: string,
  ): Promise<UserInfo> {
    // jwt 파싱
    const jwtString = headers.authorization.split('Bearer ')[1];
    this.authService.verify(jwtString);
    return await this.usersService.getUserInfo(userId);
  }
}

/src/users/users.service.ts

// 유저 정보 조회
async getUserInfo(userId: string): Promise<UserInfo> {
  // DB 에 userId 가진 유저 존재 여부 조회 후 없다면 에러 처리
  const user = await this.userRepository.findOne({
    where: { id: userId },
  });

  if (!user) {
    throw new NotFoundException('존재하지 않는 유저');
  }

  // 조회 데이터를 userInfo 타입으로 리턴
  return {
    id: userId,
    name: user.name,
    email: user.email,
  };
}

이제 JWT 를 전달하여 유저 정보를 조회해본다.

$ curl --location 'http://localhost:3000/users/01GXN39KWVPKV7WZR5XFD0A5FH' \
--header 'Authorization: Bearer eyJhbGciOiJ6IkpXVCJ9.eyJpZCI6IjAxR1hOMzlLV1ZQS1Y3V1pSNVhGRDBBNUZIIiwibmFtZSI6ImFzc3UiLCJlbWFpbCI6ImpoLmxlZUB3ZXZlcnNlY29tcGFueS5jb20iLCJpYXQiOjE2ODExMTc0NzksImVLmNvbSJ9._-ysauarpOjSz_LFcYhjZha9Mw' | jq

{
  "id": "01GXN39KWVPKV7WZR5XFD0A5FH",
  "name": "assu",
  "email": "test@test.com"
}

3.4. Guard 로 인가 처리

위의 유저 정보 조회 API 를 보면 Header 에 포함된 JWT 토큰 유효성 검사 로직을 모든 엔드포인트에 중복 구현하도록 되어 있다.
이를 NestJS 에서 제공하는 Guard 를 이용하여 handler 에서 분리해본다.

/src/uses/users.controller.ts (기존 방식)

// 유저 정보 조회
@Get(':id')
async getUserInfo(
  @Headers() headers: any,
  @Param('id') userId: string,
): Promise<UserInfo> {
  // jwt 파싱
  const jwtString = headers.authorization.split('Bearer ')[1];
  this.authService.verify(jwtString);
  return await this.usersService.getUserInfo(userId);
}

앞에서 본 것처럼 AuthGuard 를 적용해본다.

/src/auth.guard.ts

import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Observable } from 'rxjs';
import { AuthService } from './auth/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();
    return this.validateRequest(request);
  }

  // 얻은 정보(request) 를 내부 규칙으로 평가 진행
  private validateRequest(request: any) {
    const jwtString = request.headers.authorization.split('Bearer ')[1];
    this.authService.verify(jwtString);
    // false 로 리턴 시 403 Forbidden 에러 발생함
    // 다른 에러 응답을 원하면 직접 다른 예외 생성해서 던지면 됨
    return true;
  }
}

AuthGuard 를 전역으로 적용하면 가입, 로그인 등 액세스 토큰없이 요청하는 기능이 사용 불가하므로 회원 조회 엔드포인트에만 적용한다.
컨트롤러를 분리하여 분리된 컨트롤러에 적용해도 무방하다.

/src/uses/users.controller.ts (Guard 적용 후)

// 유저 정보 조회
@UseGuards(AuthGuard)
@Get(':id')
async getUserInfo(
  @Headers() headers: any,
  @Param('id') userId: string,
): Promise<UserInfo> {
  // jwt 파싱
  //const jwtString = headers.authorization.split('Bearer ')[1];
  //this.authService.verify(jwtString);
  return await this.usersService.getUserInfo(userId);
}

이제 다시 유저 정보 조회를 해보면 정상적으로 동작하는 것을 확인할 수 있다.

$ curl --location 'http://localhost:3000/users/01GXN39KWVPKV7WZR5XFD0A5FH' \
--header 'Authorization: Bearer eyJhbGciOiJIUzpXVCJ9.eyJpZCI6IjAxR1hOMzlLV1ZQS1CJlbWFpbCI6ImpoLmxlZUB3ZXZlcnNlY29tcGFueS5jb20iLCJpYXQiOjE2ODExMTc0NzksImV4cCI6MTY4MTIwMzg3OSwiYXVkIjoidGVzdC5jb20iLCJpc3MiOiJ0ZXN0LmNvbSJ9._-ysauarpOjYhjZha9Mw' | jq

{
  "id": "01GXN39KWVPKV7WZR5XFD0A5FH",
  "name": "assu",
  "email": "test@test.com"
}
$ curl --location 'http://localhost:3000/users/01GXN39KWVPKV7WZR5XFD0A5FH' \
--header 'Authorization: Bearer wrongToken' | jq

{
  "statusCode": 401,
  "message": "Unauthorized"
}

좀 더 나은 AOP 가 되었다.


참고 사이트 & 함께 보면 좋은 사이트

본 포스트는 한용재 저자의 NestJS로 배우는 백엔드 프로그래밍을 기반으로 스터디하며 정리한 내용들입니다.






© 2020.08. by assu10

Powered by assu10