[NestJS] How to implement Refresh-Token using JWT?

DatQueue·2023년 5월 3일
13
post-thumbnail

시작하기에 앞서

오랜만에 작성하는 포스팅인 것 같다. 최근에 개인적인 일도 있고, 뭔가 쉬어가고 싶어서 천천히 공부를 하며 어떤 주제를 다뤄보며 좋을까 고민을 했었다. 그러던 와중, 예전부터 들어만보았지, 한 번도 다뤄본적이 없었던 "Refresh-Token"에 대해 NestJS로 구현해보면 좋을것 같다는 생각이 들었다.

NestJS는 물론이고, 흔히 서버 기술과 관련된 프레임워크를 다루다보면 처음으로 "인증(Authentication)"에 관한 내용을 접하게 된다. "인증"을 다루는데 있어서 흔히 "User Authentication(유저 인증)"을 처음 접하게 되고, 동시에 "로그인(Log-in) 구현"을 통해 이를 알아보게 된다.

여기서 우린 "세션(session)", "토큰(token)" 이란 개념을 인증 방법으로써 도입하게 되는데 이번 포스팅에선 토큰 방식의 인증(Token-based Authentication), 그 중에서도 JWT(Json Web Token)을 통해서 인증을 구현해보고자 한다.

단순히 "Access-Token"만으로 다뤄보진 않을 것이다. 해당 방법은 많은 강좌 혹은 블로그, 혹은 공식문서에도 기술되어있고 쉽게 구현해볼 수 있다. 본인 또한 일전에 JWT를 이용하여(오로지 Access-Token 기능을 통해) 유저 인증 기능을 구현해보았었다. (아래 링크 참조)


JWT 생성과 토큰 검증 시리즈


이번 포스팅에선 "Access-Token"만이 아닌, "Refresh-Token"을 도입함으로써 보안적 측면을 조금 더 강화한 인증을 구현해보고자 한다. JWT를 통해 "Refresh-Token"을 적용시키는 것은 물론이고, NestJS에선 이를 어떻게 다룰 수 있는지에 대해 알아볼 것이다.

본격적 내용에 들어가기에 앞서 언급하자면, 이번 포스팅에선 "어떻게 Refresh-Token을 구현해 내는가?"에 중점을 둘 것이다. "Refresh-Token"을 구현하였다고해서 보안적 측면에서의 모든 문제를 해결하였고, 궁극적인 방법에 이른 것은 절대 아니다. Refresh-Token이라도 허점은 분명히 존재하고, 보완적으로 추가해야할 부분들이 많다. 이러한 내용은 추후 다음 포스팅들에서 다뤄보도록 하겠다.

또한, 지금부터 기술하게 되는 내용 혹은 방법만이 전부는 아닐 것이므로 다양한 생각을 항상 열어둘 필요는 있을 것이다.


💥 Refresh Token ?

우리는 이번 글에선 "NestJS에서 Refresh-Token을 어떻게 구현해볼 수 있는가"에 중점을 맞출 것이므로, Refresh-Token에 대한 깊은 내용까진 설명하진 않을 것이다. 간단히 Refresh-Token은 무엇이고, 왜? 사용하는지에 대해서 알아보자.

> Refresh Token은 왜 등장하였는가?

"Refresh-Token"은 단어 뜻 그대로 "새로 고침 토큰"이다. 정의하는 사람에 따라 다르겠지만, 본인은 이것을 "Access-Token의 새로 고침"으로 생각하면 좋을 거 같았다.

우연히 아래와 같은 문구를 보았다.

"""
It's important to highlight that the access token is a bearer token.
"""

Access-Token은 Bearer-Token임을 강조하는 것이 중요하다는 뜻이다. 이 말이 의미하는게 무엇일까?

"Bearer-Token"은 인증된 사용자가 보호된 자원에 접근하는 데 사용되는 토큰이다. Bearer-Token은 인증된 사용자가 보호된 자원에 접근하는 데 사용되는 토큰이다. (Bearer-Token은 "Bearer"라는 프로토콜 헤더에 포함되어 서버로 전송된다) 즉, Access-Token은 Bearer-Token으로써, 식별 정보(identification artifact)대신에 보호된 자원에 액세스할 수 있는 자격 증명 정보(credential artifact)로 작용한다.

이렇게 Access-Token은 보호된 자원에 액세스하는 데 필요한 인증 수단이지만, 해당 토큰을 가지고 있는 사람이라면 "누구든지" 해당 자원에 접근할 수 있게된다. 이러한 특성 때문에 악의적인 사용자가 시스템을 침해하고 Access-Token을 훔쳐서 보호된 자원에 접근하는 것을 막기 위한 적절한 조치가 필요한 것이다.

