← 목록

Nest.js 백엔드 AuthGuard (인증) 가이드

작성: 2024년 02월 06일읽기: 약 10분

Nest.js에서는 Guard라는 개념을 통하여 인증을 구현하도록 권장하고 있습니다. Nest.js는 내부적으로 express기반의 웹 서버를 사용하기 때문에 Guard는 일종의 특수한 미들웨어(middleware)라고 할 수 있습니다.

Express.js에서 미들웨어(middleware)란 요청(request)를 받은 이후에 응답(response)를 보내기전 별도로 구현된 로직을 타는 중간단계의 로직입니다. 아래는 Express 미들웨어의 예제입니다.

const express = require("express");
const app = express();

app.use((req, res, next) => {
  console.log("요청을 받았습니다.", new Date());
  next();
})

Nest.js의 Guard는 미들웨어와 동일하지만 canActivate라는 함수로 Boolean값을 리턴하여 true면 요청 진행, false면 403 Unauthorized Exception을 발생하는 특수한 미들웨어입니다.

아래는 간단한 Guard예제 입니다.

@Injectable()
export class AuthGuard implements CanActivate {
  // Express.js 미들웨어와 다르게 ExecutionContext라는 파라미터를 제공하여 요청으로부터 어떤 응답을 할 것인지에 대한 정보까지 들어있는 실행 컨텍스트입니다.
  canActivate(context: ExecutionContext): boolean {
    // 원하는 로직을 구현합니다. (생략)
    
    // 요청을 승인합니다.
    return true;
  }
}

메타데이터 지정 및 사용

Nest.js에서는 라우터마다 원하는 메타데이터를 지정하고 ExecutionContext에서는 지정된 메타데이터를 읽어와서 사용할 수 있습니다. 공식문서에서 예제로나온 Public이라는 메타데이터를 지정하고 사용하는 예제를 설명하겠습니다.

만약에 애플리케이션 전역적으로 로그인이 필요하도록 AuthGuard를 지정해주고 싶지만, 특정 메소드는 로그인이 없어서 호출 가능하도록 예외처리를 하고싶다면 다음과 같이 설정합니다.

@Get()
@SetMetadata('public', true)
async test() {
  return true;
}

@SetMetadata 데코레이터는 키와 값을 받아 해당 라우터에 메타데이터로 지정합니다.

지정하였다면 AuthGuard에서 다음과 같이 메타데이터를 사용할 수 있습니다.

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}
  
  canActivate(context: ExecutionContext): boolean {
    // Reflector를 사용하여 키값에 대한 메타데이터를 추출합니다.
    const isPublic = this.reflector.get<boolean>('public', context.getHandler());
    
    if (isPublic) return true;
    else return false;
  }
}

실제 구현 가이드

Guard를 이용하여 실제적인 브라우저(클라이언트)와 인증을 공유하기 위해서는 다양한 방식이 있습니다. 예제는 액세스 토큰 / 리프레시 토큰 방식을 채택하였습니다. 자세한 설명은 다음 블로그글이 잘 정리되어있어 참조하였습니다.

AccessToken

액세스 토큰은 로그인한 유저 정보를 갖고 있는 서명된 토큰입니다. 일반적으로 짧은 만료시간을 가지고 있으며, 브라우저 쿠키등에 저장하여 어떤 유저가 요청을 하는지 확인하는 용도로 사용됩니다. 흔히 JWT를 사용하여 토큰화하고 해당 JWT를 로그인시 액세스 토큰 쿠키로 브라우저에 발급합니다.

Refresh Token

리프레시 토큰은 난수로 발급되어 서버 또는 데이터베이스에 유저 정보와 함께 저장됩니다. 일반적으로 액세스 토큰보다 긴 만료시간을 가지고 있습니다. 브라우저에서 액세스 토큰이 만료된 경우 재발급을 위해서 사용됩니다.

그렇다면, 모든 로그인 요청은 아래 4가지 케이스로 분리될 수 있습니다.

  1. 유효한 액세스 토큰, 리프레시 토큰을 모두 갖고 있는 경우 유효한 요청이기 때문에 별도의 조치 없이 Guard에서 요청을 승인합니다.
  2. 액세스 토큰만 만료된 경우 리프레시 토큰을 확인하여 유효한 경우 액세스 토큰을 재발급하고 Guard에서 요청을 승인합니다.
  3. 리프레시 토큰만 만료된 경우 액세스 토큰을 확인하여 유효한 경우 리프레시 토큰을 재발급하고 Guard에서 요청을 승인합니다.
  4. 두가지 토큰 모두 만료된 경우 모두 만료되었기 때문에 로그아웃 처리합니다.

