본문 바로가기

NestJs - JWT 사용한 인증 & 인가 처리 (access token) 본문

개발/nest.js

NestJs - JWT 사용한 인증 & 인가 처리 (access token)

자전하는명왕성 2023. 2. 12. 11:21

 

미래의 나에게

jwt 패키지 설치 / bcrypt 패키지 설치

yarn add @nestjs/jwt passport-jwt
yarn add @nestjs/passport
yarn add --dev @types/passport-jwt
yarn add bcrypt
yarn add --dev @types/bcrypt

 

2023.02.10 - [코딩/알쓸코잡] - 인증(Authentication) & 인가(Authorization)

2023.01.30 - [코딩/알쓸코잡] - JWT (JSON Web Token)

이전 포스팅과 연결고리가 많은 포스팅이다.

 

JWT (Jason Web Token)

JWT 는 유저를 인증 / 식별하기 위한 토큰 기반의 인증이라고 볼 수 있다.

JWT 는 토큰 자체에 [사용자의 권한 정보 / 서비스를 사용하기 위한 정보] 가 포함되어 있는데,

이 JWT 는 서버가 아닌 '클라이언트'에게 저장되기 때문에 서버의 부담을 덜 수 있다는 장점을 가진다.

 

JWT 구조

JWT의 구조는 Header / Payload, Signature 세 가지로 이루어져있다.

  • Header : JWT에서 사용할 타입(JWT)과 알고리즘 종류를 담겨 있다.
  • Payload : 서버에서 첨부한 사용자 권한 정보와 데이터가 담겨 있다. 
  • Signature : Header의 인코딩 값과 Payload 의 인코딩 값을 합친 후 비밀키로 만든 뒤 생성한 값이 담겨 있다.

 

https://jwt.io/

 

NestJS 에서 jwt 구현

 

인증 구현

먼저 최상단에 남겨둔 jwt패키지를 설치한 뒤,

auth.module.ts / resolver / service 파일을 생성한다.

 

1. resolver 에서 API 를 작성한다.

// auth.resolver.ts

@Mutation(() => String)// return으로 accessToken 을 받을 것이므로 return type 은 string
  login(
    @Args('email') email: string, // argument 로는 email과 password 를 받는다.
    @Args('password') password: string, //
  ): Promise<string> {	// DB 안에 해싱되어있는 비밀번호에 접근하는 과정이 필요하므로 promise
    return this.authService.login({ email, password });	// module로부터 DI한 authService
  }
  
  
// auth.service.ts
import { JwtService } from '@nestjs/jwt'; // jwt 사용을 위한 Import
import * as bcrypt from 'bcrypt'; // 전역에서 사용하기 위한 '*' 처리

@Injectable()
export class AuthService {
  constructor(
    private readonly jwtService: JwtService, // DI
    private readonly usersService: UsersService, //  DI
  ) {}
  async login({ email, password }: IAuthServiceLogin): Promise<string> {
    // 1. DB 내 저장된 email이 있는지 확인하는 로직
    const user = await this.usersService.findOneByEmail({ email }); 
    				// email과 관련된 DB를 가져와 user 에 저장
    // 2. 저장된 해당 email이 없다면 expcetion을 반환
    if (!user) throw new UnprocessableEntityException('이메일이 없습니다.');
    // 3. password 와 해쉬화되어 저장되어 있는 DB 내 user.password를 비교
    const isAuth = await bcrypt.compare(password, user.password);
    
	// 4. 비교결과가 같지 않다면 exception 반환
    if (!isAuth) throw new UnprocessableEntityException('암호가 틀렸습니다.');

    return this.getAccessToken({ user }); // 토큰 생성 함수 실행
  }

  getAccessToken({ user }: IAuthServiceGetAccessToken): string {
    return this.jwtService.sign(	// jwtService 내에 있는 함수인 sign을 실행
      { sub: user.id },	// sub, secret / expiresIn 에 해당정보를 저장한다.
      { secret: process.env.JWT_ACCESS_KEY, expiresIn: '1h' },
      // 이때 secret 은 env에 저장해놓은 accessKey, expiresIn은 만료시간을 의미
    );
  }
}

 

