본문 바로가기
Research/Nest.js

nestjs_docs_JWT Authentication

by RIEM 2023. 9. 22.

nestjs 공식문서 Authentication 파트를 요약 정리한 내용입니다(https://docs.nestjs.com/security/authentication)

인증과 인가 차이

어플리케이션은 사용자의 권한을 확인하기 위해 1)인증(authentication)과 2)인가(authorization) 작업을 한다. 1)인증은 유저가 자신이 서비스를 사용할 수 있음을 증명하는 것이고 2)인가는 인증된 유저가 특정 기능에 대한 사용 권한이 있는지 판별하는 것이다.

authentication 모듈 생성

$ nest g module auth
$ nest g controller auth
$ nest g service auth

유저 관련 작업 수행을 캡슐화시키기 위해 UsersService도 생성해주자.

$ nest g module users
$ nest g service users

유저 서비스 모듈에 findOne 로직과 하드코딩 간이 DB를 만들어주자.

// users.service.ts
import { Injectable } from '@nestjs/common';

// This should be a real class/interface representing a user entity
export type User = any;

/**
 * hard-coded DB
 * - you can replace this with ORM later
 */
@Injectable()
export class UsersService {
  private readonly users = [
    {
      userId: 1,
      username: 'john',
      password: 'changeme',
    },
    {
      userId: 2,
      username: 'maria',
      password: 'guess',
    },
  ];

  async findOne(username: string): Promise<User | undefined> {
    return this.users.find((user) => user.username === username);
  }
}

UsersService를 외부에서 사용하기 위한 유저 모듈에 서비스 추가

import { Module } from '@nestjs/common';
import { UsersService } from './users.service';

@Module({
  providers: [UsersService],
  exports: [UsersService], // enable visible outside
})
export class UsersModule {}

'Sign in' 엔드포인트 구현

// auth.service.ts

import { Injectable, UnauthorizedException } from '@nestjs/common';
import { UsersService } from 'src/users/users.service';

@Injectable()
export class AuthService {
  // 외부 유저서비스 주입
  constructor(private usersService: UsersService) {}

  async signIn(username: string, pass: string): Promise<any> {
    const user = await this.usersService.findOne(username);
    if (user?.password !== pass) {
      throw new UnauthorizedException();
    }
    const { password, ...result } = user;

    // TODO : Generate JWT and return it here instead of user obj
    return result;
  }
}

Auth 모듈 업데이트

import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { UsersModule } from 'src/users/users.module';
import { AuthService } from './auth.service';

@Module({
  imports: [UsersModule],
  providers: [AuthService],
  controllers: [AuthController],
})
export class AuthModule {}

auth컨트롤러에 signIn() 추가

  • 여기서 유저 정보가 맞으면 토큰을 줘야 하는 지점이다.
    // auth.controller.ts
    

import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { AuthService } from './auth.service';

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

@HttpCode(HttpStatus.OK)
@Post('login')
signIn(@Body() signInDto: Record<string, any>) {
return this.authService.signIn(signInDto.username, signInDto.password);
}
}

- Record<> 대신 DTO 클래스 사용해도 된다.


## JWT token

requirements about JWT:
- 유저가 username, password을 정확히 넣으면, JWT 토큰 주기
- JWT의 정상 여부에 따라 방어해주는 API 라우트 만들기

$ npm install --save @nestjs/jwt