만료시간에 관하여

액세스 토큰은 JWT 서명시 만료시간을 지정하여 JWT가 만료되도록 설정하며, 리프레시 토큰은 캐시에 저장시 TTL을 사용, DB에 저장시 refreshTokenExpiresIn 등의 칼럼을 사용하여 만료 시간을 지정합니다. 하지만 쿠키의 경우 실제 토큰이 만료되는 시간보다 길게 지정해줍니다. (위의 케이스를 모두 확인하기 위함입니다) 토큰 만료시간과 쿠키 만료시간이 동일한 경우 하나만 만료되어도 (쿠키가 없어지기 때문에) 4번 케이스에 해당됩니다.

코드 예제

다음은 실제 블루네스트에서 인증을 구현한 코드입니다 (상세한 로직은 의사코드로 대체하였습니다)

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(
  	private readonly reflector: Reflector,
    private readonly jwt: JwtService
  ) {}
  
  async canActivate(context: ExecutionContext): Promise<boolean> {
    // Context에서 Public에 대한 메타데이터 추출
    const isPublic = this.reflector.get<boolean>('IS_PUBLIC', context.getHandler());
    // Public 메타데이터가 지정된 API의 경우 별도의 인증이 필요 없기 때문에 승인합니다.
    if (isPublic) {
      return true;
    }
    
    // Context에서 요청(request), 응답(response)를 추출합니다. (Express의 req, res와 동일)
    const req = context.switchToHttp().getRequest();
    const res = context.switchToHttp().getResponse();
    
    // 요청 쿠키에서 액세스 토큰, 리프레시 토큰을 추출합니다.
    const accessToken = req.cookies["액세스 토큰 쿠키 키값"];
    const refreshToken = req.cookies["리프레시 토큰 쿠키 키값"];
    
    // 엑세스 토큰이 없는 경우
    if (!accessToken) {
      return false;
    }
    
    // CASE 1: 모든 토큰이 만료된 경우
    if (isAccessTokenExpired(accessToken) && isRefreshTokenExpired(refreshToken)) {
      return false;
    }
    // CASE 2: 액세스 토큰만 만료된 경우
    else if (isAccessTokenExpired(accessToken) && !isRefreshTokenExpired(refreshToken)) {
      // 만료된 액세스 토큰과 리프레시 토큰을 검증하여 신규 액세스 토큰을 발급합니다.
      const newAccessToken = renewAccessToken(accessToken, refreshToken);
      // 신규 액세스 토큰을 쿠키로 발급합니다.
      res.cookie("액세스 토큰 쿠키 키값", newAccessToken, {maxAge: 액세스_토큰_만료_시간 });
      return true;
    }
    // CASE 3: 리프레시 토큰만 만료된 경우
    else if (!isAccessTokenExpired(accessToken) && isRefreshTokenExpired(refreshToken)) {
      // 액세스 토큰과 만료된 리프레시 토큰을 검증하여 신규 액세스 토큰을 발급합니다.
      const newRefreshToken = renewRefreshToken(accessToken, refreshToken);
      // 신규 리프레시 토큰을 쿠키로 발급합니다.
      res.cookie("리프레시 토큰 쿠키 키값", newRefreshToken, {maxAge: 리프레시_토큰_만료_시간 });
      return true;
    }
    // CASE 4: 모든 토큰이 유효한 경우
    else {
      return true;
    }
  }
}

Guard의 실제 적용

Nest.js에서 Guard는 크게 3가지 레벨에서 지정할 수 있습니다.

  1. 애플리케이션 단위

    // main.ts에서 useGlobalGuards를 사용하여 전역으로 지정할 수 있습니다.
    async boostrap() {
      const app = await NestFactory.create(AppModule);
      app.useGlobalGuards(new AuthGuard());
    }
    
    // 또는 모듈에서 APP_GUARD 메타데이터를 지정하여 애플리케이션 단위로 지정할 수 있습니다.
    @Module({
      providers: [
        { provider: APP_GUARD, useClass: AuthGuard }
      ]
    })
    export class AppModule {}
    
  2. 모듈 단위

    // 특정 모듈에서만 사용할 수 있습니다.
    @Module({
      providers: [AuthGuard]
    })
    export class PostsModule {}
    
    // 특정 컨트롤러에서만 사용할 수 있습니다.
    @Controller('cats')
    @UseGuards(new AuthGuard())
    export class CatsController {}
    
  3. 메소드 단위

    @Controller()
    export class AppController {
      @Get()
      @UseGuards(AuthGuard)
      async getTest() {
        return true;
      }
    }