개발일지

[NestJS] 로그인 구현 #2 - Passport, JWT 본문

NestJS, Node.js/#01 Project - 투표 커뮤니티

[NestJS] 로그인 구현 #2 - Passport, JWT

lyjin 2022. 10. 27.

개념 정리

Passport

Passport는 Node.js에서 제공해주는 인증 미들웨어입니다. NestJS에서는 @nestjs/passport 모듈로써 NestJS 환경에 맞게 사용할 수 있도록 합니다.

 

Passport는 인증 방식에 따른 다양한 전략(Strategy)이 존재하며, 다양한 인증 단계를 표준 패턴으로 추상화하여 제공합니다. 먼저 @nestjs/passport를 사용하기 전 vanilla Passport 작동방식에 대해 알아봅시다.

1. 해당 전략에 적용할 옵션. 예를 들어 jwt strategy에서 token 서명에 필요한 secret key를 적용할 수 있습니다.
2. verify callback. 사용자의 존재여부 또는 자격 증명 유효성 검증 등이 이뤄집니다. 유효성 검증에 성공하면 user 객체를 반환하고 실패하면 null을 반환합니다.


@nestjs/passport는 PassportStrategy class를 확장하여 Passport 전략을 구성합니다.

  • sub class에서 super() 메소드 호출 시 선택적으로 옵션 객체를 전달 (1)
  • sub class의 validate() 메소드를 구현하여 verify callback 제공 (2)

 

Guard

Guard를 사용해 인증되지 않은 사용자에 대한 접근을 제한할 것입니다. @nestjs/passport 모듈에서는 AuthGuard를 제공합니다. 이 Guard는 적절한 Passport Strategy를 호출합니다.

 


JWT 패키지 설치

jwt 인증 방식과 passport를 사용하기 위해 필요한 패키지를 설치합니다.

 

$ npm install --save @nestjs/jwt passport-jwt
$ npm install --save-dev @types/passport-jwt

 

JWT 인증 구현하기

// users/jwt/jwt.strategy.ts

@Injectable()
export class JwtAccessStrategy extends PassportStrategy(
  Strategy,
  'jwt-access-token',
) {
  constructor(private readonly usersRepository: UsersRepository) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: 'access-secret-key', // 임시
    });
  }

  async validate(payload: JwtPayload) {
    ...
  }
}

 

위의 항목1에 해당하는 전략 옵션입니다.

  • jwtFromRequest: Request에서 JWT를 추출하는 방법. API 요청의 Authorization 헤더에 Bearer Token을 제공하는 표준 방식 사용.
  • ignoreExpiration: false 경우 JWT가 Passport 모듈에 만료되지 않았는지 확인하는 책임을 위임함. 즉, 경로에 만료된 JWT가 제공되면 요청이 거부되고 401 Unauthorized응답 전송함.
  • secretOrKey: token 서명 및 인증시 필요한 secret key. (대칭키 방식)

 

verify callback validate() 를 구현합니다.


jwt-strategy는 jwt를 검증하고 json으로 디코딩합니다. 그 후 validate 메소드의 매개변수(payload)로 전달합니다. payload 형태(JwtPayload)는 다음과 같습니다.

 

export type JwtPayload = {
  sub: number;
  nickname: string;
};

 

 

사용자의 존재여부를 판단한 뒤, 존재할 경우 해당 user 객체를 반환합니다.

 

  async validate(payload: JwtPayload) {
    const whereOption: WhereOptionByUserId = {
      id: payload.sub,
    };
    const user: Users = await this.usersRepository.findUserByWhereOption(
      whereOption,
    );

    if (!user) {
      throw new CustomException(UsersException.USER_NOT_EXIST);
    }

    const { password, ...userInfo } = user;

    return userInfo;
  }
}

 

JwtAccessGuard

JwtAccessStrategy를 호출 하는 guard를 생성합니다. 저는 jwt 검증 에러 처리를 더 세분화 하고 싶어 canActivate를 재정의했습니다.

 

// users/jwt/users.guard.ts

@Injectable()
export class JwtAccessGuard extends AuthGuard('jwt-access-token') {
  constructor(private readonly jwtService: JwtService) {
    super();
  }

