Javascript/NestJS

NestJS JWT 발급과 인증

kyoulho 2023. 4. 23. 12:57

 

저번 시간에는 Google OAuth를 통해 사용자를 인증하는 방법에 대해 다뤘습니다. 오늘은 이어서 인증된 정보를 가지고 JWT를 발급하고 인증하는 방법에 대해서 다루겠습니다.

 

패키지 설치

npm i --save @nestjs/jwt passport-jwt bcryptjs
npm i --save-dev @types/passport-jwt @types/bcryptjs

@nestjs/jwt는 Nest.js 프레임워크에서 JWT(JSON Web Tokens) 인증을 구현할 때 사용되는 패키지입니다.

passport-jwt는 Passport와 함께 JWT 인증을 사용할 때 사용되는 패키지입니다.

bcryptjs는 refresh token을 해쉬하여 데이터베이스에 저장하기 위해서 설치합니다.

 

JWT 발급

JwtModule 등록

Auth Module 의 JwtModule과 TypeOrmModule을 추가하여 줍니다. JwtModule은 JwtService 객체를 사용하기 위해 등록해 주어야합니다.

AppModule에 JwtModule을 Import 해주고 하위 모듈에서 JwtService를 Provider로 등록하여도 애플리케이션이 로드되는데는 문제없지만 JwtService에 옵션은 공유되지 않기 때문에 JwtService를 사용하는 모든 모듈에서 Import를 해줘야하는 아주 귀찮은 문제가 있습니다.

@Module({
  imports: [
    TypeOrmModule.forFeature([User]),
    JwtModule.registerAsync({
      inject: [ConfigService],
      useFactory: (configService: ConfigService) => ({
        secret: configService.get('JWT_SECRET'),
      }),
    }),
  ],
  controllers: [AuthController],
  providers: [
    GoogleStrategy,
    KakaoStrategy,
    NaverStrategy,
    FacebookStrategy,
    AuthService,
  ],
})
export class AuthModule {}

 

Auth Service