이러한 "적절한 조치"중 하나로, 우리는 Access-Token의 "만료시간"짧게 설정하는 것이다. 액세스 토큰은 몇 시간 또는 며칠 단위로 정의된 짧은 시간 동안만 유효하게 하는 것이다. 이렇게 하면, 기존의 Access-Token이 탈취당하더라도 새로운 Access-Token만이 자원에 액세스할 수 있으므로, 궁극적인 해결은 아닐지라도 어느 정도 보안을 해준다.


하지만! 위와 같이 Access-Token의 만료시간을 짧게 설정할 경우 보안적 측면에선 좋겠지만, "유저 경험" 즉, UX 측면에선 굉장히 불편한 문제를 야기한다.

새로운 Access-Token을 받기 위해선, 해당 토큰이 만료될때마다 사용자에게 다시 로그인을 요구하게 된다. 만약 자주 사용하는 채팅 서비스 혹은 쇼핑몰 서비스를 이용할 시, 토큰의 만료에 따라 한 시간마다 계속해서 로그인을 해줘야한다고 생각해보자. 이렇게 계속 Acess-Token이 만료될 때마다 사용자에게 다시 로그인을 요구하는 것은 UX 측면에서 좋지 않은 것이 분명하다.

Access-Token의 "UX" 측면에서 이를 개선하기 위해 등장한 것이 "Refresh-Token"이라 할 수 있다. 서버에 저장된 Refresh-Token을 사용하여 새로운 Access-Token을 발급하고, 사용자에게 다시 로그인을 요구하지 않고 보호된 리소스에 액세스할 수 있도록 해준다.


> Mechanism of Refresh Token

앞서, Refresh-Token은 왜 등장하게 되었는가를 알아보았으니 이젠 Refresh-Token이 인증(+인가)의 과정에서 어떠한 매커니즘으로 동작하는지에 대해 간단히 알아보자.

아래는 직접 나타내본 Refresh-Token의 동작 매커니즘(진행 순서)이다.

세세한 설명은 굳이 언급하지 않아도 위의 이미지를 통해 진행되는 순서및 동작을 이해할 수 있을것이다.

이제 본격적 NestJS 코드를 통해 Refresh-Token을 구현해보자.


💥 NestJS에서 Refresh Token 구현하기

코드 설명에 들어가기에 앞서 JWT 토큰 기반의 인증 절차를 진행하는데 있어서 사용하게 될, Guard, StrategyPassportModule에 대한 세세한 내용은 생략하고 진행하겠다. (하지만 꼭 필요하고, 해당글을 읽는데 있어서 필요한 내용임은 분명합니다 __ 글이 너무 장황해질 것이므로 생략합니다)

> Login 기능 구현하기

회원가입 로직은 생략하고 진행해보자. 컨트롤러의 login 메서드를 먼저 확인해봄으로써 Refresh-Token이 login 과정에서 어떻게 생성되는지 확인해보자.

login 핸들러 함수 (AuthController)

// auth.controller.ts

  @Post('login')
  async login(
    @Body() loginDto: LoginDto,
    @Res({ passthrough: true }) res: Response,
  ): Promise<any> {
    const user = await this.authService.validateUser(loginDto);
    const access_token = await this.authService.generateAccessToken(user);
    const refresh_token = await this.authService.generateRefreshToken(user);
	
	// 유저 객체에 refresh-token 데이터 저장 
    await this.userService.setCurrentRefreshToken(refresh_token,user.id);

    res.setHeader('Authorization', 'Bearer ' + [access_token, refresh_token]);
    res.cookie('access_token', access_token, {
      httpOnly: true,
    });
    res.cookie('refresh_token', refresh_token, {
      httpOnly: true,
    });
    return {
      message: 'login success',
      access_token: access_token,
      refresh_token: refresh_token,
    };
  }
  • 클라이언트에서 입력한 로그인 데이터를 통해 검증한 유저객체 생성

  • 해당 유저객체를 통해 Access-tokenRefresh-token 생성

  • 해당 id의 유저에 해당하는 Refresh-token 데이터를 User 테이블의 특정 컬럼에 저장한다.

  • Bearer type으로써 토큰을 요청 헤더의 Authorization 필드에 담아 보낸다.

  • "express"의 response 객체의 cookie 메소드를 사용하여 쿠키를 클라이언트에게 전송한다. 로그인 시엔 access_token에 대한 쿠키와 refresh_token에 대한 쿠키를 둘 다 보내주게 되는데, 이때 옵션으로 httpOnlytrue로 설정한다. 이렇게 함으로써 Javascript 코드에서 쿠키에 접근할 수 없도록 보호한다. (Xss- Cross-site scripting에 대처)

  • 생성한 access_token과 함께 refresh_token을 응답으로 리턴한다.


