Nest.js Passport 없이 로그인 인증정보 받아오기

@beygee· February 19, 2022 · 8 min read

본 글은 Nest Docs Authentication를 토대로 공부하여 작성한 글입니다.

Overviews

Authentication은 대부분의 애플리케이션에서 필수입니다. 직접 인증/인가를 구현할 수 있지만, nest.js에서는 passport를 이용하여 쉽게 구현할 수 있습니다.

@nestjs/passport 라이브러리는 passportnest.js의 인테그레이션 모듈입니다.

이번 글에서는 Passport로 인증을 구현하는 법과, 커스텀으로 인증을 구현해야하는 상황을 알아보고 대처해보겠습니다.

Passport

passport는 다음 일련의 기능을 제공합니다.

  • 사용자의 credentials을 통해 인증여부를 파악합니다.
  • 인증 토큰을 발급하거나 상태를 session과 같은 저장소에 보존 및 관리할 수 있습니다.
  • credentials을 통해 추가 정보를 Request에 붙일 수 있습니다. (userId 등)

인증된 유저 정보 가져오기

독스 내용을 보시면 passport의 다양한 strategy(local, jwt 등)를 이용하여 credentials을 검증하고 CurrentUser로 유저의 정보를 가져옵니다.

다음 코드를 보며 확인해보겠습니다.

패스포트를 이용하기

먼저 jwt를 이용하기 위해 다음 라이브러리를 설치합니다.

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

그리고 AuthModuleJwtModule을 추가합니다.

import { PassportModule } from "@nestjs/passport"
import { JwtModule } from "@nestjs/jwt"

@Module({
  imports: [
    UsersModule,
    PassportModule,
    JwtModule.register({
      secret: "SECRET",
      signOptions: { expiresIn: "60s" },
    }),
  ],
  providers: [AuthService],
  exports: [AuthService],
})
export class AuthModule {}

이제 Request내의 Jwt토큰을 받으면 어떻게 Decode하고, Decode된 값을 어떻게 Request에 붙일지 전략을 설정해야합니다.

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), // 헤더 Bearer 토큰을 파싱합니다.
      ignoreExpiration: false, // 만료된 토큰이라면 에러를 반환합니다.
      secretOrKey: "SECRET",
    })
  }

  async validate(payload: any) {
    // 이 데이터가 req.user로 접근할 수 있게 합니다.
    return { userId: payload.sub, username: payload.username }
  }
}

이제 JwtStrategyAuthModule에 추가합니다.

import { PassportModule } from "@nestjs/passport"
import { JwtModule } from "@nestjs/jwt"

@Module({
  imports: [
    UsersModule,
    PassportModule,
    JwtModule.register({
      secret: "SECRET",
      signOptions: { expiresIn: "60s" },
    }),
  ],
  providers: [AuthService, JwtStrategy],
  exports: [AuthService],
})
export class AuthModule {}

이제 UseGuards 데코레이터를 통해 Decodejwt 유저 데이터를 가져올 수 있습니다.

@Controller()
export class AppController {
  constructor(private authService: AuthService) {}

  @UseGuards(LocalAuthGuard)
  @Post("auth/login")
  async login(@Request() req) {
    return this.authService.login(req.user)
  }

  // JwtStrategy를 통해 credentials를 해독합니다.
  // 만일 토큰이 유효하지 않다면 에러를 반환합니다.
  @UseGuards(AuthGuard("jwt"))
  @Get("profile")
  getProfile(@Request() req) {
    return req.user // Decode된 유저 정보는 req.user에 있습니다.
  }
}

여기까지가 공식 문서에 있는 내용을 발췌한 것입니다. 더욱 상세한 설명은 문서에서 확인하실 수 있습니다.

다만 한가지 문제점이 존재합니다. 유저가 로그인이던, 비로그인이던 같은 API를 사용해야하는 경우의 처리가 어렵습니다.

상황을 예시로 들겠습니다.

커머스에서 상품 상세페이지를 보여준다. 상세페이지에는 상품 좋아요 여부를 보여주어야한다. 로그인 유저면 Service에 좋아요 여부를 체크를 해야하고, 비로그인 유저면 좋아요 여부는 무조건 false이다.

