새소식

반응형
Back-end/NestJS

[NestJS | Docs] Passport 알아보기 (feat. authentication)

2024.02.20
  • -
반응형

1. Passport

Passportnode.js 인증 라이브러리커뮤니티에서 잘 알려져 있고 많은 운영 단계의 애플리케이션들에서 성공적으로 사용되고 있는 것 중에 하나입니다. 이 라이브러리를 Nest 애플리케이션과 통합하는 방법은 @nestjs/passport 모듈을 사용하는 것입니다. 고수준에서 Passport는 다음과 같은 몇가지 단계들을 실행합니다:

  • 'credentials'(예: 사용자 이름/비밀번호, JWT(JSON 웹 토큰) 또는 ID 공급자의 ID 토큰)을 확인하여 사용자를 인증합니다.
  • 인증 상태 관리(JWT와 같은 휴대용 토큰을 발급하거나 Express 세션을 생성함을 통해 관리)
  • 라우트 핸들러에서 추가 사용을 위해 인증된 사용자에 대한 정보Request 객체에 첨부합니다.

 

Passport에는 다양한 인증 메커니즘을 구현하는 풍부한 strategies 생태계가 있습니다. 그 개념은 단순하지만 선택할 수 있는 Passport strategies는 매우 다양하고 방대합니다. Passport는 이러한 다양한 단계를 표준 패턴으로 추상화하며, @nestjs/passport 모듈에서 이 패턴을 익숙한 Nest 구조로 감싸 표준화하였습니다.

이 포스팅에서는 이렇듯 강력하고 유연한 passport 모듈을 사용하여 RESTful API 서버를 위한 fully end-to-end 인증 솔루션을 구현해 볼 것입니다. 여기에 설명된 개념을 사용한다면 인증 체계를 커스터마이징하기 위해 어떠한 Passport 전략이든지 구현할 수 있을 것입니다.

 

1-1. Authentication requirements (인증 요구사항)

먼저 요구 사항을 정의해 보겠습니다.

 

클라이언트는 username과 password로 인증을 시작합니다. 인증이 완료되면 서버는 인증을 증명하기 위한 수단으로 후속 요청 시 인증 헤더의 Bearer 토큰에 담아 전송할 수 있는 JWT를 발급합니다. 또한 유효한 JWT가 포함된 요청에만 액세스할 수 있는 보호된 라우트를 생성합니다.

첫 번째 요구 사항인 사용자 인증부터 시작하겠습니다. 그런 다음 JWT를 발급하여 이를 확장해 나갈 것이고 마지막으로 요청에 유효한 JWT가 있는지 확인하는 보호된 라우트를 만들어 보겠습니다.

먼저 이를 위해 필요한 패키지를 설치해야 합니다. Passport는 username/password 인증 메커니즘을 구현하는 passport-local이라는 strategy을 제공하며, 이는 앞서 저희가 정의한 요구사항에 가장 적합합니다.

 

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

 

어떤 passport strategy를 선택하든 항상 @nestjs/passport 및 passport 패키지는 반드시 필요합니다. 그런 다음 구축하려는 특정 인증 전략을 구현한 전략별 패키지(예: passport-jwt 또는 passport-local)를 설치해줘야 합니다.

또한 위와 같이 @types/passport-local을 사용하여 모든 Passport 전략에 대한 타입 정의(type definition)를 설치할 수도 있으며, 이는 TypeScript 코드를 작성하는 동안 도움을 제공합니다.

 

 

1-2. Implementing Passport strategies (Passport 전략 구현하기)

이제 인증 기능을 구현할 준비가 되었습니다. 먼저 Passport 전략에 사용되는 프로세스에 대한 개요부터 살펴보겠습니다. Passport는 그 자체로 하나의 미니 프레임워크라고 생각하면 도움이 됩니다. 이 프레임워크의 장점은 인증 프로세스를 구현하는 전략에 따라 커스텀할 수 있는 몇 가지 기본 단계로 추상화할 수 있다는 것입니다.

 

사용자 정의 매개변수(일반 JSON 객체)와 콜백 함수 형태의 사용자 정의 코드를 제공하여 구성하고, 이를 Passport가 적절한 시점에 호출하기 때문에 프레임워크와 비슷하다고 볼 수 있습니다. 이 프레임워크를 Nest 스타일의 패키지로 감싸는 @nestjs/passport 모듈이 Nest 애플리케이션에 쉽게 통합할 수 있도록 도와줍니다. 아래에서는 @nestjs/passport를 사용하겠지만, 먼저 그 전에 vanila Passport는 어떻게 작동하는지 살펴보겠습니다.

 