이렇게 로그인 라우트 핸들러 함수가 어떤 과정을 지니는지 알아보았다. 이젠 해당 함수가 사용한 서비스 모듈의 메서드들을 하나씩 알아보자.


login에 필요한 메서드 (by AuthService)

// authService.ts

import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { User } from 'src/users/entities/users.entity';
import { UsersService } from 'src/users/users.service';
import { LoginDto } from './model/login.dto';
import { Payload } from './payload/payload.interface';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class AuthService {
  constructor(
    private readonly userService: UsersService,
    private readonly jwtService: JwtService,  
    private readonly configService: ConfigService,
  ) {}
  
  async validateUser(loginDto: LoginDto): Promise<User> {
    const user = await this.userService.findUserByEmail(loginDto.email);

    if (!user) {
      throw new NotFoundException('User not found!')
    }

    if (!await bcrypt.compare(loginDto.password, user.password)) {
      throw new BadRequestException('Invalid credentials!');
    }

    return user;
  } 

  async generateAccessToken(user: User): Promise<string> {
    const payload: Payload = {
      id: user.id,
      email: user.email,
      firstName: user.firstName,
      lastName: user.lastName,
    }
    return this.jwtService.signAsync(payload);
  }

  async generateRefreshToken(user: User): Promise<string> {
    const payload: Payload = {
      id: user.id,
      email: user.email,
      firstName: user.firstName,
      lastName: user.lastName,
    }
    return this.jwtService.signAsync({id: payload.id}, {
      secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
      expiresIn: this.configService.get<string>('JWT_REFRESH_EXPIRATION_TIME'),
    });
  }

}

Access-Token을 생성하는데 있어서 필요한 옵션값 (secret-key, expiresIn)들은 Refresh-Token과 다르게 직접 지정해주지 않았다.

Access-Token에 필요한 정보들은 AuthModule 단에서 JwtModule 설정을 통해서 동적으로 받도록 한다.

// auth.module.ts

@Module({
  imports: [
    TypeOrmModule.forFeature([User]),
    TypeOrmExModule.forCustomRepository([UsersRepository]),
    PassportModule.register({}),
    JwtModule.registerAsync({
      imports: [ConfigModule],
      useFactory: async (configService: ConfigService) => ({
        secret: configService.get<string>('JWT_ACCESS_SECRET'),
        signOptions: {
          expiresIn: configService.get<string>('JWT_ACCESS_EXPIRATION_TIME'),
        } 
      }),
      inject: [ConfigService],
    }),
    forwardRef(() => UsersModule),
  ],
  controllers: [AuthController],
  providers: [AuthService, UsersService, JwtRefreshStrategy, JwtAccessAuthGuard, JwtRefreshGuard],
})
export class AuthModule {}

또한, .env 파일 내부에서 jwt 토큰 생성에 필요한 시크릿 키와 만료시간을 보관해준다.

# JWT Options
JWT_ACCESS_SECRET=myaccesssecretkey
JWT_REFRESH_SECRET=myrefreshsecretkey

JWT_ACCESS_EXPIRATION_TIME=20000
JWT_REFRESH_EXPIRATION_TIME=1800000

우리는 @nestjs/config에서 제공하는 ConfigModuleConfigService를 통해서 동적으로 해당 옵션값들을 받아올 수 있다.

해당 ConfigModuleConfigServiceJwtModule 내부에서 사용하기 위해 꼭 imports를 통해 불러오고, 서비스를 의존성 주입해주는 것을 기억하자.


또한, JWT 토큰을 생성하는 과정에 있어서 Access-TokenRefresh-Token 값의 형태를 다르게 하였다.

 	// Access-Token 생성
    return this.jwtService.signAsync(payload);
 
	// Refresh-Token 생성
    return this.jwtService.signAsync({id: payload.id}, {
      // ~~
    });

Access-Token 생성시엔 Payload 객체를 온전히 받아와주었지만, Refresh-Token 생성시엔 Payloadid만 받아와주었다. 즉, Payload와 매핑한 온전한 유저 데이터를 전부 사용하지 않도록 하였다.


※ 참고 - "Payload"

export interface Payload {
  id: number;
  email: string;
  firstName: string;
  lastName: string;
}

Refresh-Token 값을 생성하는데있어 위와 같은 룰을 적용한 큰(혹은 정확한) 이유는 없지만, Refresh-Token엔 굳이 유저의 정보가 담겨져 있을 필요가 없다고 판단하였다.

