본문 바로가기
Node.js/nest.js

[NestJS / JWT / Passport] 로그인 2. JWTStrategy

by kiwi_wiki 2021. 4. 28.
이전 글 : [NestJS / JWT / Passport] 로그인 1. LocalStrategy

 

저번 글에 이어 이번에는 jwt를 이용한 부분에 대해 정리하려 한다.

 

/jwt.strategy.ts

import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { AuthService } from '../services/auth.service';
import { JwtPayload } from './jwt.payload';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly authService: AuthService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: true, // token 만료 검증은 서버에서 따로 진행..
      secretOrKey: process.env.JWT_SECRET_KEY?  process.env.JWT_SECRET_KEY : 'dev',
    });
  }

  /*async validate(payload: JwtPayload) {
    console.log('validate')
    const user = await this.authService.validateUser(payload)
    if (!user) {
      throw new HttpException('Invalid token', HttpStatus.UNAUTHORIZED);
    }
    return user;
  }*/
}

 

LocalStrategy와 비슷하게 PassportStrategy를 상속받아서 작성한다.

constructor를 살펴보면

jwtFromRequest : ExtractJwt.fromAuthHeaderAsBearerToken()이라는 것은 jwt로 생성해서 클라이언트 측으로 보냈던 Token 값을 헤더에 Bearer Token 값으로 포함하여 호출해야 서버단에서 토큰을 받아 검사할 수 있다.

ignoreExpiration: true는 토큰이 만료되었는지 검사를 하게 되어있는데 만료되더라도 바로 strategy 단에서 에러로 리턴하지 않도록 설정해주는 값이다.

secretOrKey는 jwt 토큰을 생성할 때 사용되는 키인데 절대로 외부에 노출되면 안 되는 값이므로 환경변수나 config 로 빼서 사용하는 것을 권장한다.

 

처음에는 ignoreExpiration 값을 false로 설정하여 자동으로 accessToken 만료 시 서버 에러를 리턴하려고 했으나, 토큰의 verify에 따른 리턴을 제어하기 위해 true로 설정 후 JwtAuthGuard에서 제어해주었다.

 

/jwt-auth.guard.ts

import { Injectable, ExecutionContext, UnauthorizedException, HttpException, HttpStatus } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(private jwtService: JwtService) {
    super();
  }

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

    const { authorization } = request.headers;

    if (authorization === undefined) {
      throw new HttpException('Token 전송 안됨', HttpStatus.UNAUTHORIZED);
    }

    const token = authorization.replace('Bearer ', '')
    request.user = this.validateToken(token);
    return true;
  }

  validateToken(token: string) {
    const secretKey = process.env.JWT_SECRET_KEY ? process.env.JWT_SECRET_KEY : 'dev';

    try {
      const verify = this.jwtService.verify(token, { secret: secretKey });
      return verify;
    } catch (e) {
      switch (e.message) {
        // 토큰에 대한 오류를 판단합니다.
        case 'INVALID_TOKEN':
        case 'TOKEN_IS_ARRAY':
        case 'NO_USER':
          throw new HttpException('유효하지 않은 토큰입니다.', 401);

        case 'EXPIRED_TOKEN':
          throw new HttpException('토큰이 만료되었습니다.', 410);

        default:
          throw new HttpException('서버 오류입니다.', 500);
      }
    }
  }
}

AuthGuard('jwt')를 상속받아서 작성했다. canActivate 메서드를 통해 헤더에 포함되어 온 토큰값의 유효성을 검사했다.

jwtService.verify()를 통해 토큰의 상태를 검사할 수 있다.

(secretKey 외부 노출 주의하자...)

 

/user.controller.ts

@UseGuards(JwtAuthGuard)
  @Get('/User/Auth')
  public async auth(@Req() req: Request, @Res() res: Response) {
    const { authorization } = req.headers;

    // TOKEN 만료 검사
    this.authService
      .validateAccessToken(authorization, req.user['email'])
      .then((result) => {
        res
          .status(HttpStatus.OK)
          .json(ResponseBody.createResponseBody().setBody(result));
      })
      .catch((e) => {
        console.log(e);
        res
          .status(HttpStatus.OK)
          .json(
            ResponseBody.createResponseBody()
              .setResultCode(RESULT_CODE.ERROR_BUT_IGNORED)
              .setErrorCode(ERROR_CODE.DB_DATA_NOT_FOUND)
              .setErrorDesc(e),
          );
      });
  }

사용하는 방법은 LocalStrategy 와 동일하게 @UseGuards() 어노테이션을 사용하면 된다.

다른 점이라면 이미 JwtAuthGuard 에서 AuthGuard('jwt')를 상속받아서 작업한 것이 있으므로 @UseGuards(JwtAuthGuard)로 해두면 된다.

/User/Auth api 가 호출될 때 JwtAuthGuard의 canActivate()를 먼저 실행하고, 문제가 없다면 해당 api의 로직이 실행되게 된다.

해당 api는 client에서 페이지 이동 시마다 호출되는 것으로 로그인 한 유저의 토큰을 검사하여 해당 페이지에 접근 가능한 상태인지를 검사할 수 있게 구현했다.

 

/auth.service.ts

파라미터로 받은 accessToken를 decode 해서 해당 토큰의 만료 시간 값을 조회한 뒤 현재 시간과 비교해 3분보다 적게 남은 경우는 refreshToken을 통해 accessToken을 다시 갱신시켜줄 수 있도록 구현했다.