vanila Passport 에서는 두 가지를 제공하여 전략을 구성합니다:

  1. 해당 전략에 특화된 options 묶음.
    • 예를 들어 JWT strategy에서는 토큰에 서명하기 위한 secret을 제공할 수 있습니다.
  2. verify callback.
    • 사용자 스토어(사용자 계정을 관리하는 곳)와 상호 작용하는 방법을 Passport에 알려주는 역할을 합니다. 여기에서 사용자가 존재하는지(및/또는 새 사용자를 생성하는지), 사용자의 자격 증명이 유효한지 등을 확인합니다.
    • Passport 라이브러리는 이 콜백이 유효성 검사에 성공하면 full user를 반환하고, 실패하면 null을 반환할 것을 기대합니다 (passport-local에서 실패는 사용자를 찾을 수 없거나 비밀번호가 일치하지 않는 경우로 정의됩니다.)

 

@nestjs/passport를 사용하면 PassportStrategy 클래스를 확장하여 패스포트 전략을 구성할 수 있습니다. 하위 클래스에서 super() 메서드를 호출하여 전략 option(위의 1번 항목)을 전달하고, 선택적으로 옵션 객체를 전달합니다. 하위 클래스에서 validate() 메서드를 구현하여 verify callback(위의 2번 항목)을 제공합니다.

AuthModule을 생성하고 그 안에 AuthService를 생성하는 것으로 시작하겠습니다:

$ nest g module auth
$ nest g service auth

 

AuthService를 구현하다보면 사용자 작업을 UsersService에 캡슐화하는 것이 유용하다는 것을 알게 될 것이기 때문에 해당 모듈과 서비스도 생성해 줍니다:

$ nest g module users
$ nest g service users

 

이렇게 생성된 파일의 기본 내용을 아래와 같이 바꿉니다. 예제 앱의 경우, UsersService는 단순히 하드코딩된 인메모리 사용자 목록과 사용자 이름으로 사용자를 검색하는 find 메서드를 관리합니다. 실제 앱에서는 여기에서 원하는 라이브러리(예: TypeORM, Sequelize, 몽구스 등)를 사용하여 사용자 모델과 지속성 계층을 구축할 수 있습니다.

// users/users.service.ts
import { Injectable } from '@nestjs/common';

// This should be a real class/interface representing a user entity
export type User = any;

@Injectable()
export class UsersService {
  private readonly users = [
    {
      userId: 1,
      username: 'john',
      password: 'changeme',
    },
    {
      userId: 2,
      username: 'maria',
      password: 'guess',
    },
  ];

  async findOne(username: string): Promise<User | undefined> {
    return this.users.find(user => user.username === username);
  }
}

 

UsersModule에서 필요한 유일한 변경 사항은 이 모듈 외부에서 볼 수 있도록 @Module 데코레이터의 exports 배열에 UsersService를 추가하는 것입니다(곧 AuthService에서 사용하게 될 것이기 때문입니다).

import { Module } from '@nestjs/common';
import { UsersService } from './users.service';

@Module({
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

 

인증 서비스는 사용자를 검색하고 비밀번호를 확인하는 작업을 수행합니다. 이를 위해 validateUser() 메서드를 생성합니다. 아래 코드에서는 편리한 ES6 스프레드 연산자를 사용하여 사용자 객체에서 비밀번호 속성을 제거한 후 반환합니다. 잠시 후 Passport 로컬 전략에서 validateUser() 메서드를 호출할 것입니다.

import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';

@Injectable()
export class AuthService {
  constructor(private usersService: UsersService) {}

  async validateUser(username: string, pass: string): Promise<any> {
    const user = await this.usersService.findOne(username);
    if (user && user.password === pass) {
      const { password, ...result } = user;
      return result;
    }
    return null;
  }
}

 

WARNING
물론 실제 애플리케이션에서는 비밀번호를 일반 텍스트로 저장하지 않습니다. 대신 salted one-way hash algorithm이 포함된 bcrypt와 같은 라이브러리를 사용합니다. 이 접근 방식을 사용하면 해시된 비밀번호만 저장한 다음 저장된 비밀번호를 수신 비밀번호의 해시된 버전과 비교하므로 사용자 비밀번호가 일반 텍스트로 저장되거나 노출되지 않습니다. 예제 앱은 단순하도록 유지하기 위해 이러한 절대적인 의무를 위반하여 일반 텍스트를 사용한 것이므로 실제 앱에서는 절대 이렇게 하면 안됩니다.

 

이제 AuthModule을 다음과 같이 UsersModule을 import하도록 수정합니다.

// auth/auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UsersModule } from '../users/users.module';

@Module({
  imports: [UsersModule],
  providers: [AuthService],
  controllers: [AuthController],
})
export class AuthModule {}

 

1-3. Implementing Passport local

이제 Passport local 인증 strategy을 구현할 수 있습니다. 인증 폴더에 local.strategy.ts라는 파일을 만들고 다음 코드를 추가합니다:

 

// auth/local.strategy.ts
import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private authService: AuthService) {
    super();
  }

  async validate(username: string, password: string): Promise<any> {
    const user = await this.authService.validateUser(username, password);
    if (!user) {
      throw new UnauthorizedException();
    }
    return user;
  }
}

 

