Backend/NestJS

NestJS - JWT을 사용한 사용자 인증 ( with graphql )

hou27 2022. 4. 24. 16:36

NestJS를 사용한 백엔드에서 jwt 토큰으로 인증을 진행할 때 어떻게 구현해야할까?

 

지금부터 알아보도록 하겠다.

 

우선 구현에 앞서 몇몇 코드를 살펴보고 가자.

 


Backend 서버는 graphql api를 사용하였으며, 공식문서를 참고하였습니다.

https://docs.nestjs.com/security/authentication#implementing-passport-strategies

 

Documentation | NestJS - A progressive Node.js framework

Nest is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with TypeScript and combines elements of OOP (Object Oriented Progamming), FP (Functional Programming), and FRP (Functional Reac

docs.nestjs.com


app.module

@Module({
  imports: [
    ConfigModule.forRoot({
      ...
    }),
    TypeOrmModule.forRoot({
      ...
    }),
    GraphQLModule.forRoot({
      playground: process.env.NODE_ENV !== 'prod',
      driver: ApolloDriver,
      autoSchemaFile: true,
    }),
    AuthModule.forRoot({
      accessTokenPrivateKey: process.env.ACCESS_TOKEN_PRIVATE_KEY,
      refreshTokenPrivateKey: process.env.REFRESH_TOKEN_PRIVATE_KEY,
    }),
    UsersModule,
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

app 모듈의 코드이다.

 

GraphQLModule을 통해

playground: process.env.NODE_ENV !== 'prod',

NODE_ENV 환경변수가 prod가 아닐 때 graphql playground를 활성화하도록 하고,

autoSchemaFile: true,

메모리에서 즉석으로 스키마를 생성하도록 한 후

 

ApolloServer생성자로 전달하였다.

 

https://docs.nestjs.com/graphql/quick-start#getting-started-with-graphql--typescript

 

Documentation | NestJS - A progressive Node.js framework

Nest is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with TypeScript and combines elements of OOP (Object Oriented Progamming), FP (Functional Programming), and FRP (Functional Reac

docs.nestjs.com

 

users.resolver

@Resolver((of) => User)
export class UserResolver {
  constructor(private readonly usersService: UserService) {}

  @Query((returns) => User)
  @UseGuards(GqlAuthGuard)
  me(@AuthUser() authUser: User) {
    return authUser;
  }

  ...

  @Mutation((returns) => LoginOutput)
  async login(@Args('input') loginInput: LoginInput): Promise<LoginOutput> {
    return this.usersService.login(loginInput);
  }
  
  ...
}

User 모듈의 graphql을 위한 resolver의 모습이다.

테스트를 위해 로그인, 자기 자신의 정보를 조회하는 2가지 기능만 구현하였다.

 

 


이제 여기에 공식문서를 참고하여 jwt를 사용한 인증을 구현하도록 하겠다.

 

코드를 살펴보기에 앞서 테스트할 전체적인 흐름은 다음과 같다.

 

Summary

1. 유저가 로그인을 시도하면 정보를 확인한 후 access token과 refresh token을 반환한다.

2. 아래와 같은 형식으로 헤더에 access token을 담아 서버에 me query를 보낸다.

{
  "Authorization":"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwiaWF0IjoxNjUwNzc3NzQyLCJleHAiOjE2NTA3ODEzNDJ9.QywGeby7oLSTPsqs2qOMkxUTEGOAPOIfFCLJ_56mEB8"
}

3. guard에서 http request를 graphql을 위한 context로 변환하여 반환한다.

(graphql context는 http의 context와 다르기 때문에 변환이 필요함)

 

이 때, guard 호출 시 해당 전략의 validate가 호출되게 되는데, 이를 통해 gql context의 req에,

헤더에 담긴 token을 통해 해당하는 user가 담기도록 한다.

(guard 호출 시 해당 strategy의 validate 호출)

 

4. 커스텀 데코레이터를 통해 req에 담겨있는 user 정보를 반환한다.

 

jwt를 위한 패키지 설치

npm i @nestjs/passport @nestjs/jwt passport-jwt

nest g mo auth

필요한 것들을 설치한 후 nest cli를 이용하여 auth 모듈을 생성해주었다.

auth.service

import { Inject, Injectable } from '@nestjs/common';
import { JwtModuleOptions } from './jwt/jwt.interfaces';
import { CONFIG_OPTIONS } from 'src/common/common.constants';
import * as jwt from 'jsonwebtoken';

@Injectable()
export class AuthService {
  constructor(
    @Inject(CONFIG_OPTIONS)
    private readonly options: JwtModuleOptions,
  ) {}

  signAccessToken(
    userId: number,
  ): string {
    return jwt.sign({ id: userId }, this.options.accessTokenPrivateKey, {
      expiresIn: '1h',
    });
  }

  signRefreshToken(
    userId: number,
  ): string {
    return jwt.sign({ id: userId }, this.options.refreshTokenPrivateKey, {
      expiresIn: '24h',
    });
  }

  verifyAccessToken(token: string) {
    return jwt.verify(token, this.options.accessTokenPrivateKey);
  }

  verifyRefreshToken(token: string) {
    return jwt.verify(token, this.options.refreshTokenPrivateKey);
  }
}

auth 모듈에서 사용할 service를 작성해주었다.

access token, refresh token 각각을 위한 sign, verify 메서드를 작성해주었다.

Refresh Token - JWT

auth.guard

import {
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class GqlAuthGuard extends AuthGuard('jwt') {
  getRequest(context: ExecutionContext) {
    const ctx = GqlExecutionContext.create(context);
    return ctx.getContext().req;
  }

  handleRequest<TUser = any>(
    err: any,
    user: any,
    info: any,
    context: any,
    status?: any,
  ): TUser {
    ...
  }
}

사용자 인증 시 사용되는 guard이다.

들어온 Http Request를 Graphql에 맞는 형식으로 변환하여 반환하도록 한다.

graphql context는 http의 context와 다르기 때문에 변환이 필요하다.

jwt.strategy

import { ExtractJwt, Strategy } from 'passport-jwt';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { UserService } from 'src/users/users.service';
import { Payload } from './jwt.interfaces';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly usersService: UserService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: process.env.ACCESS_TOKEN_PRIVATE_KEY,
    });
  }

  async validate(payload: Payload) {
    // guard 호출 시 해당 strategy의 validate 호출
    const { user } = await this.usersService.findById(payload.id);
    if (user) {
      return user; // req.user에 담기게 됨.
    } else {
      throw new UnauthorizedException('User Not Found');
    }
  }
}

다음은 passport-jwt를 적용하기 위해 전략을 구성해주었다.

 

jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),

jwt web token은 헤더의 Bearer 토큰으로 전달받도록 하며,

ignoreExpiration: false,
secretOrKey: process.env.ACCESS_TOKEN_PRIVATE_KEY,

jwt의 만료기한을 무시하지 않도록 하고 비밀키는 env파일에 설정해둔 값을 사용하도록 하였다.

 

strategy의 validate

전략의 validate는 guard가 호출될 때 호출된다.

jwt가 verify된 후 오류가 없으면 payload에 decode된 값이 넘겨져오는데,

필자는 payload 내에 담긴 id 값을 통해 유저 정보를 가져와 반환하도록 하였다.

 

validate 메서드의 반환값은 request에 담기게 되는데, 조금 전 guard에서 http request를 graphql에 맞는 형식으로 변환하여 반환해주었기 때문에 해당 context 내의 req에 담기게 된다.

auth.module

import { DynamicModule, forwardRef, Global, Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CONFIG_OPTIONS } from 'src/common/common.constants';
import { User } from 'src/users/entities/user.entity';
import { UsersModule } from 'src/users/users.module';
import { AuthService } from './auth.service';
import { JwtModuleOptions } from './jwt/jwt.interfaces';
import { JwtStrategy } from './jwt/jwt.strategy';

@Module({
  imports: [
    PassportModule.register({ defaultStrategy: 'jwt', session: false }),
    JwtModule.registerAsync({
      useFactory: () => ({
        secret: process.env.ACCESS_TOKEN_PRIVATE_KEY,
      }),
    }),
    forwardRef(() => UsersModule),
    TypeOrmModule.forFeature([User]),
  ],
  providers: [AuthService, JwtStrategy],
  exports: [AuthService],
})
@Global()
export class AuthModule {
  static forRoot(options: JwtModuleOptions): DynamicModule {
    return {
      module: AuthModule,
      providers: [
        {
          provide: CONFIG_OPTIONS,
          useValue: options,
        },
        AuthService,
      ],
      exports: [AuthService],
    };
  }
}

jwt를 사용할 것이기 때문에 jwt 모듈을 import해주고, passport 모듈 또한 import해주었다.

passport는 기본 전략은 jwt이며, session은 사용하지 않도록 설정해주었다.

 

또한 access, refresh token을 위한 secret key가 필요하기 때문에 option으로 받을 수 있도록 다이나믹 모듈을 설정해주었다.

auth-user.decorator

import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';

export const AuthUser = createParamDecorator(
  (data: unknown, context: ExecutionContext) => {
    const gqlContext = GqlExecutionContext.create(context).getContext();
    const user = gqlContext.req.user;
    return user;
  },
);

validate를 통해 req에 담은 user를 반환받기 위해 커스텀 데코레이터를 작성했다.

 

초반에 살펴봤던 resolver의 me query를 다시 한번 보면

@Query((returns) => User)
@UseGuards(GqlAuthGuard)
me(@AuthUser() authUser: User) {
    return authUser;
}

가드를 거친 후, 커스텀 데코레이터를 통해 user 정보를 가져와 반환하도록 되어있다.

 

그래서 login을 통해 받은 access token을 헤더에 담아 query를 보내면

위와 같은 데이터를 반환받게 되는 것이다.

 

성공!!