Nest.js에서 JWT Guard 사용하기 (2)

Sinf·2022년 6월 29일
1

고민의 흔적

목록 보기
31/38
post-thumbnail

이전 포스팅에 이어, 토큰 발급 후 로그인이 필요한 API에서 어떻게 토큰을 검증하는지 살펴보도록 하자.

Guard

Express에서 Token을 검증하기 위해서 Middleware를 사용해 검증하는 방식을 사용했다.

Nest.js에서는 @UseGuards라는 데코레이터로 API에 접근에 대해 인증 과정을 제어할 수 있다.

Guard는 CanActivate 인터페이스를 구현한다.
boolean형을 반환하는 validate 메서드를 통해 접근을 허용하거나 거부한다.

허나, 지금은 Passport를 이용해 구현하기 때문에 직접 CanActivate를 구현하지 않고, PassportStrategy를 상속 받는 Strategy, 그리고 @nestjs/passport에 정의된 AuthGuard를 사용한다.

PassportStrategy를 사용하면 쉽게 JWT를 추출해 validate의 파라미터로 payload를 전달할 수 있다.

JWT Strategy

토큰에 대한 검증을 위한 passport strategy를 정의한다.

Access Token Strategy

먼저, Access Token에 대한 검증 단계이다.
GoogleStrategy와 같이 PassportStrategy를 상속받아 구현한다.

import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { JwtPayload } from '../../@types/auth';

@Injectable()
export class AtStrategy extends PassportStrategy(Strategy, 'jwt') {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: process.env.JWT_SECRET,
    });
  }

  async validate(payload: JwtPayload) {
    return payload;
  }
}

부모 생성자에 전달되는 파라미터는 JWT을 검증하는데 사용되는 옵션 값을 전달한다. 다음에서 확인할 수 있다.

export interface StrategyOptions {
    secretOrKey?: string | Buffer | undefined;
    secretOrKeyProvider?: SecretOrKeyProvider | undefined;
    jwtFromRequest: JwtFromRequestFunction;
    issuer?: string | undefined;
    audience?: string | undefined;
    algorithms?: string[] | undefined;
    ignoreExpiration?: boolean | undefined;
    passReqToCallback?: boolean | undefined;
    jsonWebTokenOptions?: VerifyOptions | undefined;
}

여기서, 토큰을 검증하기 위한 secretKey, 요청 객체에서 JWT을 어떻게 가져올 지에 대해서만 전달했다.

ExtractJwt는 passport-jwt에 정의되어 있는데, 내부에 JWT에 접근하기 쉽도록 함수가 정의되어 있다.

Refresh Token Strategy

Refresh Token에 대한 Strategy도 Access Token에 대한 Strategy와 비슷하다. 하지만 Refresh Token은 Access Token과 다르게 직접 Refresh Token에 접근할 필요가 있다. 그렇기 때문에 passReqToCallback 옵션을 통해 validate 메서드에서 요청 객체에 접근할 수 있도록 한다.

@Injectable()
export class RtStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: process.env.JWT_SECRET,
      passReqToCallback: true,
    });
  }

  validate(req: Request, payload: JwtPayload) {
    const refreshToken = req.get('authorization').split('Bearer ')[1];

    return {
      ...payload,
      refreshToken,
    };
  }
}

그리고 요청 객체에서 Refresh Token을 꺼내 전달한다. (유저가 가지고 있는 Refresh Token과 비교하기 위해)

AuthGuard

Access Token AuthGuard

이제, Access Token을 검증하는 AuthGuard를 필요한 API에 추가하면 된다.

@Post()
@UseGuards(AuthGuard('jwt'))
async createPost(
  @Body(ValidationPipe) dto: PostReq,
) {
  // ... req.user
}

전달되는 파라미터는, Strategy에서 정한 alias를 전달하면 된다.

Refresh Token AuthGuard

Refresh Token AuthGuard는 Access Token과 다르게 일반적으로 사용되는 것이 아니라, Access Token이 만료되었을 때, 클라이언트의 요청으로 Refresh Token을 검증하게 된다.

그렇기 때문에 Refresh Token을 통해 Access Token을 검증하는 API를 만들고, 해당 API에 사용한다.

@Post('refresh')
@UseGuards(AuthGuard('jwt-refresh'))
async refreshToken(@Req() req: Request, @Res() res: Response) {
  const { refreshToken, sub, email } = req.user as JwtPayload & {
    refreshToken: string;
  };

  const user = await this.userService.findByIdAndCheckRT(sub, refreshToken);

  const token = this.authService.getToken({ sub, email });

  res.cookie('access-token', token.accessToken);
  res.cookie('refresh-token', token.refreshToken);

  await this.userService.updateHashedRefreshToken(user.id, refreshToken);

  res.redirect('/');
}
  1. AuthGuard에서 클라이언트의 Refresh Token을 검증한다.
  2. 만료된 토큰이라면 다시 로그인해야 한다. (reject)
  3. 만료되지 않은 토큰이라면, 실제 유저가 가진 Refresh Token과 비교한다. (해시 값으로 저장된)
  4. 토큰이 맞다면, 다시 갱신된 토큰을 발급한다.

이제, 클라이언트에서 요청하는 것에 따라 서버는 토큰을 검증하고 API에 대한 접근을 통제할 수 있다.

Access Token과 Refresh Token

처음 JWT 기반의 인증방식을 구현했을 때, Access Token과 Refresh Token을 사용하는 것에 대해서 고려하지 않았다.

보안의 문제로 구현하는 것이 좋다고 판단했을 때, 어떤 방식으로 구현하는지 고민이 많았다.

개인적으로 클라이언트와 서버의 통신이 적을수록 좋다고 생각했기 때문에 처음 구현할 땐 한 번의 요청에서 Refresh Token까지 모두 확인하도록 구현했었다. 굉장히 복잡해보이는 로직이 완성되었다.

하지만 구글에서 AT, RT를 구현하는 방식을 찾아봤을 때, 대부분 AT 검증에 실패한 경우 서버의 실패 응답, RT에 대한 요청으로 2번의 요청을 보내도록 구현했다.

왜 이렇게 구현하는지 정확히는 모르겠지만, 요청이 1번에서 2번으로 늘어난다고 큰 시간 차이가 나지 않는 점, 매번 AT, RT가 요청에 담겨야 하는 점(AT 노출과 RT 노출이 동시에) 등으로 따로 사용하는 것 같았다.

이제 클라이언트에서는?

그럼 서버에서 구현된 JWT 인증을 클라이언트에서는 어떻게 사용할까?

profile
주니어 개발자입니다. 🚀

0개의 댓글