passport strategy에 대하여 앞서 설명한 레시피를 따랐습니다. 위의 passport-local 사용 사례에서는 configuration 옵션이 없기 때문에 생성자에는 option 객체 없이 그저 단순한 super()를 호출합니다.

 

물론 super() 호출에 옵션 객체를 전달하여 passport 전략의 동작을 커스텀할 수 있다고 했었습니다. 이 예제에서 passport-local 전략은 기본적으로 요청 body로부터 username과 password라는 속성을 받기를 기대합니다.

options 객체를 전달함으로써 다른 속성 이름을 지정할 수가 있습니다(예: super({ usernameField: 'email' })).

 

 

또한 validate() 메서드도 구현하였습니다. 각 strategy에 대해 Passport는 적절한 strategy별 매개변수 세트를 사용하여 validate() 함수(@nestjs/passport에서 validate() 메서드로 구현됨)를 호출합니다. local startegy의 경우, Passport는 다음과 같은 시그니처를 가진 validate() 메서드를 기대합니다:

  • validate(username: string, password:string): any

대부분의 validate 작업은 AuthService에서 수행되기 때문에(UsersService의 도움과 함께) 이 메서드는 매우 간단하게 구성될 수 있게됩니다.

 

모든 Passport 전략의 validate() 메서드는 credentials이 표현되는 세부 사항만 다를 뿐 비슷한 패턴을 따릅니다. 사용자가 발견되고 credentials이 유효하면 해당 user가 반환되어 Passport가 작업(예: 요청 개체에 사용자 속성 생성)을 완료하고 요청 처리 파이프라인을 계속하는 식입니다.

 

그러나 만약 사용자를 찾을 수 없으면 예외가 발생하고 이를 exception layer에서 처리하도록 흘러갑니다.

 

일반적으로 각 전략의 validate() 메서드에서 중요하게 생각되는 유일한 차이점사용자가 존재하고 유효한지 확인하는 방식입니다.

 

예를 들어 JWT 전략에서는 요구 사항에 따라 디코딩된 토큰에 포함된 userId가 사용자 데이터베이스의 레코드와 일치하는지, 아니면 해지된 토큰 목록과 일치하는지 평가할 수 있습니다.

 

따라서 sub-classing하고 strategy별 validate을 구현하는 이러한 패턴은 일관성 있고 우아하게 확장 가능합니다.

이제 방금 정의한 passport 기능을 사용 가능하도록 AuthModule을 구성해야 합니다. auth.module.ts를 다음과 같이 업데이트합니다:

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UsersModule } from '../users/users.module';
import { PassportModule } from '@nestjs/passport';
import { LocalStrategy } from './local.strategy';

@Module({
  imports: [UsersModule, PassportModule],
  providers: [AuthService, LocalStrategy],
})
export class AuthModule {}

 

1-4. Built-in Passport Guards

Guard 챕터에서는 guard의 주요 기능인 요청을 라우트 핸들러가 처리할지 여부를 결정하는 기능에 대해서 설명했었습니다. 이 기능은 이곳에서 여전히 유효하며 이제부터 이 표준 기능을 이용해 볼 것입니다.

 

또한 @nestjs/passport 모듈을 사용하는 맥락에서 처음엔 다소 혼란스러울 수 있는 약간의 새로운 관점을 소개해 볼 것입니다.

 

인증 관점에서 앱이 두 가지 상태로 존재할 수 있다고 생각해 봅시다:

  • 사용자/클라이언트가 로그인하지 않은 상태(인증되지 않음)
  • 사용자/클라이언트가 로그인한 상태(인증됨)

 