* JwtService 의 sign 메서드에 대한 추가 설명

토큰 생성 메서드
jwt.sign(json data, secretKey, [options, callback])

 

json data : 유저의 정보가 담긴 payload 의미

secretKey : 서명된 JWT를 생성할 때 사용하는 키

option : 해싱 알고리즘 (기본적으로 HS256 해싱 알고리즘을 사용), 토큰 유효 기간 및 발행자 지정 가능

 

이제 완성된 resolver와 service를 합친다.

// auth.module.ts

@Module({
  imports: [
	  // register에는 JWT토큰을 만들 때, 필요한 설정들을 넣어줄 수 있음
    JwtModule.register({}), 
    TypeOrmModule.forFeature([	// user table을 조회하기 위함
      User, //
    ]),
    UsersModule, //
  ],
  providers: [
    AuthResolver, //
    AuthService, //
    UsersService, // email을 검증하는 과정에서 userService.findMail-- 을 사용했기 떄문에 추가됨
    JwtService, //
  ],
})
export class AuthModule {}


// app.module.ts

@Module({
  imports: [
    AuthModule,	// 추가
    ProductsModule,
// 중략 //

 

 

여기까지 완료하게 되면, 플레이그라운드에서 jwt로 만들어진 인가를 위한 accessToken을 발급받을 수 있게 된다.

해당 이메일이 DB에 없을 시 오류 || 비밀번호가 맞지 않을 시, throw ConflictExpception

 

인가 구현

작성 전 data-flow

사용자의 인증이 필요한 경우 

클라이언트는 발급받은 JWT를 Request Header에 실어 같이 보내고

backend 는 JWT을 받고 Guard를 통해 JWT strategy 를 실행하며 Secret Key 를 통해 JWT 를 디코딩한다.

JWT을 복호화한 후, 원하는 API 의 로직이 실행된 이후에 Response 됨

 

먼저 passport 라이브러리를 설치한다.

passport 인증 라이브러리로 JWT / 사용자 이름&암호를 확인하여 사용자를 인증하기 위해 사용하며

인증된 사용자에 대한 정보를 Request 객체에 담아줄 수 있다.

 

이전 만들어 둔 fetchUser 는 누구나 사용할 수 있는 API 이기에,

이번에는 로그인(accessToken을 보유)하고 있는 사용자만 활용할 수 있는 API를 만들어본다.

 

먼저 user.resolver.ts 에 이를 위한 API 형식을 간단히 작성해준다.

// users.resolver.ts

@UseGuards(AuthGuard('access')) // 이후 설명
  @Query(() => String)
  fetchLoginUser(): string {
    console.log('인가에 성공했습니다.');
    return '인가에 성공했습니다.';
  }

 

 

PassportStrategy 구현

passport 라이브러리에서 제공되는 passportStrategy 를 extends 하여 전략패턴(strategy)으로 사용할 것이므로,

Auth 폴더 안에 strategy 폴더를 생성한 뒤 jwt-access.strategy 파일을 생성했다.

** super 를 사용하여 passportStrategy의 생성자함수를 호출하여 JWT값을 넘긴다.

** 이때 passportStrategy에는 accessToken 이 유효한지, 만료시간이 남았는지를 파악하는 로직이 있어 이를 활용하기 위함이다.

// jwt-access.strategy.ts

import { PassportStrategy } from '@nestjs/passport'; // passport
import { ExtractJwt, Strategy } from 'passport-jwt';

// PassportStrategy 상속 // 이때, 'access' 는 액세스를 위한 함수이므로 access라 이름 붙임
// 이때 이름은 user.resolver.ts AuthGuard 와 이름이 같아야 함 (아래에서 다룸)
export class JwtAccessStrategy extends PassportStrategy(Strategy, 'access') {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), // bearer 을 제외한 문자열 추출
      secretOrKey: process.env.JWT_ACCESS_KEY,	// 복호화할 키
    });
  }

  validate(payload) {	// 인증 성공 시 payload 열람하기 위한 validate 
    return {
      email: payload.email, // 아래와 같음
      id: payload.sub,	// payload.sub 는 인증할 때 sub : id 로 담았던 id
    };
  }
}