import { HttpStatus, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { User } from './entity/user.entity';
import { Repository } from 'typeorm';
import { OauthUserDto } from './dto/oauth-user.dto';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import { JwtPayload } from './dto/jwt-payload';
import { CustomException, ExceptionCode } from '../exception/custom.exception';

@Injectable()
export class AuthService {
  constructor(
    @InjectRepository(User)
    private userRepository: Repository<User>,
    private jwtService: JwtService,
  ) {}

  async loginOrSignIn(userDto: OauthUserDto) {
    const user = await this.findUserOrSave(userDto);
    const token = this.createToken(user);
    await this.updateHashedRefreshToken(user.id, token.refreshToken);
    return token;
  }

  async refreshToken(
    authorization: string,
    userId: number,
  ): Promise<{ accessToken: string; refreshToken: string }> {
    const user = await this.userRepository.findOneByOrFail({ id: userId });
    const refreshToken = authorization.replace('Bearer ', '');
    if (!(await bcrypt.compare(refreshToken, user.hashedRefreshToken))) {
      throw new CustomException(
        ExceptionCode.INVALID_TOKEN,
        'Refresh Token 비교 중에 예외가 발생했습니다.',
        HttpStatus.UNAUTHORIZED,
      );
    }
    const token = this.createToken(user);
    await this.updateHashedRefreshToken(user.id, token.refreshToken);
    return token;
  }

  private async findUserOrSave(userDto: OauthUserDto) {
    const { provider, providerId } = userDto;
    const existingUser = await this.userRepository.findOne({
      where: { provider, providerId },
      withDeleted: true,
    });
    return existingUser || this.userRepository.create(userDto).save();
  }

  private createToken(user: User): {
    accessToken: string;
    refreshToken: string;
  } {
    const payload: JwtPayload = {
      userId: user.id,
      nickname: user.nickname,
      birthdate: user.birthdate.getTime(),
      isDeleted: !!user.deleteAt,
    };
    const accessToken = this.jwtService.sign(payload, { expiresIn: '1h' });
    const refreshToken = this.jwtService.sign(payload, { expiresIn: '1d' });
    return { accessToken, refreshToken };
  }

  private async updateHashedRefreshToken(
    userId: number,
    refreshToken: string,
  ): Promise<void> {
    const salt = bcrypt.genSaltSync();
    const hashedRefreshToken = bcrypt.hashSync(refreshToken, salt);
    await this.userRepository.update({ id: userId }, { hashedRefreshToken });
  }
}

AuthService 클래스의 생성자는 UserRepository와 JwtService 객체를 인자로 받습니다.

loginOrSignIn()는 UserDto 객체를 인자로 받아, 해당 사용자를 찾거나 새로운 사용자를 생성한 후, JWT 토큰을 생성합니다. 마지막으로 생성된 refresh token을 데이터베이스에 저장합니다.

refreshToken()는 Authorization 헤더로 넘어온 refresh token을 데이터베이스와 비교하여 인증하고 유저 정보로 JWT 토큰을 생성한 후, 새로운 refresh token을 해쉬하여 데이터베이스에 저장합니다.

findUserOrSave()는 UserDto 객체를 인자로 받아, 해당 사용자를 데이터베이스에서 찾거나 새로운 사용자를 생성합니다.

createToken()는 User 객체를 인자로 받아, access token과 refresh token을 생성하고 이를 반환합니다. access token은 1시간 후 만료되며, refresh token은 1일 후 만료됩니다.

updateHashedRefreshToken()는 User의 ID와 새로운 refresh token을 인자로 받아, 새로운 refresh token을 bcrypt 알고리즘을 사용하여 해싱하고, 데이터베이스 해싱된 값을 업데이트합니다

 

인증 절차 과정 이해하기

NestJS에서 인증 과정은 JwtStrategyAuthGuard 를 상속받아 구현한 JwtAuthGuard 에 의해 진행됩니다.

JwtStrategy는 클라이언트로부터 전달된 JWT를 검증하는 역할을 합니다. 검증에 성공하면, JwtStrategy는 검증된 페이로드(payload)를 request 객체의 user 프로퍼티에 저장합니다.

JwtAuthGuard는 NestJS에서 제공하는 라우터 가드(router guard) 중 하나로, HTTP 요청을 처리하기 전에 해당 요청이 인증된 사용자로부터 온 것인지 확인합니다. 내부적으로 JwtStrategy를 사용하여, 클라이언트로부터 전달된 JWT를 검증합니다.

따라서, 프로세스의 흐름은 다음과 같습니다.

  1. 클라이언트에서 HTTP 요청이 발생합니다.
  2. 해당 요청이 처리되기 전에 JwtAuthGuard가 요청을 가로챕니다.
  3. JwtAuthGuard는 요청의 헤더(header) 또는 쿼리(query) 매개변수(parameter)로부터 JWT를 추출합니다.
  4. JwtAuthGuard는 추출된 JWT를 JwtStrategy로 전달하여 검증합니다.
  5. JwtStrategy는 JWT를 검증하고, 검증된 페이로드(payload)를 사용자(user) 객체로 변환합니다.
  6. JwtAuthGuard는 검증된 사용자(user) 객체를 요청(request) 객체의 user 프로퍼티에 저장합니다.
  7. 라우터(router)는 JwtAuthGuard에서 인증된 사용자(user) 객체를 사용하여 요청을 처리합니다.

정리하자면 JwtAuthGuard는 요청의 유효성을 검증하고, 요청을 처리하기 전에 사용자(user) 인증을 수행합니다. JwtStrategy는 사용자(user) 객체를 추출하는 역할을 수행합니다.

 

JwtStrategy

import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtPayload } from '../dto/jwt-payload';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
  constructor(private readonly configService: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: configService.get('JWT_SECRET'),
    });
  }

  async validate(payload: JwtPayload): Promise<number> {
    const { userId } = payload;
    return userId;
  }
}

PassportStrategy 클래스를 확장하여 JwtStrategy 클래스를 정의합니다.

JwtStrategy 클래스는 configService를 주입받아서, secretOrKey 필드에 configService에서 가져온 JWT 비밀키를 설정하고, jwtFromRequest 필드에는 fromAuthHeaderAsBearerToken()를 통해 JWT 토큰을 요청 객체의 Authorization 헤더에서 추출하도록 설정합니다.

validate 메소드는 JWT 토큰에서 추출한 사용자 정보를 검증하는 메소드입니다. JwtPayload 타입의 payload 인자를 받아서, 해당 payload에서 userId 필드를 추출하여 반환합니다.