첫 번째 경우(사용자가 로그인하지 않은 상태)에는 두 가지 기능을 수행해야 합니다:

  • 인증되지 않은 사용자가 액세스할 수 있는 라우트를 제한합니다(즉, 제한된 경로에 대한 액세스를 거부합니다). 보호된 라우트에 Guard를 배치하여 이 기능을 처리하는 데 익숙한 guard 기능을 사용할 것입니다. 예상할 수 있듯이 이 guard에서는 유효한 JWT가 있는지 확인할 것이기 때문에 나중에 JWT를 성공적으로 발급한 후 이 guard가 작동할 것입니다.
  • 이전에 인증되지 않은 사용자가 로그인을 시도할 때 인증 단계 자체를 시작합니다. 이 단계는 유효한 사용자에게 JWT를 발급하는 단계입니다. 잠시 생각해보면 인증을 시작하려면 username/password credentials을 POST해야 한다는 것인데, 그러면 우선 이를 처리하기 위한 POST /auth/login 경로를 설정해야 합니다. 그렇다면 이 라우트에서 정확히 어떻게 passport-local 전략을 호출할 것인가라는 질문이 생길 수 있습니다.

 

그 답은 간단합니다. 약간 다른 유형의 다른 Guard를 사용하면 됩니다. @nestjs/passport 모듈은 이 작업을 수행하는 내장된 가드를 제공합니다. 이 가드는 Passport strategy를 호출하고 위에서 설명한 단계(credential 가져오기, verify 함수 실행, user 속성 생성 등)를 시작합니다.

위의 2번 항목의 경우(로그인한 사용자)는 로그인한 사용자가 보호된 라우트에 액세스할 수 있도록 이미 설명한 표준 유형의 guard에 의존하기만 하면 됩니다.

 

1-5. Login route

strategy을 구축했으므로 이제 백지상태의 /auth/login 라우트를 구현하고 기본 제공 Guard를 적용하여 passport-local 플로우를 시작할 수 있습니다.

app.controller.ts 파일을 열고 그 내용을 다음과 같이 바꿉니다:

 

import { Controller, Request, Post, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Controller()
export class AppController {
  @UseGuards(AuthGuard('local'))
  @Post('auth/login')
  async login(@Request() req) {
    return req.user;
  }
}

 

UseGuards(AuthGuard('local'))는 passport-local 전략을 상속할 때 @nestjs/passport가 자동으로 제공하는 AuthGuard를 사용하고 있습니다.

 

이에 대해 더 자세히 살펴보겠습니다. Passport 로컬 전략의 기본 이름은 'local'입니다. 이 이름을 @UseGuards() 데코레이터에서 참조하여 passport-local 패키지에서 제공하는 코드와 연결시킬 수 있습니다. 이는 앱에 여러 개의 Passport 전략이 있는 경우, 호출할 전략을 분명히 명시하기 위해 사용됩니다(각 전략은 전략별 AuthGuard를 제공할 수 있음). 지금까지는 이러한 strategy가 하나뿐이었기 때문에 필요없을 수 있었겠지만, 이제 곧 두 번째 strategy를 추가할 예정이므로 지금부터는 명확하게 구분하기 위해서 꼭 필요합니다.

 

라우트를 테스트하기 위해 지금은 /auth/login 경로에서 간단히 사용자를 간단히 반환하도록 하겠습니다. 이를 통해 또 다른 Passport 기능을 시연할 수도 있습니다:

  • Passport는 validate() 메서드에서 반환한 값을 기반으로 사용자 객체를 자동으로 생성하고 이를 요청 객체에 req.user로 할당합니다. 나중에는 이러한 과정 대신 JWT를 생성하고 반환하는 코드로 대체할 것입니다.

API 라우트이므로 일반적으로 사용 가능한 cURL 라이브러리를 사용하여 테스트할 수 있습니다. UsersService에 하드 코딩된 모든 사용자 개체를 사용하여 테스트해봅시다.

$ # POST to /auth/login
$ curl -X POST http://localhost:3000/auth/login -d '{"username": "john", "password": "changeme"}' -H "Content-Type: application/json"
$ # result -> {"userId":1,"username":"john"}

 

이 방법은 잘 작동하긴 하지만 AuthGuard()에 전략 이름을 직접 전달하면 코드베이스에 magic string이 들어가게 되기 때문에 좋은 방법은 아닙니다. 이렇게 하는 것보단 아래와 같이 자체 클래스를 생성하여 해당 클래스를 전달하는 방식이 대부분의 경우 사용됩니다:

import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}

 

이제 /auth/login 라우트 핸들러의 UseGuards 인자로 방금 정의한 LocalAuthGuard를 사용할 수 있습니다:

@UseGuards(LocalAuthGuard)
@Post('auth/login')
async login(@Request() req) {
  return req.user;
}

 

1-6. JWT functionality