즉, 해당 Refresh-Token이 어떠한 유저의 토큰인지를 알 수 있는 "식별자"(여기선 id값 선택) 정도만 포함시키도록 하였다. Refresh-Token이 노출되어도 해당 토큰에서 사용자의 정보를 모두 보여주게 되는것 보단, 보안상 유출의 위험이 줄어들 것이다.


✔ Insert Current-Refresh-Token to Database (UserService) <중요!!>

로그인을 통한 인증(Authentication)시에 생성하게 된 Refresh-Token을 데이터베이스에 저장시켜줘야한다. 우린 로그인 라우트 함수에서 아래와 같은 작업을 통해 수행해주었다.

// auth.controller.ts
// 첫 번째 인자는 생성한 refresh_token, 두 번째는 해당 유저의 id
await this.userService.setCurrentRefreshToken(refresh_token,user.id);

사용하게 된 setCurrentRefreshToken() 메서드는 유저 정보에 관한 처리이므로 UserService에서 작성해주었다.

  // user.service.ts
  async setCurrentRefreshToken(refreshToken: string, userId: number) {
    const currentRefreshToken = await this.getCurrentHashedRefreshToken(refreshToken);
    const currentRefreshTokenExp = await this.getCurrentRefreshTokenExp();
    await this.userRepository.update(userId, {
      currentRefreshToken: currentRefreshToken,
      currentRefreshTokenExp: currentRefreshTokenExp,
    });
  }

getCurrentHashedRefreshToken(), getCurrentRefreshTokenExp()를 통해 현재 Refresh-Token값과 해당 토큰의 만료시간을 받아온다.

  async getCurrentHashedRefreshToken(refreshToken: string) {
    // 토큰 값을 그대로 저장하기 보단, 암호화를 거쳐 데이터베이스에 저장한다. 
    // bcrypt는 단방향 해시 함수이므로 암호화된 값으로 원래 문자열을 유추할 수 없다. 
    const saltOrRounds = 10;
    const currentRefreshToken = await bcrypt.hash(refreshToken, saltOrRounds);
    return currentRefreshToken;
  }

  async getCurrentRefreshTokenExp(): Promise<Date> {
    const currentDate = new Date();
  	// Date 형식으로 데이터베이스에 저장하기 위해 문자열을 숫자 타입으로 변환 (paresInt) 
    const currentRefreshTokenExp = new Date(currentDate.getTime() + parseInt(this.configService.get<string>('JWT_REFRESH_EXPIRATION_TIME')));
    return currentRefreshTokenExp;
  }
  

또 하나 눈여겨 볼 부분은, 토큰을 저장할 때 typeormsave()를 통해 저장하는것이 아니라 update()를 해주도록 하였다.

그 이유를 설명하기 전에 먼저 User 테이블을 살펴보자.

// user.entity.ts

import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';

@Entity({name:'users'})
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  // ...

  @Column({ nullable: true })
  currentRefreshToken: string;

  @Column({ type: 'datetime', nullable: true })
  currentRefreshTokenExp: Date;
}

보기와 같이, currentRefreshTokencurrentRefrehTokenExp의 값은 null허용하도록 해주었다. 즉, default값은 null일 것이다.

이로 인해, 업데이트를 통해 null -> hashed token value -> null(logout)값을 반복해서 가지게 될 것이다. (만료시간 역시 마찬가지다)


> Guard 주입을 통한 유저 권한 확인하기

"Refresh-Token"을 통해 "Access-Token"을 재발급 받기에 앞서, 먼저 Access-Token을 통한 인가(Authorization)에 접근하는 코드를 구현해보자. 우린 이를 통해 조금 더 실용적이고 눈에 보이는 상황을 만들어 볼 수 있을 것이다.

authenticate 핸들러 함수 (AuthController)

// auth.controller.ts

  @Get('authenticate')
  @UseGuards(JwtAccessAuthGuard)
  async user(@Req() req: any, @Res() res: Response): Promise<any> {
    const userId: number = req.user.id; 
    const verifiedUser: User = await this.userService.findUserById(userId);
    return res.send(verifiedUser);
  }

authenticate url로 접근해 만약 가드를 통과하면 인증된 유저 객체를 반환하고, 그렇지 않을경우 가드에서 설정한 false 반환으로 인한 에러를 띄우도록 한다.

가드는 아래와 같다. 유저의 권한을 확인하는데 있어서, 오로지 Access-Token 만이 사용된다. Refresh-Token은 무관하다.


JwtAccessAuthGuard 구현

// jwt-access.guard.ts

import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";