// app.module.ts // DI

import { JwtAccessStrategy } from './apis/auth/strategy/jwt-access.strategy';
@module({
// 중략 //
 providers: [
    JwtAccessStrategy, //
  ],
  })

 

@UseGuard 구현

GraphQL에서는 guard 를 사용하기 위해 추가적인 로직이 필요하다.

Guard 는 말 그대로 API 요청자가 우리 사용자가 맞는지 파악한 뒤

API 를 실행할 수 있도록 도와주는 역할을 한다.

 

먼저, auth 내 guard 폴더를 생성한 후,  gql-auth.guard.ts 파일을 만들어준다.

// gql-auth.guard.ts

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

// GqlAuthAccessGuard 는 AuthGuard('access')를 상속받음
// GqlAuthAccessGuard 실행 후 AuthGuard 실행 (오버라이딩)
// AuthGuard('access')는 user.resolver.ts 에서 만들어준 Guard
export class GqlAuthAccessGuard extends AuthGuard('access') {
  getRequest(context: ExecutionContext) {
    const gqlContext = GqlExecutionContext.create(context);
      // rest-api 용으로 들어오는 context 이기에 GraphQL 용으로 바꿔줌 (오버라이딩)
    return gqlContext.getContext().req;// user.resolver.ts 파일이 해당 클래스로 리턴
  }
}

 

이제 GqlAuthAccessGuard 를 사용할 수 있게 되었으니  기존 users.resolver.ts 파일을 수정한다.

// users.resolver.ts

// @UseGuards(AuthGuard('access'))
@UseGuards(GqlAuthAccessGuard) // 수정
  @Query(() => String)
  fetchLoginUser(): string {
    console.log('인가에 성공했습니다.');
    return '인가에 성공했습니다.';
  }

 

여기까지 완료되면 GraphQL header 에 access 토큰을 담아보내면 올바르게 실행됨을 확인할 수 있다.

다만 아직 user 정보를 확인할 수 없기에 받아올 수 있도록 수정해본다.

 

user 정보가져오기 

먼저 resolver.ts를 다음과 같이 수정한다.

// user.resolver.ts

@UseGuards(GqlAuthAccessGuard)
  @Query(() => String)
  fetchLoginUser(@Context() context: IContext): string { // 수정
    console.log(context.req.user);
    return '인가에 성공했습니다.';
  }

이때 등장한 context 는 무엇일까.

잠시 resolver 함수에서 다루는 네 개의 인수에 대해 소개한다. (GraphQL)

  • obj : query 타입 이전에 사용된 객체로 요즘은 사용되지 않는 인수다. (_ 를 통해 빈값으로 사용하기도 함)
  • arg : GraphQL 쿼리의 필드에 제공된 인수. argument 의 약자로, 매개변수로 쓴다.
  • info : 현재 query. schema 정보와 관련된 정보를 보유한다. 
  • context : 모든 resolver 함수에 전달되며, 현재 로그인한 사용자 / DB access 와 같은 중요 정보를 담는다.
  •  ** context 는 Request / Response / header / payload 등 에 대한 정보들을 담고 있다.

우리는 이를 타입스크립트에서 입맛대로 활용하기 위해, context 를 위한 인터페이스를 만든다.

전역에서 사용할 것이므로, commons 에서 context.ts 생성한다.

// commons/interfaces.context.ts

import { Request, Response } from 'express'; // 익스프레스에서 받는다.


export interface IAuthUser { // user 에 대한 interface
  user?: { // 검증에 실패하는 경우도 존재하기에 user? 필수적이지 않는 타입으로 지정
    id: string;
  };
}

export interface IContext { // context 는 key:value 형식이므로 아래와 같이 설정한다.
  req: Request & IAuthUser;
  res: Response;
}

 

여기까지 완료되면 console 창에서 

validate 내 존재하는 payload 값과 API 요청자의 id 값을 받아올 수 있게 된다.

Comments