  canActivate(context: ExecutionContext) {
    const request = context.switchToHttp().getRequest();
    const { authorization } = request.headers;

    if (authorization === undefined) {
      throw new CustomException(UsersException.TOKEN_NOT_EXISTS);
    }

    const accessToken: string = authorization.split(' ')[1];

    try {
      this.jwtService.verify(accessToken, {
        secret: 'access-secret-key',
      });
    } catch (error) {
      switch (error.message) {
        case 'jwt expired':
          throw new CustomException(UsersException.EXPIRED_TOKEN);
        default:
          throw new CustomException(UsersException.UNVERIFIED_ACCESS_TOKEN);
      }
    }

    return super.canActivate(context);
  }
}

 

JWT 생성하기

이제 users module에 PassportModule과 JwtModule을 등록해줍니다. JwtModule.register의 secret은 토큰 서명시 사용되는 secret key로 위에서 설정한 key와 동일해야합니다.

 

// users/users.module.ts

@Module({ 
  imports: [
    PassportModule.register({ defaultStrategy: 'jwt-access-token' }),
    JwtModule.register({
      secret: 'access-secret-key',
      signOptions: {
        expiresIn: '3m', //임시
      },
    }),
  ],
  ...
})

 

 

다시 service로 돌아가 access token, refresh token 생성하는 메소드를 구현합니다. jwt 관련된 클래스 TokenService class를 생성했습니다. JwtService@nestjs/jwt에 내장된 서비스로 jwt 생성 .sign() , 검증 .verify() 등의 기능을 제공해줍니다.

 

// users/users.service.ts

import { JwtService } from '@nestjs/jwt';

class TokenService {
  private readonly jwtService: JwtService;

  constructor(private readonly usersRepository: UsersRepository) {
    this.jwtService = new JwtService();
  }

  createAccessToken(payload: JwtPayload): string {
    const accessToken: string = this.jwtService.sign(payload, {
      secret: 'access-secret-key', // 임시
      expiresIn: '3m', // 임시
    });

    return accessToken;
  }

  async createRefreshToken(payload: JwtPayload): Promise<string> {
    const refreshToken: string = this.jwtService.sign(payload, {
      secret: 'refresh-secret-key',
      expiresIn: '10m',
    });

    // 암호화, 후에 설명
    const encryptRefreshToken: string = this._encryptRefreshToken(refreshToken);

    await this.usersRepository.createRefreshToken(
      payload.sub,
      encryptRefreshToken,
    );

    return encryptRefreshToken;
  }
}

 

 

refresh token은 생성 후 db에 저장해야합니다. users.repository에 token을 저장하는 메소드를 구현합니다. connect 옵션으로 user와 일대일 매핑해줍니다.

 

// users/users.repository.ts

  async createRefreshToken(
    userId: number,
    encryptedRefreshToken: string,
  ): Promise<RefreshTokens> {
    try {
      return await this.prisma.refreshTokens.create({
        data: {
          token: encryptedRefreshToken,
          user: {
            connect: {
              id: userId,
            },
          },
        },
      });
    } catch (error) {
      throw new CustomException(UsersException.REFRESH_TOKEN_EXIST);
    }
  }

 

 

완성된 signIn 서비스는 다음과 같습니다. 보안성을 위해 refresh token은 암호화한 뒤 response 해줍니다.

 

// users/users.service.ts

@Injectable()
export class UsersService {
  private readonly tokenService: TokenService;

  constructor(private readonly usersRepository: UsersRepository) {
    this.tokenService = new TokenService(usersRepository);
  }

  ...

  async signIn(data: SignInUserDto): Promise<SignIn> {
    const whereOption: WhereOptionByUserEmail = {
      email: data.email,
    };
    const user: Users = await this.usersRepository.findUserByWhereOption(
      whereOption,
    );

    if (!user) {
      throw new CustomException(UsersException.USER_NOT_EXIST);
    }
    await this._validatePassword(data.password, user.password, 'signIn');

    // 토큰 생성
    const payload: JwtPayload = { sub: user.id, nickname: user.nickname };

    const accessToken: string = this.tokenService.createAccessToken(payload);
    const encryptRefreshToken: string =
      await this.tokenService.createRefreshToken(payload);

    return { accessToken, refreshToken: encryptRefreshToken };
  }
}