@Injectable()
export class JwtAccessAuthGuard implements CanActivate {
  constructor(
    private jwtService: JwtService,
  ) {}
  async canActivate(
    context: ExecutionContext,
  ): Promise<any> {
    try {
      const request = context.switchToHttp().getRequest();
      const access_token = request.cookies['access_token'];
      const user = await this.jwtService.verify(access_token);
      request.user = user;
      return user;
    } catch(err) {
      return false;
    }
  }
}

일반적으로, 공식문서에서 PassportModule을 통한 JWT 인증 방법으로 "Guard(가드)"와 가드가 사용하게 되는 "Strategy(전략)"를 통해 구현할 것을 제시한다.

하지만, 너무 해당 방법에 얽매일 필요는 없다고 생각한다. 유저 인증을 하는 로직을 꼭 "Strategy"에서 작성해야할까? 그건 아니다. 우린 진작 authService에서 이를 구현해주었고, 또 충분히 서비스단에서 구현해줄 수 있다.

본인은, "Access-Token"에 있어서는 굳이 PassportModule을 사용할 필요가 없다고 생각하여 CanActivate를 확장하여 가드를 작성해주었다.

클라이언트의 요청 시, 쿠키에 담긴 토큰을 검증해주고 만약 해당 토큰이 유효하다면 해당하는 유저객체를 리턴하도록 한다.

request.user = user;

위에 작성된것 처럼, request의 user 프로퍼티와 우리가 jwtService.verify()를 통해 검증해준 user 객체를 동일시 해주는 작업이 중요!! 하다. (이를 수행하지 않는다면 컨트롤러의 라우트 핸들러 함수에서 req.userundefined로 받게 될 것이다...)

이렇게 작성된 JwtAccessAuthGuard를 통한 권한 인증의 테스트는 추후 포스트맨을 통한 테스트 부분에서 알아보도록 하고 다음 과정으로 넘어가자.


> Refresh Token 을 통한 Access Token 재발급

바로 위에서 언급하였다시피, 마지막에 포스트맨을 통한 전체 인증과정 진행을 보여주겠지만 일단은 앞선 로그인 과정에서 "Access-Token""Refresh Token"을 둘 다 응답받는다는 것을 알아두자.

JWT_ACCESS_EXPIRATION_TIME=20000

앞서 Access-Token의 만료시간을 위와 같이 20초(20000ms)로 설정해주었다. 즉, 20초가 지난다면 우린 만료시간 설정으로 인해 해당 Access-Token을 사용할 수 없게된다.

자, 이제 우리는 로그인 시 응답받은 refresh_token을 사용할 시간이다.


refresh() 핸들러 함수 (AuthController)

새로운 "access_token"을 받기 위한 라우트 핸들러 함수이다. Body(전문)에 앞서 로그인 시 부여받은 "refresh_token" 값을 전달해 새로운 access_token(newAccessToken)을 응답받을 것이다. 만약 오류가 생긴다면(잘못된 값, refresh_token의 만료시간 경과) 간단히 catch문을 통해 예외처리를 시켜준다.


※ 참고 - RefreshTokenDto

// refresh-token.dto.ts
import { IsNotEmpty } from "class-validator";

export class RefreshTokenDto {
  @IsNotEmpty()
  refresh_token: string;
}

// auth.controller.ts

  @Post('refresh')
  async refresh(
    @Body() refreshTokenDto: RefreshTokenDto,
    @Res({ passthrough: true }) res: Response,
  ) {
    try {
      const newAccessToken = (await this.authService.refresh(refreshTokenDto)).accessToken;
      res.setHeader('Authorization', 'Bearer ' + newAccessToken);
      res.cookie('access_token', newAccessToken, {
        httpOnly: true,
      });
      res.send({newAccessToken});
    } catch(err) {
      throw new UnauthorizedException('Invalid refresh-token');
    }
  }

AuthService에서 정의해준 refresh() 함수를 통해 새로운 newAccessToken을 얻게된다. 그 후, login() 시와 마찬가지로 새로운 토큰을 응답 헤더를 통해 클라이언트에게 Bearer type으로 보내주고, cookie에도 설정해준다.


refresh() 함수 (AuthService)

// auth.service.ts

  async refresh(refreshTokenDto: RefreshTokenDto): Promise<{ accessToken: string }> {
    const { refresh_token } = refreshTokenDto;

    // Verify refresh token
    // JWT Refresh Token 검증 로직
    const decodedRefreshToken = this.jwtService.verify(refresh_token, { secret: process.env.JWT_REFRESH_SECRET }) as Payload;

    // Check if user exists
    const userId = decodedRefreshToken.id;
    const user = await this.userService.getUserIfRefreshTokenMatches(refresh_token, userId);
    if (!user) {
      throw new UnauthorizedException('Invalid user!');
    }

    // Generate new access token
    const accessToken = await this.generateAccessToken(user);

    return {accessToken};
  }