아마 컨트롤러에서 코드는 대략 다음과 같을 겁니다.

@Controller()
export class ProductsController {
  constructor(private productsService: ProductsService) {}

  @Get(":id")
  getProfile(@Request() req, @Param("id", ParseIntPipe) productId: number) {
    const product = await this.productsService.findOneByProductId(productId)
    const user = req.user

    // 비로그인이면 false, 로그인이면 좋아요 여부 체크
    const isLike = !!user
      ? await this.productsService.checkIsLikeProduct(user.id, productId)
      : false
    return { ...product, isLike }
  }
}

하지만 이 코드는 문제가 있습니다. 여기에서 유저의 정보(req.user)를 가져오려면 ControllermethodUseGuards 데코레이터가 있어야만 합니다.

UseGuards를 통해 AuthGuard('jwt')를 적용하고, JwtStrategy를 실행하여 req.user 정보를 가져오기 때문이죠.

패스포트를 이용하지 않기

따라서 패스포트를 이용하지 않고도 인증하는 법을 알아보겠습니다.

기존에 nest.js 공식 문서에 나온 UseGuardsInterceptor, Pipe, Controller, Service 등 모두 결국 Middleware입니다.

각 미들웨어에 명칭을 부여하여 개발자가 비즈니스 로직을 구분하여 이용하기 쉽도록 만든 것이죠.

image2

우리는 이 순서도를 참고하여 UseGuards를 포함하여 다른 미들웨어를 이용하여 위 문제를 해결하겠습니다.

우선 문제를 해결하기 위해선 Request에 유저 정보를 붙이는 것과, 로그인 여부를 확인하는 단계를 나눌 필요가 있습니다.

passport처럼 로그인 여부를 확인 후, 유저 정보를 Request에 붙이는 것이 아니라 Request에 정보를 먼저 붙이고, 로그인 정보를 확인하는 것이죠.

이를 위해 Guard 이전에 실행되는 middleware 부분에서 Request에 정보를 붙이는 작업이 필요합니다.

import { Injectable, NestMiddleware } from "@nestjs/common"
import { Request, Response } from "express"
import { verifyToken } from "./auth.jwt.util"

@Injectable()
export class AuthTokenMiddleware implements NestMiddleware {
  // token을 decode후 req.user에 붙여서 넘어갑니다.
  // 만약 토큰이 없거나 유효하지 않으면 req.user에는 null 값이 들어갑니다.
  public async use(req: Request, res: Response, next: () => void) {
    req.user = await this.verifyUser(req)
    return next()
  }

  private async verifyUser(req: Request): Promise<number> {
    let user: User = null
    try {
      const { authorization } = req.headers
      const token = authorization.replace("Bearer ", "").replace("bearer ", "")
      const decoded = await verifyToken(token)
      user = decoded
    } catch (e) {}

    return user
  }
}

위처럼 미들웨어를 만든 후, 모든 라우팅에 해당 미들웨어를 지나가도록 합니다.

export class AppModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(AuthTokenMiddleware)
      .forRoutes({ path: "*", method: RequestMethod.ALL })
  }
}

이제 UseGuards 상관없이 모든 라우팅에서 req.user에 접근할 수 있습니다. 위에서 나왔던 상품 조회 API 코드가 정상 작동합니다.

그리고 UseGuards를 사용하면 비로그인 유저는 차단하는 로직을 작성합니다.

import {
  CanActivate,
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from "@nestjs/common"
import { Reflector } from "@nestjs/core"

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}
  public canActivate(context: ExecutionContext): boolean {
    const req = context.switchToHttp().getRequest()
    if (!req.user) throw new UnauthorizedException("권한이 없습니다")
    return true
  }
}

미들웨어에서 정보를 먼저 파싱해주니까 AuthGuard 로직이 간단해졌습니다.

References

@beygee
미션 달성을 위해 실험적인 도전부터 안정적인 설계까지 구현하는 것을 즐겨합니다.