이제 인증 시스템의 JWT 파트로 넘어갈 준비가 되었습니다. 요구 사항을 다시 검토하고 구체화해 봅시다:

  • 사용자가 username/password로 인증할 수 있도록 허용하여 보호된 API 엔드포인트에 대한 후속 호출에서 계속 사용할 수 있는 JWT를 반환해야 합니다. 지금까지 이 요구 사항을 충족하기 위한 작업은 순조롭게 진행 중이었습니다. 이제 이를 잘 마무리하려면 JWT를 발급하는 코드를 작성해야 합니다.
  • Bearer 토큰으로 유효한 JWT의 존재를 기반으로 보호되는 API 경로를 만듭니다.

 

이제 JWT 요구 사항을 지원하기 위해 몇 가지 패키지를 더 설치해야 합니다:

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

 

@nestjs/jwt 패키지(자세한 내용은 여기를 참조하세요)는 JWT 조작에 도움이 되는 유틸리티 패키지입니다. passport-jwt 패키지는 JWT 전략을 구현하는 Passport 패키지이며 @types/passport-jwt는해당 패키지에 대한 TypeScript 타입 정의를 제공합니다.

POST /auth/login 요청이 어떻게 처리되는지 자세히 살펴봅시다. passport-local strategy에서는 기본 제공 AuthGuard를 사용하여 라우트를 데코레이트했었습니다. 

  1. 라우트 핸들러는 사용자가 유효성이 검사된 경우에만 호출됩니다.
  2. req 매개변수에는 user 속성(passport-local 인증 플로우 중에 Passport에 의해서 채워짐)이 포함되어 있습니다.

 

이를 염두에 두고 이제 마침내 실제 JWT를 생성하고 이 라우트를 통해 반환하는 작업을 진행해봅시다.

 

서비스를 깔끔하게 모듈화하기 위해 authService에서 JWT 생성을 처리할 것입니다. auth 폴더에서 auth.service.ts 파일을 열고 login() 메서드를 추가한 다음 그림과 같이 JwtService를 임포트합니다:

import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class AuthService {
  constructor(
    private usersService: UsersService,
    private jwtService: JwtService
  ) {}

  async validateUser(username: string, pass: string): Promise<any> {
    const user = await this.usersService.findOne(username);
    if (user && user.password === pass) {
      const { password, ...result } = user;
      return result;
    }
    return null;
  }

  async login(user: any) {
    const payload = { username: user.username, sub: user.userId };
    return {
      access_token: this.jwtService.sign(payload),
    };
  }
}

 

 

user 객체 속성의 부분 집합으로 JWT를 생성하는 sign() 함수를 제공하는 @nestjs/jwt 라이브러리를 사용하여access_token 속성을 하나만을 가진 간단한 객체로 반환합니다.

  • 참고: JWT 표준과 일관성을 유지하기 위해 sub라는 속성 이름을 선택하여 userId 값을 보관합니다. AuthService에 JwtService provider를 주입하는 것을 잊지 마세요.

 

이제 새로운 의존성을 가져오기 위해 AuthModule을 업데이트하고 JwtModule을 구성해야 합니다.

먼저 auth 폴더에 constants.ts를 만들고 다음 코드를 추가합니다:

export const jwtConstants = {
  secret: 'DO NOT USE THIS VALUE. INSTEAD, CREATE A COMPLEX SECRET AND KEEP IT SAFE OUTSIDE OF THE SOURCE CODE.',
};

 

이 변수를 사용하여 JWT 서명 및 확인 단계 간에 키를 공유할 것입니다.

 

이제 auth 폴더에서 auth.module.ts를 열고 다음과 같이 업데이트합니다:

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LocalStrategy } from './local.strategy';
import { UsersModule } from '../users/users.module';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './constants';

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

 

register()을 사용하여 구성 객체를 전달하여 JwtModule을 구성합니다.

이제 /auth/login 라우트를 업데이트하여 JWT를 반환할 수 있습니다.

import { Controller, Request, Post, UseGuards } from '@nestjs/common';
import { LocalAuthGuard } from './auth/local-auth.guard';
import { AuthService } from './auth/auth.service';

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

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

 

이제 cURL을 사용하여 경로를 다시 테스트해 보겠습니다. UsersService에 하드코딩된 모든 사용자 객체를 사용하여 테스트할 수 있습니다.

$ # POST to /auth/login
$ curl -X POST http://localhost:3000/auth/login -d '{"username": "john", "password": "changeme"}' -H "Content-Type: application/json"
$ # result -> {"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}
$ # Note: above JWT truncated

 

1-7. Passport JWT 구현하기