AuthService에서 작성한 위의 refresh() 메서드의 경우엔 RefreshGuard를 통해 나타낼 수도 있겠지만, 서비스의 메서드를 호출하는 방식으로 직접 구현해보았다.

로그인 시 생성해 발급받은 refresh_tokenjwtService.verify()를 통해 올바른 JWT 형식인지 검증을 하고, Payload 타입으로써 단언하여준다.
이렇게 함으로써 decodedRefreshTokenPayload 객체를 준수한 타입을 가짐을 명시한다.

새로 발급받을 accessToken을 생성하는데에 있어서 우린 앞서 만들어준 generateAccessToken() 메서드를 그대로 사용할 것인데, 이때 인자로써 User 객체가 필요하다.

해당 user 객체는 단순히 불러오기 보단, 데이터베이스 내부 유저 테이블의refresh_token값과 / 요청 시 Body(전문)에 실어준 refresh_token값이 일치하는지의 과정을 거친 user 객체를 불러올 필요가 있다.
이 작업을 우린 UserServicegetUserIfRefreshTokenMatches() 메서드를 통해 아래와 같이 정의할 수 있다.

  // user.service.ts

  async getUserIfRefreshTokenMatches(refreshToken: string, userId: number): Promise<User> {
    const user: User = await this.findUserById(userId);
	
	// user에 currentRefreshToken이 없다면 null을 반환 (즉, 토큰 값이 null일 경우)
    if (!user.currentRefreshToken) {
      return null;
    }
	
    // 유저 테이블 내에 정의된 암호화된 refresh_token값과 요청 시 body에 담아준 refresh_token값 비교
    const isRefreshTokenMatching = await bcrypt.compare(
      refreshToken,
      user.currentRefreshToken
    );

	// 만약 isRefreshTokenMatching이 true라면 user 객체를 반환
    if (isRefreshTokenMatching) {
      return user;
    } 
  }

자, 이제 우린 이렇게 Refresh-Token을 통해 새로 발급받은 Access-Token으로 인증이 필요한 권한 (인가)에 접근할 수 있게 된다.


> Guard를 통한 Logout 구현

로그아웃은 nestjs에서 제시하는, PassportModule을 사용해 GuardStrategy로써 유저 권한을 검증할 것이다.

또한 로그아웃시엔, 로그인 시 생성되어 유저 테이블에 저장된 currentRefreshToken값을 null로 수정하고 access-token과 refresh-token에 해당하는 cookie를 모두 삭제하여준다.


logout 핸들러 함수 (AuthController)

  // auth.controller.ts

  @Post('logout')
  @UseGuards(JwtRefreshGuard)
  async logout(@Req() req: any, @Res() res: Response): Promise<any> {
    await this.userService.removeRefreshToken(req.user.id);
    res.clearCookie('access_token');
    res.clearCookie('refresh_token');
    return res.send({
      message: 'logout success'
    });
  }

JwtRefreshGuard - Guard

// jwt-refresh.guard.ts

import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtRefreshGuard extends AuthGuard('jwt-refresh-token') {}

JwtRefreshStrategy - Strategy

아래는 위의 JwtRefreshGuard가 사용하게 될 전략이다.

// jwt-refresh.strategy.ts

import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { UsersService } from "src/users/users.service";
import { Payload } from "../payload/payload.interface";
import { Request } from "express";
import { User } from "src/users/entities/users.entity";

@Injectable()
export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh-token') {
  constructor(
    private readonly userService: UsersService,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromExtractors([
        (request) => {
          return request?.cookies?.refresh_token;
        },
      ]),
      secretOrKey: process.env.JWT_REFRESH_SECRET,
      passReqToCallback: true,
    })
  }

  async validate(req: Request, payload: Payload) {
    const refreshToken = req.cookies['refresh_token'];
    const user: User = await this.userService.getUserIfRefreshTokenMatches(
      refreshToken,
      payload.id
    );
    return user;
  }
}

JwtRefreshStrategyPassportStrategy를 상속받은 클래스이다.

super() 내부에서 해당 Strategy를 상속받는데에 있어서 필요한 설정을 지정할 수 있다.

jwtFromRequest 옵션은 HTTP 요청에서 JWT를 추출할 위치를 지정한다. ExtractJwt.fromExtractos() 를 사용하여 request 객체의 쿠키에서 refresh_token 값을 추출하도록 설정하였다.

다음으로 중요한 부분은 passReqToCallbacktrue로 설정해야 한다는 것이다.