```ts
// auth.service.ts

import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { UsersService } from 'src/users/users.service';

@Injectable()
export class AuthService {
  // 외부 유저서비스 주입
  constructor(
    private usersService: UsersService,
    private jwtService: JwtService,
  ) {}

  async signIn(username: string, pass: string): Promise<any> {
    const user = await this.usersService.findOne(username);
    if (user?.password !== pass) {
      throw new UnauthorizedException();
    }

    const payload = { sub: user.userId, username: user.username };
    return {
      access_token: await this.jwtService.signAsync(payload)
    }
  }
}

JWT생성한 다음 access_token라는 객체에 넣어 반환했다. signAsync() 는 JWT 생성하는 함수다.

시크릿 키를 저장하는 파일을 만들자.

// ./auth/constants.ts
export const jwtConstants = {
  secret:
    'DO NOT USE THIS VALUE. INSTEAD, CREATE A COMPLEX SECRET AND KEEP IT SAFE OUTSIDE OF THE SOURCE CODE.',
};

auth 모듈에 jwt 모듈을 import 하고, auth 모듈을 export해준다.

import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { UsersModule } from 'src/users/users.module';
import { AuthService } from './auth.service';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './constants';

@Module({
  imports: [
    UsersModule,
    JwtModule.register({
      global: true,
      secret: jwtConstants.secret,
      signOptions: { expiresIn: '60s' },
    }),
  ],
  providers: [AuthService],
  controllers: [AuthController],
  exports: [AuthService],
})
export class AuthModule {}

JWT는 global로 등록해주었기에 어플리케이션 내 어디서든 Jwt모듈 import하지 않고 바로 사용할 수 있다.

authentication 가드 구현

AuthGuard 를 통해 JWT 여부에 따라 특정 엔드포인트들을 방어하는 방법을 알아보자.

// auth.guard.ts
import {
  CanActivate,
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { jwtConstants } from './constants';
import { Request } from 'express';

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(private jwtService: JwtService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const token = this.extractTokenFromHeader(request);
    if (!token) {
      throw new UnauthorizedException();
    }
    try {
      const payload = await this.jwtService.verifyAsync(token, {
        secret: jwtConstants.secret,
      });
      // we are assigning the payload to the request object here
      // so that we can access it in our route handlers
      request['user'] = payload;
    } catch {
      throw new UnauthorizedException();
    }
    return true;
  }

  private extractTokenFromHeader(request: Request): string | undefined {
    const [type, token] = request.headers.authorization?.split(' ') ?? [];
    return type === 'Bearer' ? token : undefined;
  }
}

AuthGuard를 profile 라우터에 추가해준다.

// auth.controller.ts

import {
  Body,
  Controller,
  Get,
  HttpCode,
  HttpStatus,
  Post,
  Request,
  UseGuards,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthGuard } from './auth.guard';

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

  @HttpCode(HttpStatus.OK)
  @Post('login')
  signIn(@Body() signInDto: Record<string, any>) {
    return this.authService.signIn(signInDto.username, signInDto.password);
  }

  @UseGuards(AuthGuard)
  @Get('profile')
  getProfile(@Request() req) {
    return req.user;
  }
}
$ # GET /profile
$ curl http://localhost:3000/auth/profile
{"statusCode":401,"message":"Unauthorized"}

$ # POST /auth/login
$ curl -X POST http://localhost:3000/auth/login -d '{"username": "john", "password": "changeme"}' -H "Content-Type: application/json"
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2Vybm..."}

$ # GET /profile using access_token returned from previous step as bearer code
$ curl http://localhost:3000/auth/profile -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2Vybm..."
{"sub":1,"username":"john","iat":...,"exp":...}

beare 토큰 없이 profile로 요청하면 unauthorized 401 에러가 발생하지만, 로그인한 다음 받은 JWT를 베어러 토큰에 추가하여 다시 요청하면 정상적으로 데이터를 반환해준다.

가드를 글로벌로 적용하기

위에서는 auth/profile 엔드포인트에만 가드를 적용했다면, 글로벌로 모든 엔드포인트에 적용할 수도 있다.

// auth.module.ts

import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { UsersModule } from 'src/users/users.module';
import { AuthService } from './auth.service';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './constants';
import { APP_GUARD } from '@nestjs/core';
import { AuthGuard } from './auth.guard';

@Module({
  imports: [
    UsersModule,
    JwtModule.register({
      global: true,
      secret: jwtConstants.secret,
      signOptions: { expiresIn: '60s' },
    }),
  ],
  providers: [
    AuthService,
    // global guard 등록
    {
      provide: APP_GUARD,
      useClass: AuthGuard,
    },
  ],
  controllers: [AuthController],
  exports: [AuthService],
})
export class AuthModule {}

providers에 APP_GUARD를 AuthGuard 클래스로 사용한다고 등록하면, 자동으로 모든 엔드포인트에 AuthGuard가 적용된다.

SetMetadata 데코레이터 팩토리 함수를 이용하여 커스텀 데코레이터를 만들자.

// ./auth/decorators/public.decorator.ts

import { SetMetadata } from "@nestjs/common";

// metadata key
export const IS_PUBLIC_KEY = 'isPublic';

// new decorator called 'Public'
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);


// result -> we will get @Public() decorator

isPublic 메타데이터가 있을 경우 AuthGuard가 리턴하도록 해주자.

// auth.guard.ts
import {
  CanActivate,
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { jwtConstants } from './constants';
import { Request } from 'express';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from './decorators/public.decorator';

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(private jwtService: JwtService, private reflector: Reflector) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) {
      return true;
    }

    const request = context.switchToHttp().getRequest();
    const token = this.extractTokenFromHeader(request);
    if (!token) {
      throw new UnauthorizedException();
    }
    try {
      const payload = await this.jwtService.verifyAsync(token, {
        secret: jwtConstants.secret,
      });
      // we are assigning the payload to the request object here
      // so that we can access it in our route handlers
      request['user'] = payload;
    } catch {
      throw new UnauthorizedException();
    }
    return true;
  }

  private extractTokenFromHeader(request: Request): string | undefined {
    const [type, token] = request.headers.authorization?.split(' ') ?? [];
    return type === 'Bearer' ? token : undefined;
  }
}

댓글