이제 마지막 요구 사항인, 요청에 유효한 JWT가 있어야 엔드포인트를 보호할 수 있는 기능을 만들어봅시다. 여기서도 Passport가 도움을 줍니다.

  • JWT로 RESTful 엔드포인트를 보호하기 위한 passport-jwt strategy을 제공합니다.

 

먼저 auth 폴더에 jwt.strategy.ts라는 파일을 만들고 다음 코드를 추가합니다:

// auth/jwt.strategy.ts
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { jwtConstants } from './constants';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: jwtConstants.secret,
    });
  }

  async validate(payload: any) {
    return { userId: payload.sub, username: payload.username };
  }
}

 

JwtStrategy에서는 앞서 설명한 모든 passport 전략에 대해 동일한 레시피를 따릅니다. 이 strategy은 약간의 초기화가 필요하므로 super() 호출에서 options 객체를 전달하여 초기화를 수행해야 합니다. 사용 가능한 옵션에 대한 자세한 내용은 여기에서 확인할 수 있습니다.

 

위에서 설정한 옵션은 다음과 같습니다:

  • jwtFromRequest: Request로부터 JWT를 추출하는 메서드를 제공합니다. API 요청의 Authorization 헤더에 Bearer 토큰을 제공하는 표준 방식을 사용합니다. 다른 옵션은 여기에 설명되어 있습니다.
  • ignoreExpiration: 명확히 하기 위해 기본값인 false 설정을 선택하여 JWT가 만료되지 않았는지 확인하는 책임을 Passport 모듈에 위임할 수 있습니다. 즉, 만료된 JWT가 경로에 제공되면 요청이 거부되고 '401 Unauthroization' 응답이 전송됩니다. Passport는 이를 편리하게 자동으로 처리합니다.
  • secretOrKey: 토큰 서명을 위해 대칭 secret을 제공하는 편리한 옵션입니다. PEM-encoded public key와 같은 다른 옵션은 운영 단계의 앱에 더 적합할 수 있습니다(자세한 내용은 여기를 참조하세요). 어떤 경우든 앞서 주의한 대로 이 secrete 키를 공개적으로 노출하지 않도록 조심해야 합니다.

 

validate() 메서드에 대해 조금 추가 설명을 하도록 하겠습니다.

 

 

JWT 전략의 경우 Passport는 먼저 JWT의 서명을 확인하고 JSON을 디코딩하는 과정을 수행합니다. 그런 다음 디코딩된 JSON을 단일 매개 변수로 전달하는 validate() 메서드를 호출합니다. 이 메서드를 통해 JWT 서명이 작동하는 방식에 따라 이전에 서명하고 유효한 사용자에게 발급한 유효한 토큰을 수신하고 있음이 보장이 됩니다.

이 모든 것의 결과로 validate() 콜백에 대한 응답은 간단합니다. userId 및 username 속성이 포함된 객체를 반환합니다. Passport는 validate() 메서드의 반환값을 기반으로 user 객체를 생성하고 이를 Request 객체의 속성(ex. req.user)으로 첨부합니다.

 

또한 이 접근 방식은 프로세스에 다른 비즈니스 로직을 삽입할 수 있는 여지('후크')를 남긴다는 점에서 주목할만 합니다. 예를 들어, validate() 메서드에서 데이터베이스 조회를 수행하여 사용자에 대한 더 많은 정보를 추출하여 Request에서 더 풍부한 user 객체를 사용할 수 있습니다.

 

또한 해지된 토큰 목록에서 userId를 조회하는 등 추가적인 토큰 유효성 검사를 수행하여 토큰 해지를 수행할 수도 있습니다. 아래 샘플 코드에서 구현한 모델은 빠른 "stateless JWT" 모델로, 각 API 호출은 유효한 JWT의 존재 여부에 따라 즉시 승인되며 요청자에 대한 약간의 정보(사용자 ID 및 사용자 이름)를 요청 파이프라인에서 사용할 수 있습니다.

 

자 그럼 이제 구현을 해봅시다. 먼저 AuthModule에 새 JwtStrategy를 공급자로 추가합니다:

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LocalStrategy } from './local.strategy';
import { JwtStrategy } from './jwt.strategy';
import { UsersModule } from '../users/users.module';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './constants';

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

 

JWT에 서명할 때 사용한 것과 동일한 secret을 가져옴으로써 Passport에서 수행하는 확인 단계와 AuthService에서 수행하는 서명 단계가 공통 secret을 사용하도록 보장합니다.

마지막으로 기본 제공 AuthGuard를 확장하는 JwtAuthGuard 클래스를 정의합니다:

// auth/JwtAuthGuard
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

 

1-8. Implement protected route and JWT strategy guards

이제 보호된 라우트와 연관된 Guard를 구현할 것입니다.