passReqToCallback 옵션은 validate 함수의 첫 번째 인자로 요청(request)객체를 전달할지 여부를 결정한다. 즉, 이 옵션을 true로 설정하면 validate 함수의 첫 번째 인자로 request 객체를 전달할 수 있다.

이에 따라, req.cookies['refresh_token'] 해당 코드를 사용할 수 있게 된다.

이렇게 요청 시 쿠키를 통해 서버로 전달해준 refreshToken 값이 DB의 테이블내에 저장된 currentRefreshToken과 일치하는지 검증하기 위해 우린 앞서 refresh()에서 다뤄준것과 동일하게 getUserIfRefreshTokenMatches() 함수를 사용하여 비교한다.

이처럼 우린 단순히 서비스 내부에서 토큰 검증을 해줄 수도 있지만, 위와 같이 Passport를 이용해 "Guard""Strategy"를 통해 검증해 줄 수도 있다.


다시, 컨트롤러에서 정의한 logout 함수를 확인해보면

  // auth.controller.ts

  @Post('logout')
  @UseGuards(JwtRefreshGuard)
  async logout(@Req() req: any, @Res() res: Response): Promise<any> {
    await this.userService.removeRefreshToken(req.user.id);
    res.clearCookie('access_token');
    res.clearCookie('refresh_token');
    return res.send({
      message: 'logout success'
    });
  }

아래와 같이 removeRefreshToken의 인자로써 req.user.id를 받는 것을 확인할 수 있다. req는 any 타입이지만, 우리는 가드와 해당 가드가 사용하는 Strategy를 통해 request의 프로퍼티로 user 객체를 불러올 수 있게 되었다.

따로 user.id를 불러오는 함수 혹은 객체를 호출할 필요없이 가드를 통해 깔끔하게 받아오게 된 것이다.

await this.userService.removeRefreshToken(req.user.id);

✔ remove refresh-token from user table - (UserService)

바로 위에서 언급한 removeRefreshToken 함수를 알아보자. 단순하다. 로그아웃 시 refresh-token과 관련된 데이터 값을 전부 null로 바꿔준다.

  // user.service.ts
  async removeRefreshToken(userId: number): Promise<any> {
    return await this.userRepository.update(userId, {
      currentRefreshToken: null,
      currentRefreshTokenExp: null,
    });
  }

> 💨 Postman을 통한 테스트

✔ login

로그인 성공 시에 "Access-Token"과 "Refresh-Token` 값을 받게된다.

쿠키에도 잘 저장된 것을 확인할 수 있다.


✔ login 후 유저 테이블 확인

✔ Access-Token을 통한 권한 인증

인증에 성공하였을 시 (가드를 통과하였을 시) 유저 데이터 반환

✔ Access-Token을 통한 권한 인증 - Access-Token의 만료 시간이 지났을 시

지정해준 토큰의 만료시간이 지나게 되면 가드를 통과하지 못하고 권한 접근 에러를 응답한다.

✔ Refresh-Token을 통한 Access-Token 재발급

앞서 로그인 시 부여받은 Refresh-Token 값을 통해 새로운 Access-Token을 부여받을 수 있다.

✔ 권한에 재접근 - 새로운 Access Token을 통해

새로 부여받은 Access-Token을 통해 다시 권한에 접근할 수 있다. 새로운 Access-Token은 쿠키와 헤더에 담겨 전달된다.

✔ logout - 토큰 삭제

로그아웃 요청이 성공된다면 위와 같이 쿠키가 전부 빈 것을 확인할 수 있다. 동시에 테이블의 토큰 값 및 만료시간 또한 null로 수정되어 있을 것이다.


생각정리

이번 포스팅에서 우린 보안적 측면과 UX(유저 경험)를 고려하여 "Refresh-Token"을 통한 인증을 구현해보았다.

Refresh-Token을 구현하는데 있어, 세밀한 작업까진 수행하지 않았지만 어떻게 해당 토큰을 관리하고 인가(Authorization)에 적용할 지에 관해 고민해보았다.

NestJS 공식문서에선 JWT 토큰을 통한 인증 구현법을 설명할 때, PassportModule을 통한 Guard와 Strategy 패턴으로써 해당 구현을 제시한다. 또한, NestJS를 통해 처음 인증을 구현할 때 찾아보게 될 여러 블로그들에서 해당 구현법을 제시한다.

물론 좋은 방법은 맞지만, 가드와 전략이 어떻게 소통하는지, 그리고 각각의 책임은 무엇인지에대해 모르고 사용할 경우 사용한것만 못하다는 생각이 들었다.

서비스를 통해 토큰 및 유저의 검증로직을 작성할 수 있지만, 조금 더 가독성있고 간결하고 재사용성을 고려한 코드를 만들기 위해 PassportStrategy-Pattern을 사용하게 된다. 이러한 이유를 확실히 알 필요가 있다.