이러한 JwtStrategy 클래스는, JwtAuthGuard 와 함께 사용되어, JWT 인증을 위한 middleware로 작동합니다.

 

JwtAuthGuard

import {
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ExpiredTokenException } from './exception/expired-token.exception';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  canActivate(context: ExecutionContext) {
    return super.canActivate(context);
  }

  handleRequest(err, user, info) {
    if (info && info.name === 'TokenExpiredError') {
      throw new ExpiredTokenException();
    }
    if (err || !user) {
      throw err || new UnauthorizedException();
    }
    return user;
  }
}

@nestjs/passport 패키지에서 제공하는 AuthGuard 클래스를 확장한 JwtAuthGuard라는 NestJS 가드입니다. 이 가드는 Authorization 헤더에 유효한 JSON Web Token(JWT)이 필요한 들어오는 요청을 인증하는 데 사용됩니다.

AuthGuard() 에 타입정보로 jwt를 사용하면, 부모의 canActivate 메서드 내부에서 passport.authenticate('jwt')가 호출되어 JWT 인증이 수행됩니다. 이때, JwtStrategy 클래스가 사용되어 JWT 토큰의 검증과 사용자 정보의 추출이 수행됩니다.

handleRequest 메서드는 인증 프로세스의 결과를 처리하기 위해 구현되었습니다. 인증 전략에서 반환된 info 객체가 TokenExpiredError 이름을 포함하면 사용자 지정 ExpiredTokenException을 throw합니다. 인증이 실패하거나 오류가 발생하면 UnauthorizedExceptio*을 throw합니다. 인증이 성공하면 user 객체를 반환합니다.

직접 canActivate() 를 구현하여 상황에 맞는 예외처리를 할 수도 있습니다.

 

Controller

@Post('refresh-token')
  @UseGuards(JwtAuthGuard)
  async refreshToken(
    @Req() req,
    @GetUserId() userId: number,
  ): Promise<{ accessToken: string; refreshToken: string }> {
    const { authorization } = req.headers;
    return await this.authService.refreshToken(authorization, userId);
  }

가드를 적용하고자 하는 핸들러에 JwtAuthGuard 를 전달하면 핸들러 호출 전에 JwtStrategy의 validate함수가 실행되고 유효한 토큰이라면 request의 user 속성으로 사용자의 id가 바인딩됩니다.

 

커스텀 데코레이터로 유저 정보 가져오기

이전에 Strategy 의 validate 메소드 반환값은 request의 user속성으로 들어간다고 설명했습니다.

	@Get()
  @UseGuards(JwtAuthGuard)
  async getUserId(@Req() req): Promise<number> {
		retu req.user;
  }

매번 이렇게 request에서 필요한 값을 가져다가 사용해도 되지만 커스텀 데코레이터를 이용하여 핸들러의 인자로 넣어줄 수도 있습니다.

 

GetUserId 데코레이터

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

export const GetUserId = createParamDecorator(
  (data, ctx: ExecutionContext): number => {
    const req = ctx.switchToHttp().getRequest();
    return req.user;
  },
);

createParamDecorator 함수를 사용하여 GetUserId라는 파라미터 데코레이터를 만들었습니다. GetUserId 함수는 ExecutionContext와 데이터를 매개변수로 받습니다. ExecutionContext는 NestJS에서 HTTP 요청을 처리할 때 사용되는 실행 컨텍스트입니다.

ctx.switchToHttp().getRequest()를 사용하여 현재 HTTP 요청 객체(request object)를 가져온 다음, 해당 요청 객체의 user 속성에서 사용자 ID를 추출하여 반환합니다. 이렇게 반환된 사용자 ID는 요청 핸들러(request handler)에서 GetUserId 데코레이터가 적용된 매개변수로 전달됩니다.

  @Get()
  @UseGuards(JwtAuthGuard)
  async getUserId(@GetUserId() userId: number): Promise<number> {
    	return userId;  
  }

'Javascript > NestJS' 카테고리의 다른 글

[NestJS] HTTP 기본 제공 예외 클래스  (0) 2024.09.07
NestJS OAuth 인증 두번째  (0) 2023.04.23
NestJS OAuth 인증 첫번째  (1) 2023.04.23
NestJS cookie-parser import 에러  (0) 2023.03.29
NestJS Health Check API  (0) 2023.03.29