app.controller.ts 파일을 열고 아래와 같이 업데이트합니다:

import { Controller, Get, Request, Post, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { LocalAuthGuard } from './auth/local-auth.guard';
import { AuthService } from './auth/auth.service';

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

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

  @UseGuards(JwtAuthGuard)
  @Get('profile')
  getProfile(@Request() req) {
    return req.user;
  }
}

 

여기서도 다시 한 번, passport-jwt 모듈을 구성할 때 @nestjs/passport 모듈이 자동으로 제공하는 AuthGuard를 적용하고 있습니다. 이 Guard는 해당 strategy의 default 이름인 jwt로 참조됩니다. GET /profile 라우트에 도달하면 Guard가 자동으로 passport-jwt 커스텀 구성 전략을 호출하고 JWT의 유효성을 검사한 다음 user 속성을 Request 개체에 할당합니다.

테스트를 위해 앱이 실행 중인지 확인하고 cURL을 사용하여 경로에 요청을 보냅니다.

$ # GET /profile
$ curl http://localhost:3000/profile
$ # result -> {"statusCode":401,"message":"Unauthorized"}

$ # POST /auth/login
$ curl -X POST http://localhost:3000/auth/login -d '{"username": "john", "password": "changeme"}' -H "Content-Type: application/json"
$ # result -> {"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2Vybm... }

$ # GET /profile using access_token returned from previous step as bearer code
$ curl http://localhost:3000/profile -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2Vybm..."
$ # result -> {"userId":1,"username":"john"}

 

인증 모듈에서는 JWT의 만료가 60초가 되도록 구성했습니다. 이는 너무 짧은 만료 시간일 수 있는데, 토큰 만료 및 새로 고침에 대한 세부 사항을 다루는 것은 이 포스팅에서 다루지 않을 것입니다.

  • 이는 JWT의 중요한 품질과 passport-JWT 전략을 보여주기 위해 이렇게 설정한 것임을 말씀드립니다.

 

이를 확인하기 위해 인증 후 60초를 기다렸다가 GET /profile 요청을 다시 시도하면 401 Unauthorization 응답을 받게 될 것인데, 이는 Passport가 자동으로 JWT의 만료 시간을 확인해주기 때문에 애플리케이션에서 이를 확인하는 수고를 덜어준 것입니다.

이제 모든 JWT 인증 구현을 완료했습니다. 이제 JavaScript 클라이언트(예: Angular/React/Vue) 및 기타 JavaScript 앱은 우리의 API 서버와 안전하게 인증하고 통신할 수 있게 되었습니다.

 

1-9. Extending guards (guard 확장)

대부분의 경우 제공된 AuthGuard 클래스를 사용하는 것으로 충분합니다.

 

그러나 기본 에러 핸들링이나 인증 로직을 단순히 확장하고자 하는 사용 사례가 있을 수 있습니다. 이러한 경우에는 기본 제공 클래스를 확장하고 하위 클래스 내에서 메서드를 재정의하는 방식으로 진행하면 됩니다.

 

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

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  canActivate(context: ExecutionContext) {
    // Add your custom authentication logic here
    // for example, call super.logIn(request) to establish a session.
    return super.canActivate(context);
  }

  handleRequest(err, user, info) {
    // You can throw an exception based on either "info" or "err" arguments
    if (err || !user) {
      throw err || new UnauthorizedException();
    }
    return user;
  }
}

 

기본 에러 핸들링 및 인증 로직을 확장하는 것 이외에도 인증이 일련의 전략을 거치도록 할 수 있습니다.

 

첫 번째 strategy가 succeed(성공), redirection, error 중 하나가 발생하면 체인이 중단됩니다.

 

인증 실패가 각 strategy를 통해 연쇄적으로 진행되다가 결국 모든 strategy가 실패하면 최종적으로 인증이 실패하게 됩니다.

export class JwtAuthGuard extends AuthGuard(['strategy_jwt_1', 'strategy_jwt_2', '...']) { ... }

 

1-10. Enable authenticaation globally

대부분의 엔드포인트를 기본적으로 보호해야 하는 경우 인증 가드를 global guard로 등록하고 각 컨트롤러 위에 @UseGuards() 데코레이터를 사용하는 대신 어떤 경로를 공개해야 하는지를 설정하는 것과 같이 간단히 플래그를 지정하는 방식도 있습니다.

먼저, 다음 구성을 사용하여 JwtAuthGuard를 gloabl guard로 등록합니다(아무 모듈에서):

providers: [
  {
    provide: APP_GUARD,
    useClass: JwtAuthGuard,
  },
],

 