바로 다음 포스팅이 될 진 모르겠지만, 단순 Refresh-Token 구현에서 그치지 않고 조금 더 심화있게 들어가보고 더 나은 유저 인증을 위한 내용을 다뤄보도록 할 예정이다.


※ 참고자료

Implementing refresh tokens using JWT


profile
You better cool it off before you burn it out / 티스토리(Kotlin, Android): https://nemoo-dev.tistory.com

12개의 댓글

comment-user-thumbnail
2023년 6월 10일

안녕하세요! 글을 너무 유익하게 잘 봐서 처음으로 댓글을 달아봅니다..ㅎㅎ
덕분에 Refresh Token을 NestJS에서 어떤식으로 적용하는지 잘 알 수 있었습니다!

공부 차원에서 제 Velog에 글쓴이님의 내용을 참고해서 정리하고 싶은데 괜찮으실까요?
출처는 포스팅 아래에 남겨놓도록 하겠습니다!

1개의 답글
comment-user-thumbnail
2023년 9월 8일

신입 개발자에게 한줄기 빛을.. 감사합니다

1개의 답글
comment-user-thumbnail
2023년 9월 18일

엄청 막혔었는데 덕분에 많이 얻어갑니다ㅠㅠ
근데 질문 하나 있는데
refresh_token을 암호화까지 한다음 만료일자랑 같이 디비에 저장하는 이유가 무엇인가요?
어차피 검증은
this.jwtService.verify(refreshToken, {
secret: this.configService.get('JWT_REFRESH_SECRET'),
}) as Payload;
이 함수로 가능하고 refresh_token이 탈취 당했을 경우 디비에서 조회해서 검증하더라도 verify랑 같은 결과라 예상됩니다. 제가 생각했을 때 메리트를 하나 고르자면 bcrypt의 해시함수로 인해 무차별대입공격(이거 맞나?)에 대해 내성이 생긴다 정도인데 제가 생각한게 맞을까요??

2개의 답글
comment-user-thumbnail
2023년 11월 1일

안녕하세요. 덕분에 크게 도움이 되었습니다.
한가지 궁금한게 있는데 DB에 저장된 refresh_token의 유효기간이 더 이상 유효하지 않을 경우(만료 일자 < 현재 일자) DB에서 다시 null로 주는 부분은 어떻게 구현할 수 있을까요?

1개의 답글
comment-user-thumbnail
2023년 11월 11일

안녕하세요, 정말 자세하게 설명해주신 포스트 덕분에 너무너무 큰 도움 되었습니다..
풀스택 개발을 하고 있는 학생인데 혹시 프론트측이 해야하는 역할과 의문점에 대해서도 질문을 드려도 될까요?

  1. 로그인 요청을 한다 (Post "/login").
  2. 요청의 응답 객체로 accessToken과 refreshToken을 전달받는다.
    (의문점1. 서버에서 두 토큰을 모두 쿠키에 저장해줬는데 응답객체로도 보내주고 있습니다. refreshToken은 나중에 refresh를 위해서 보내주는 것 같은데 accessToken까지 굳이 넘겨주는 이유가 무엇인가요? 그리고 refreshToken을 어떻게 저장을 해주는 것이 좋을 지 모르겠습니다. localStorage는 보안상 이유로 부적절하고 전역상태로의 저장은 새로고침 시 유실되기 때문에 의미가 없는 것 같습니다.)
  3. 사용자가 브라우저를 껏다가 다시 켜도, 로그인 사용자 정보를 유지하기 위해 기본적으로 인증 요청을 한다(Get "/authenticate"). 이때, HTTP쿠키에 들어있는 accessToken이 자동으로 같이 넘어간다. JWT AuthGuard에 의해 인증이 완료된다면 사용자 정보를 다시 불러와준다.
  4. 만약 JWT AuthGuard에 의해 인증이 만료되어 프론트로 false(error)가 반환되었다면 RefreshToken으로 리프레시 요청을 한다(Post "/refresh").
    (의문점2. "/refresh" 요청을 할때 refreshToken을 프론트에서 어떻게 넣어서 보내줘야 하는지 모르겠습니다. localStorage로 저장해놓고 가져와서 보내주는 방법밖에 떠오르지가 않네요..)
  5. 만약 4의 refresh 함수 실행도중 RefreshToken의 인증기간까지 만료되어 에러를 던진다면, 사용자에게 재로그인을 요청한다.

프론트쪽도 같이 하시는 지 몰라서 죄송하지만, 위와 같은 과정으로 작성해야하는 것이 맞는지 여쭈어보고 싶습니다..

1개의 답글