async validateAccessToken(accessToken: string, email: string) {
    const user = await this.userService.findOneByEmail(email);

    if (!user.isActive) {
      return {
        isAuth: false,
        isActive: user.isActive,
      };
    }

    // accessToken 이 만료 됐지만 refreshToken 은 기간중인 경우 Token 갱신
    const token = accessToken.replace('Bearer ', '');
    const decoded = this.jwtService.decode(token);

    // accessToken 기간 체크
    const tokenExp = new Date(decoded['exp'] * 1000);
    const now = new Date();

    // 남은시간 (분)
    const betweenTime = Math.floor(
      (tokenExp.getTime() - now.getTime()) / 1000 / 60,
    );

    // 기간 만료된 경우 || 기간 얼마 안남은 경우
    if (betweenTime < 3) {
      // refreshToken 통신 유도
      return {
        id: user.id,
        email: user.email,
        isAuth: true,
        isRefresh: true,
      };
    }

    return {
      id: user.id,
      email: user.email,
      isAuth: true,
      isRefresh: false,
    };
  }

로그인 시 응답 값으로 accessToken과 refreshToken을 함께 리턴하게 되어있는데, refreshToken은 accessToken 보다 만료 기간을 더 길게 설정하여 발급한 뒤 accessToken 보다 더욱 안전한 곳에 저장해야 한다. 보통은 db에 저장을 해둔다고 해서 나도 그렇게 했다.

그리고 클라이언트 측에서는 두 토큰을 모두 쿠키에 저장해 두고 api를 통신 시 헤더에 accessToken을 포함하여 호출했고, /User/Auth 통신의 응답 값 중 isRefresh 값이 true로 올 경우 토큰을 갱신시켜주는 api를 통신하도록 했다.

 

/user.controller.ts

@UseGuards(JwtAuthGuard)
  @Post('/User/Auth/Refresh')
  public async authRefresh(
    @Req() req: Request,
    @Body() dto: LoginUserDto,
    @Res() res: Response,
  ) {
    const { authorization } = req.headers;

    this.authService
      .refreshAccessToken(authorization, dto.email)
      .then((result) => {
        res
          .status(HttpStatus.OK)
          .json(ResponseBody.createResponseBody().setBody(result));
      })
      .catch((e) => {
        console.log(e);
        res
          .status(HttpStatus.OK)
          .json(
            ResponseBody.createResponseBody()
              .setResultCode(RESULT_CODE.ERROR_BUT_IGNORED)
              .setErrorCode(ERROR_CODE.DB_DATA_NOT_FOUND)
              .setErrorDesc(e),
          );
      });
  }

/User/Auth/Refresh api는 헤더에 accessToken 이 아닌 refreshToken 이 포함되어 호출된다.

 

/auth.service.ts

async refreshAccessToken(authorization: string, email: string) {
    const secretKey = process.env.JWT_SECRET_KEY
      ? process.env.JWT_SECRET_KEY
      : 'dev';
    const refreshToken = authorization.replace('Bearer ', '');
    const verify = this.jwtService.verify(refreshToken, { secret: secretKey });
    // refreshToken 만료 안된경우 accessToken 새로 발급
    if (verify) {
      const user = await this.userService.findOneByEmail(email);

      // db에 저장된 토큰과 비교
      if (user.token == refreshToken) {
        const token = this._createToken(user); // accessToken
        return {
          token: token,
          isAuth: true,
        };
      }
    }

    return {
      isAuth: false,
    };
  }

refreshToken의 만료 상태를 jwtService.verify()를 통해 검사하고, 만료되지 않았다면 db에 저장되어 있던 토큰 값과도 비교하여 새로운 accessToken을 발급해 리턴한다. 그러면 클라이언트 측에선 새로 발급받은 토큰을 받아서 기존의 만료된 accessToken값을 저장했던 쿠키 값을 경신시켜준 뒤 헤더에 포함하여 기존처럼 사용하면 된다.

 

기존의 모듈에도 추가해주었다.

/auth.module.ts

@Module({
  imports: [
    UserModule,
    PassportModule,
    JwtModule.register({
      secret: process.env.JWT_SECRET_KEY?  process.env.JWT_SECRET_KEY : 'dev',
      signOptions: {
        expiresIn: process.env.JWT_TOKEN_EXPIRES_IN? process.env.JWT_TOKEN_EXPIRES_IN : '3h',
      },
    }),
  ],
  controllers: [UserController],
  providers: [AuthService, JwtStrategy, LocalStrategy],
})

JwtModule.register()를 살펴보면

secret 값은 토큰을 생성할 때 사용하는 키이며, 역시나 절대 외부에 노출되면 안 되는 값이므로 주의해야 한다.

signOptions의 expireIn을 통해 토큰의 만료 기간을 설정해 줄 수 있다.

 

유저 모듈은 심플하다.

/user.module.ts

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '../entities/user.entity';
import { UserService } from '../services/user.service';

@Module({
  imports: [TypeOrmModule.forFeature([User])],
  providers: [UserService],
  exports: [UserService],
})
export class UserModule {}
728x90
반응형

'Node.js > nest.js' 카테고리의 다른 글

[NestJS / JWT / Passport] 로그인 1. LocalStrategy  (0) 2021.04.27