이 설정이 완료되면 Nest는 모든 엔드포인트에 JwtAuthGuard를 자동으로 바인딩합니다.

이제 라우트를 공개(public)로 선언하는 메커니즘을 제공해야 합니다. 이를 위해 SetMetadata 데코레이터 팩토리 함수를 사용하여 사용자 정의 데코레이터를 만들 수 있습니다.

 

import { SetMetadata } from '@nestjs/common';

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

 

위 파일에서 두 개의 상수를 내보내고 있습니다. 하나는 IS_PUBLIC_KEY라는 메타데이터 키이고, 다른 하나는 Public이라고 부르는 새 데코레이터 그 자체입니다(프로젝트에 맞게 SkipAuth 또는 AllowAnon으로 지정할 수 있습니다).

이제 커스텀 @Public() 데코레이터가 생겼으므로 다음과 같이 모든 메서드를 데코레이션하는 데 사용할 수 있습니다:

@Public()
@Get()
findAll() {
  return [];
}

 

마지막으로, "isPublic" 메타데이터가 발견되면 참을 반환하도록 JwtAuthGuard가 필요합니다. 이를 위해 Reflector 클래스를 사용합니다.

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

  canActivate(context: ExecutionContext) {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) {
      return true;
    }
    return super.canActivate(context);
  }
}

 

1-11. Request-scoped strategies

passport API는 라이브러리의 전역 인스턴스에 strategy을 등록하는 것을 기본으로 동작합니다.

 

그렇기 때문에 strategy들은 request에 의존하는 옵션을 갖거나 요청별로 동적 인스턴스화되도록 설계되지는 않았습니다.

 

request-scoped로 strategy을 구성해버리면 이는 특정 라우트에 묶이지 않게 되기 때문에 Nest가 Strategy를 인스턴스화하지 않습니다. 그래서 요청별로 어떤 "request-scoped"의 strategy를 실행해야 하는지를 물리적으로 결정할 수 있는 방법은 없습니다.

하지만 해당 strategy 내에서 request-scoped로 지정된 provider를 동적으로 해결하는 방법은 있습니다. 이를 위해 module refererence 기능을 활용합니다.

먼저 local.strategy.ts 파일을 열고 일반적인 방법으로 ModuleRef를 삽입합니다:

constructor(private moduleRef: ModuleRef) {
  super({
    passReqToCallback: true,
  });
}

 

위와 같이 passReqToCallback 구성 속성을 true로 설정해야 합니다.

다음 단계에서는 새 context 식별자를 생성하는 대신 request 인스턴스를 사용하여 현재 context 식별자를 가져옵니다.

이제 LocalStrategy 클래스의 validate() 메서드 내에서 ContextIdFactory 클래스의 getByRequest() 메서드를 사용하여 request 객체를 기반으로 context ID를 생성하고 이를 resolve() 메서드 호출에 전달합니다:

 

async validate(
  request: Request,
  username: string,
  password: string,
) {
  const contextId = ContextIdFactory.getByRequest(request);
  // "AuthService" is a request-scoped provider
  const authService = await this.moduleRef.resolve(AuthService, contextId);
  ...
}

 

위의 예제에서 resolve() 메서드는 AuthService provider의 request-scoped 인스턴스를 비동기적으로 반환합니다(AuthService가 request-scoped provider 로 표시되어 있다고 가정했습니다).

 

1-12. Customize Passport

모든 표준 passport의 커스텀 옵션은 register() 메서드를 사용하여 똑같은 방식으로 전달할 수 있습니다.

 

사용 가능한 옵션은 구현 중인 전략에 따라 다를 것입니다.

PassportModule.register({ session: true });

 

생성자에서 strategy에 option 객체를 전달하여 구성할 수도 있습니다. 예를 들어, local strategy의 경우 다음과 같이 전달할 수 있을 것입니다:

 

constructor(private authService: AuthService) {
  super({
    usernameField: 'email',
    passwordField: 'password',
  });
}

 

속성 이름들은 여기에서 확인하실 수 있습니다.

 

1-13. Named strategies

strategy를 구현할 때 PassportStrategy 함수에 두 번째 인수를 전달하여 전략의 이름을 지정할 수 있었습니다.

 

이렇게 하지 않으면 각 strategy에는 기본으로 지정된 이름으로 정해집니다(예: jwt-strategy의 경우 'jwt'):

 

export class JwtStrategy extends PassportStrategy(Strategy, 'myjwt')

 

이렇게 하면 @UseGuards(AuthGuard('myjwt'))와 같은 데코레이터를 통해 이를 참조할 수 있습니다.

 

 

반응형
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.