NestJs

NestJS - passport

hs-archive 2023. 3. 13. 01:08

Passport

passport는 애플리케이션의 인증을 도와주는 Node.js용 미들웨어입니다. passport는 OAuth, JWT, HTTP 기본/다이제스트 등과 같은 다양한 인증 메커니즘에 알맞은 로직(전략)을 제공해 줍니다. 각 인증에 맞게 미리 만들어진 로직(전략)을 그대로 사용할 수도 있지만 우리 입맛에 맞게 확장할 수도 있습니다. 이러한 '전략'을 통한 유연한 인증을 가능케 하는 것이 passport입니다.

 

OAuth로 인증하고 싶어? OAuth 인증에 필요한 전략(로직) 이미 내가 다 작성해 놓았어!
JWT로 인증하고 싶어? JWT 인증에 필요한 전략(로직) 이미 내가 다 작성해 놓았어!

ID/PW로 인증하고 싶어? ID/PW 인증에 필요한 전략(로직) 이미 내가 다 작성해 놓았어!

너는 그냥 이대로 써도 되고 원한다면 추가적인 로직을 작성해도 돼! 이런 느낌입니다.

 

** Node.js에서 미들웨어란?
Node.js 웹 애플리케이션의 맥락에서 미들웨어는 들어오는 요청이 최종 요청 처리기에 도달하기 전에 순서대로 호출되는 일련의 함수를 나타냅니다. Passport는 Node.js 웹 애플리케이션 용 미들웨어 라이브러리로, 애플리케이션에서 인증 및 권한 부여를 처리하는 데 사용할 수 있는 기능 세트를 제공합니다. Passport의 미들웨어 기능은 Node.js의 요청-응답 주기와 함께 작동하도록 설계되었으므로 Node.js를 포함한 모든 Node.js 웹 프레임워크에서 사용할 수 있습니다.

 

NestJS에서 Passport 사용할 때의 내부 진행 흐름

NestJS는 Passport 전략을 좀 더 쉽고 편하게 사용할 수 있도록 인증 프로세스를 자동으로 처리하는 "@nest/passport" 라이브러리를 제공합니다. 물론 해당 라이브러리 없이도 Passport를 사용할 수 있겠지만, (예를 들어, 직접 passport strategy를 call 하고 직접 미들웨어로 등록하고 직접 try-catch 전부 하고...) 프레임워크가 알아서 주입해 주고 알아서 미들웨어로 등록해 준다는데 사용하지 않을 이유는 없으므로 "@nest/passport"를 사용하여 코드를 작성하겠습니다.

 

passport-local을 사용하여 로그인을 구현해보겠습니다. passport-local은 passport가 지원하는 여러 인증 방법 중 'local' 인증 방법에 대한 전략을 제공하는 라이브러리입니다. ('local'인증 방법이란 '로컬'과 연결되어 있는 데이터베이스나 아니면 아예 '로컬 메모리'에 적혀있는 값들을 대조하는 등의 방식으로 인증을 진행하는 인증 방법을 뜻합니다.)

 

LocalStrategy -> PassportStrategy -> Strategy 순으로 코드의 흐름을 알아보겠습니다.

 

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

@Injectable()
export class LocalStrategy extends PassportStrategy(
  /** 1 */ Strategy, 
  'local'
) {

  constructor() {
    /** 2 */
    super({
		usernameField: 'email',
		passwordField: 'pw'
    });
  }
  
  /** 3 */
  async validate(email: string, pw: string): Promise<any> {
    return { email, pw }
  }
}

 

<local.strategy.ts - 개발자가 작성>

LocalStrategy는 Strategy와 추가하고 싶은 검증 로직을 PassportStrategy에게 전달합니다. 주석 1, 2, 3으로 표시된 부분의 로직을 통해 PassportStrategy에게 값을 전달합니다. 하나하나 흐름을 따라가 봅시다.

 

주석 1은 passport-local의 "Strategy"(전략)를 PassportStrategy의 인자로 전달하는 로직입니다. 만약 passport-local의 전략이 아닌 passport-jwt 전략을 사용하고 싶으면 import { Strategy } from 'passport-jwt';를 하여 passport-jwt의 전략을 가져온 뒤, PassportStrategy의 인자로 넘기기만 하면 전략이 변경됩니다.


주석 2는 부모(PassportStrategy)의 생성자 함수를 통해 "옵션"을 전달하는 로직입니다. 옵션은 나중에 전략의 로직을 진행하는 데 꼭 필요하거나 있어도 되고 없어도 되는 값들을 뜻합니다. 위 코드의 경우 usernameField를 "email"로 설정하고, passwordField를 "pw"로 설정하는 옵션을 주었습니다. 이는 나중에 request에서 사용자가 보낸 값을 찾을 때 key로 사용됩니다. 예를 들어 다음과 같습니다.

 

const username = request.body.email
const password = request.body.pw

 

만약 옵션을 주지 않으면 usernameField는 "username", passwordField는 "password"가 기본입니다. 그리고, 전략이 바뀌면 전달할 수 있는 옵션도 바뀝니다.

 

주석 3은 추가하고 싶은 검증 로직을 구현하는 validate() 메서드입니다. validate()에게 주어지는 매개변수는 각 전략마다 다르며 passport-local은 username과 password라는 두 개의 string을 전달합니다. 우리는 이 두 개의 매개변수를 가지고 추가하고 싶은 검증 로직이 있다면 그것을 구현하면 됩니다. validate()는 PassportStrategy의 추상 메서드이기 때문에 반드시 구현해야 하며, 우리가 구현한 validate()에 PassportStrategy가 접근할 수 있습니다. 뒤에서 볼 것이지만 미리 말하자면, validate()에서 return 한 값은 전달 전달되어 최종적으로는 request.user에 삽입됩니다. 위에서는 { email, pw }를 반환하였으므로 해당 인증이 정상적으로 끝난 뒤에 핸들러에서는 아래와 같이 값을 확인할 수 있을 것입니다.

 

console.log(request.user)

/**
* { 
*     email: "사용자가_보낸_이메일",
*     pw: "사용자가_보낸_패스워드"
* }
*/

 

<passport.strategy.ts - '@nestjs/passport' 라이브러리에서 제공>

PassportStrategy는 LocalStrategy가 전달한 값들을 포장하여 Strategy에게 전달하는 일을 합니다. PassportStrategy는 개발자에게 validate()라는 메서드를 구현하게 만들어서 개발자는 추가하고 싶은 검증 로직만 작성하도록 만들고 나머지 자잘한 일(try-catch 작성, 콜백 함수 작성해서 passport strategy에게 넘기기 등등)은 본인이 대신해 줍니다. 자세히 알아봅시다.

 

PassportStrategy는 우선 callback()이라는 콜백 함수로 validate() 함수를(LocalStrategy에서 주석 3번으로 전달하였음) 감쌉니다. 그 뒤 Strategy에게(LocalStrategy에서 주석 1번으로 전달하였음) 첫 번째 인자로 옵션(LocalStrategy에서 주석 2번으로 전달하였음)을 전달하고, 두 번째 인자로 callback() 함수를 전달합니다. (super(...args, callback)의 형태로 Strategy에게 값을 전달함)

 

PassportStrategy가 만든 callback(...params: any[]) 콜백 함수는 아래 순서대로 작동합니다. (PassportStrategy 전체 코드를 원한다면 아래 접은 글을 읽어주세요.)

1. 파라미터의 마지막 인덱스에 있는 done()이라는 콜백 함수를 받는다.

    const done = params[params.length - 1];

2. 파라미터로 받은 값들을 LocalStrategy의 validate() 메서드에게 전달하고 결괏값을 validateResult에 저장한다.

    const validateResult = await this.validate(...params);

3. 오류가 발생하지 않았다면 done()에 첫 번째 인자로 null, 두 번째 인자로 validateResult를 넣어 호출한다.

    done(null, validateResult)

4 만약 오류가 발생했다면 done()에 첫 번째 인자로 err, 두 번째 인자로 null을 넣어 호출한다.

    done(err, null)

 

더보기
import * as passport from 'passport';
import { Type } from '../interfaces';

export function PassportStrategy<T extends Type<any> = any>(
  Strategy: T,
  name?: string | undefined
): {
  new (...args): InstanceType<T>;
} {
  abstract class MixinStrategy extends Strategy {
    abstract validate(...args: any[]): any;

    constructor(...args: any[]) {
      const callback = async (...params: any[]) => {
        const done = params[params.length - 1];
        try {
          const validateResult = await this.validate(...params);
          if (Array.isArray(validateResult)) {
            done(null, ...validateResult);
          } else {
            done(null, validateResult);
          }
        } catch (err) {
          done(err, null);
        }
      };
      
      super(...args, callback);

      const passportInstance = this.getPassportInstance();
      if (name) {
        passportInstance.use(name, this as any);
      } else {
        passportInstance.use(this as any);
      }
    }

    getPassportInstance() {
      return passport;
    }
  }
  return MixinStrategy;
}

 

<strategy.js - 'passport-local' 라이브러리에서 제공

Strategy란 이 글의 맨 앞에서 설명한 것처럼, 인증에 관한 로직을 적어놓은 것입니다. Strategy의 로직은 각 인증 방법마다 다르므로 맨 처음 LocalStrategy에서 다른 Strategy(예: passport-jwt)를 제공했다면 아래 내용은 달라집니다. 현재 우리는 LocalStrategy에서 passport-local의 Strategy를 제공하였으니 passport-local의 Strategy대로 설명하겠습니다.

 

passport-local의 Strategy는 우선 PassportStrategy가 첫 번째 인자로 전달한 옵션을 사용하여 아래와 같이 username과 password를 찾습니다.

 

var username = lookup(req.body, this._usernameField) || lookup(req.query, this._usernameField);
var password = lookup(req.body, this._passwordField) || lookup(req.query, this._passwordField);


이곳에서 우리가 LocalStrategy의 1번 주석에서 넘겨준 usernameField : 'email'과 passwordField: 'pw'가 사용됩니다. LocalStrategy에서 usernameField: 'email'로 넘겨줬으니 지금은 req.body에 'email'이 key인 value가 있는지 없는지를 확인합니다. 만약 LocalStrategy에서 usernameField: 'some_abc'로 넘겨줬다면 req.body에 'some_abc'가 key인 value가 있는지 없는지를 확인할 것입니다. 위에서 말했듯 옵션 값을 전달하지 않으면 usernameField에는 'username', passwordField에는 'password'가 기본값으로 들어갑니다.

이렇게 username과 password를 찾는데, 둘 중 하나라도 req에서 해당 값을 찾을 수 없다면 예외를 발생시킵니다.
그렇지 않고 username과 password가 둘 다 존재한다면 PassportStrategy가 두 번째 인자로 전달한 callback()을 
아래와 같이 호출합니다.

 

callback(username, password, verified)

 

callback()에 전달하는 마지막 매개변수인 verified는 passport-local의 Strategy에 아래와 같이 선언되어 있습니다. (passport-local의 Strategy 전체 코드를 원한다면 아래 접은 글을 읽어주세요.)

 

function verified(err, user, info) {
    if (err) { return self.error(err); }
    if (!user) { return self.fail(info); }
    self.success(user, info);
 }

 

더보기
// Module dependencies.
var passport = require('passport-strategy')
  , util = require('util')
  , lookup = require('./utils').lookup;

/**
 * Create a new `Strategy` object.
 *
 * @classdesc This `Strategy` authenticates requests that carry a username and
 * password in the body of the request.  These credentials are typically
 * submitted by the user via an HTML form.
 *
 * @public
 * @class
 * @augments base.Strategy
 * @param {Object} [options]
 * @param {string} [options.usernameField='username'] - Form field name where
 *          the username is found.
 * @param {string} [options.passwordField='password'] - Form field name where
 *          the password is found.
 * @param {boolean} [options.passReqToCallback=false] - When `true`, the
 *          `verify` function receives the request object as the first argument,
 *          in accordance with `{@link Strategy~verifyWithReqFn}`.
 * @param {Strategy~verifyFn|Strategy~verifyWithReqFn} verify - Function which
 *          verifies username and password.
 *
 * @example
 * var LocalStrategy = require('passport-local').Strategy;
 *
 * new LocalStrategy(function(username, password, cb) {
 *   users.findOne({ username: username }, function(err, user) {
 *     if (err) { return cb(err); }
 *     if (!user) { return cb(null, false, { message: 'Incorrect username or password.' }); }
 *
 *     crypto.pbkdf2(password, user.salt, 310000, 32, 'sha256', function(err, hashedPassword) {
 *       if (err) { return cb(err); }
 *       if (!crypto.timingSafeEqual(user.hashedPassword, hashedPassword)) {
 *         return cb(null, false, { message: 'Incorrect username or password.' });
 *       }
 *       return cb(null, user);
 *     });
 *   });
 * });
 *
 * @example <caption>Construct strategy using top-level export.</caption>
 * var LocalStrategy = require('passport-local');
 *
 * new LocalStrategy(function(username, password, cb) {
 *   // ...
 * });
 */

function Strategy(options, verify) {
  if (typeof options == 'function') {
    verify = options;
    options = {};
  }
  if (!verify) { throw new TypeError('LocalStrategy requires a verify callback'); }
  
  this._usernameField = options.usernameField || 'username';
  this._passwordField = options.passwordField || 'password';
  
  passport.Strategy.call(this);
  
  /** The name of the strategy, which is set to `'local'`.
   *
   * @type {string}
   * @readonly
   */
  this.name = 'local';
  this._verify = verify;
  this._passReqToCallback = options.passReqToCallback;
}

// Inherit from `passport.Strategy`.
util.inherits(Strategy, passport.Strategy);

/**
 * Authenticate request by verifying username and password.
 *
 * This function is protected, and should not be called directly.  Instead,
 * use `passport.authenticate()` middleware and specify the {@link Strategy#name `name`}
 * of this strategy and any options.
 *
 * @protected
 * @param {http.IncomingMessage} req - The Node.js {@link https://nodejs.org/api/http.html#class-httpincomingmessage `IncomingMessage`}
 *          object.
 * @param {Object} [options]
 * @param {string} [options.badRequestMessage='Missing credentials'] - Message
 *          to display when a request does not include a username or password.
 *          Used in conjunction with `failureMessage` or `failureFlash` options.
 *
 * @example
 * passport.authenticate('local');
 */
Strategy.prototype.authenticate = function(req, options) {
  options = options || {};
  var username = lookup(req.body, this._usernameField) || lookup(req.query, this._usernameField);
  var password = lookup(req.body, this._passwordField) || lookup(req.query, this._passwordField);
  
  if (!username || !password) {
    return this.fail({ message: options.badRequestMessage || 'Missing credentials' }, 400);
  }
  
  var self = this;
  
  function verified(err, user, info) {
    if (err) { return self.error(err); }
    if (!user) { return self.fail(info); }
    self.success(user, info);
  }
  
  try {
    if (self._passReqToCallback) {
      this._verify(req, username, password, verified);
    } else {
      this._verify(username, password, verified);
    }
  } catch (ex) {
    return self.error(ex);
  }
};

// Export `Strategy`.
module.exports = Strategy;


/**
 * Verifies `username` and `password` and yields authenticated user.
 *
 * This function is called by `{@link Strategy}` to verify a username and
 * password, and must invoke `cb` to yield the result.
 *
 * @callback Strategy~verifyFn
 * @param {string} username - The username received in the request.
 * @param {string} password - The passport received in the request.
 * @param {function} cb
 * @param {?Error} cb.err - An `Error` if an error occured; otherwise `null`.
 * @param {Object|boolean} cb.user - An `Object` representing the authenticated
 *          user if verification was successful; otherwise `false`.
 * @param {Object} cb.info - Additional application-specific context that will be
 *          passed through for further request processing.
 */

/**
 * Verifies `username` and `password` and yields authenticated user.
 *
 * This function is called by `{@link Strategy}` to verify a username and
 * password when the `passReqToCallback` option is set, and must invoke `cb` to
 * yield the result.
 *
 * @callback Strategy~verifyWithReqFn
 * @param {http.IncomingMessage} req - The Node.js {@link https://nodejs.org/api/http.html#class-httpincomingmessage `IncomingMessage`}
 *          object.
 * @param {string} username - The username received in the request.
 * @param {string} password - The passport received in the request.
 * @param {function} cb
 * @param {?Error} cb.err - An `Error` if an error occured; otherwise `null`.
 * @param {Object|boolean} cb.user - An `Object` representing the authenticated
 *          user if verification was successful; otherwise `false`.
 * @param {Object} cb.info - Additional application-specific context that will be
 *          passed through for further request processing.
 */

 

 

이게 전부입니다. 이제 NestJS로 요청이 들어오면 등록된 모든 함수들이 순서대로 호출될 것입니다. 한 번 예시를 들어보겠습니다. (아직 LocalStrategy를 미들웨어로 등록하진 않았지만 등록했다고 가정하겠습니다.) 요청이 들어오면 다음과 같이 동작할 것입니다.

 

<Guard 없는 인증 흐름>

1. 요청이 들어온다.
2. Strategy로직에서 요청(request) 안에 있는 username과 password를 찾는다.

  - 만약 username 혹은 password가 존재하지 않으면 예외를 발생시킨다.
3. verify(username, password, verified)를 호출한다. (이때 verify()는 PassportStrategy의 callback() 메서드입니다.)
4. 호출된 PassportStrategy의 callback() 메서드는 마지막 파라미터에 있는 done() 콜백 함수를 받는다.

    const done = params[params.length - 1]

5. PassportStrategy의 callback() 메서드는 LocalStrategy에서 작성한 validate()를 호출하고 결괏값을 validateResult에 저장한다.
     const validateResult = await this.validate(...params);
6. 만약 오류가 발생하지 않았다면, 첫 번째 인자로 null, 두 번째 인자로 validateResult를 넣어 done()을 호출한다.
    done(null, validateResult)
7. 만약 오류가 발생했다면 첫 번째 인자로 err, 두 번째 인자로 null을 넣어 done()을 호출한다.
    done(err, null)
8. done()은 passport-local Strategy의 verified(err, user, info)이므로 해당 함수의 로직을 따라 실행된다.

  - err가 있는 경우 
      return self.error(error)

  - user가 존재하지 않는 경우

      return self.fail(info)

  - 그 외의 경우

      self.success(user, info)

 

이렇게 done() 함수 즉, verified() 함수까지 완료되면 verified()가 만든 값 self.error(error) 혹은 self.fail(info) 혹은 self.success(user, info)가 다음 미들웨어 혹은 다음 흐름으로 넘어갑니다. 

 

Passport와 AuthGuard

위에서 흐름의 이름을 <Guard 없는 인증 흐름>이라고 했습니다. 그렇다면 Guard가 있는 흐름도 있을까요? 물론 있습니다! NestJS는 LocalStrategy를 손쉽게 미들웨어로 사용할 수 있게 해주는 AuthGuard라는 클래스를 제공합니다. AuthGuard도 PassportStrategy와 마찬가지로 LocalStrategy(개발자)와 passport(라이브러리)를 이어주는 가교 역할을 합니다. 따라서 AuthGuard도 'nest/passport' 라이브러리에 있습니다. 참고로, AuthGuard도 Guard이므로 UseGuard()를 통해 사용할 수 있습니다.

 

AuthGuard를 통해 LocalStrategy와 passport를 잇는 방법은 무척 간단합니다. 클래스를 하나 만들고 AuthGuard를 확장한 뒤 AuthGuard 매개변수에 string을 넘겨주면 됩니다. 이때 넘겨주는 string은 LocalStrategy class에서 PassportStrategy()에게 넘겨준 두 번째 파라미터 string과 같아야 합니다. 따라서 우리의 경우 'local'을 넘겨주면 됩니다. 그래야지 AuthGuard가 우리가 작성한 전략을 찾아서 미들웨어로 사용할 수 있습니다. 아래는 LocalStrategy와 passport를 잇는 간단한 AuthGuard의 예시입니다.

 

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

 

class body에 아무것도 구현하지 않아도 잘 동작합니다. 하지만 좀 더 커스텀한 작업을 하고 싶다면 AuthGard의 메서드를 오버라이드해서 그것을 달성할 수도 있습니다. (아래 적힌 것 외에도 logIn(), logOut() 등의 메서드도 있습니다.)

 

canActivate(context) - AuthGuard도 Guard이기 때문에 canActivate() 메서드가 있습니다. 최종적으로 canActivate() 메서드가 모든 동작을 마치고 true를 반환하면 AuthGuard의 역할은 끝나고 다음 핸들러로 흐름이 넘어갑니다. 만약 false를 반환하면 요청은 거부됩니다. AuthGuard의 canActivate() 메서드에는 클라이언트로부터 요청이 왔을 때 우리가 작성한 전략을 인증 미들웨어로 사용하도록 하는 코드가 적혀 있기 때문에 LocalAuthGuard에서 canActivate() 메서드를 오버라이드 하더라도 마지막에는 'return super.canActivate(context)'를 넣어서 AuthGuard의 canActivate() 메서드를 호출해 줘야 합니다.

handleRequest(err, user, info, context, status) - AuthGuard의 canActivate() 메서드 내부에서 콜백 함수로 감싸진 뒤, passport.authenticate()에 전달되어 passport.authenticate() 콘텍스트에서 호출되는 메서드입니다. 인증 성공/실패에 대한 핸들링을 담당합니다. (passport.authenticate() 메서드는 인증을 시작하기 위해 호출해야 하는 인증의 트리거입니다. AuthGuard의 canActivate() 메서드에서 해당 passport.authenticate() 메서드를 호출합니다.) (nestjs/passport의 AuthGuard 전체 코드를 원한다면 아래 접은 글을 읽어주세요.)

 

<local.strategy.ts>
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
    // 위에서 작성한 코드와 동일함..
}


// LocalStrategy를 미들웨어로 사용하기위해
// 새로 만든 LocalAuthGuard 클래스입니다.
<local.guard.ts>
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {

    // 부모의(AuthGuard의) canActivate()를 호출하기 전에 
    // 하고 싶은 작업이 있다면 여기에 작성합니다.
    // 어떤 작업을 하든 마지막에는
    // return super.canActivate(context)를 해줘야 AuthGuard가 정상적으로 LocalStrategy를 사용합니다.
    
    // 만약 AuthGuard가 제공하는 그대로 괜찮다면
    // 굳이 override 하지 않아도 됩니다.
    canActivate(context: ExecutionContext) {
        return super.canActivate(context);
    }

    // handleRequest()는 앞에서 보았던
    // Strategy 로직과
    // LocalStrategy validate() 이후에 동작하며
    // 해당 로직들의 반환 값을 인자로 받아 동작합니다.
    
    // 이곳에서 반환하는 user는 canActivate() 메서드 본문 로직에 의해 request에 담깁니다.
        // request[user] = user;
    // 만약 이곳에서 err를 던지면
    // passport.authenticate()가 오류를 catch해서
    // 오류 응답을 보내고 요청을 중단합니다.
    
    // 만약 AuthGuard가 제공하는 그대로 괜찮다면
    // 굳이 override 하지 않아도 됩니다.
    handleRequest(err, user, info) {
    if (err || !user) {
      throw err || new UnauthorizedException();
    }
    return user;
  }
}


// 테스트를 위해 새로 만든 CatsController 클래스입니다.
<auth.controller.ts>
@Controller('/test')
export class AuthController {
  @Get()
  @UseGuard(LocalAuthGuard)
  test(): string {
    return 'test OK';
  }
}

// 모듈입니다.
@Module({
  imports: [PassportModule],
  controllers: [AuthController],
  providers: [LocalStrategy]
})
export class AuthModule {}

 

더보기
/* eslint-disable @typescript-eslint/no-unused-vars */
import {
  CanActivate,
  ExecutionContext,
  Inject,
  Logger,
  mixin,
  Optional,
  UnauthorizedException
} from '@nestjs/common';
import * as passport from 'passport';
import { Type } from './interfaces';
import {
  AuthModuleOptions,
  IAuthModuleOptions
} from './interfaces/auth-module.options';
import { defaultOptions } from './options';
import { memoize } from './utils/memoize.util';

export type IAuthGuard = CanActivate & {
  logIn<TRequest extends { logIn: Function } = any>(
    request: TRequest
  ): Promise<void>;
  handleRequest<TUser = any>(
    err,
    user,
    info,
    context: ExecutionContext,
    status?
  ): TUser;
  getAuthenticateOptions(
    context: ExecutionContext
  ): IAuthModuleOptions | undefined;
};
export const AuthGuard: (type?: string | string[]) => Type<IAuthGuard> =
  memoize(createAuthGuard);

const NO_STRATEGY_ERROR = `In order to use "defaultStrategy", please, ensure to import PassportModule in each place where AuthGuard() is being used. Otherwise, passport won't work correctly.`;
const authLogger = new Logger('AuthGuard');

function createAuthGuard(type?: string | string[]): Type<CanActivate> {
  class MixinAuthGuard<TUser = any> implements CanActivate {
    @Optional()
    @Inject(AuthModuleOptions)
    protected options: AuthModuleOptions = {};

    constructor(@Optional() options?: AuthModuleOptions) {
      this.options = options ?? this.options;
      if (!type && !this.options.defaultStrategy) {
        authLogger.error(NO_STRATEGY_ERROR);
      }
    }

    async canActivate(context: ExecutionContext): Promise<boolean> {
      const options = {
        ...defaultOptions,
        ...this.options,
        ...(await this.getAuthenticateOptions(context))
      };
      const [request, response] = [
        this.getRequest(context),
        this.getResponse(context)
      ];
      const passportFn = createPassportContext(request, response);
      const user = await passportFn(
        type || this.options.defaultStrategy,
        options,
        (err, user, info, status) =>
          this.handleRequest(err, user, info, context, status)
      );
      request[options.property || defaultOptions.property] = user;
      return true;
    }

    getRequest<T = any>(context: ExecutionContext): T {
      return context.switchToHttp().getRequest();
    }

    getResponse<T = any>(context: ExecutionContext): T {
      return context.switchToHttp().getResponse();
    }

    async logIn<TRequest extends { logIn: Function } = any>(
      request: TRequest
    ): Promise<void> {
      const user = request[this.options.property || defaultOptions.property];
      await new Promise<void>((resolve, reject) =>
        request.logIn(user, (err) => (err ? reject(err) : resolve()))
      );
    }

    handleRequest(err, user, info, context, status): TUser {
      if (err || !user) {
        throw err || new UnauthorizedException();
      }
      return user;
    }

    getAuthenticateOptions(
      context: ExecutionContext
    ): Promise<IAuthModuleOptions> | IAuthModuleOptions | undefined {
      return undefined;
    }
  }
  const guard = mixin(MixinAuthGuard);
  return guard;
}

const createPassportContext =
  (request, response) => (type, options, callback: Function) =>
    new Promise<void>((resolve, reject) =>
      passport.authenticate(type, options, (err, user, info, status) => {
        try {
          request.authInfo = info;
          return resolve(callback(err, user, info, status));
        } catch (err) {
          reject(err);
        }
      })(request, response, (err) => (err ? reject(err) : resolve()))
    );

 

클라이언트가 위 nestJS 서버의 '/test' 끝점에 요청을 보내면 다음과 같은 흐름으로 진행됩니다. (접은 글에 있는 여러 전체 코드들을 보시면 이해하기 더 좋습니다.)

 

<AuthGuard를 사용한 인증 흐름>

1. 요청이 들어오면 @UseGuard 데코레이터로 인해 LocalAuthGuard의 canActivate() 메서드가 실행된다.

2. canActivate() 메서드는 passport.authenticate() 메서드를 호출하여 우리가 만든 LocalStrategy를 사용하여 인증을 시도한다.

  - passport.authenticate()는 인증의 트리거입니다. canActivate()가 호출합니다.

 

------------<이 부분은 Guard 없는 인증 흐름과 똑같음> ------------

3. Strategy로직에서 요청(request) 안에 있는 username과 password를 찾는다.

  - 만약 username 혹은 password가 존재하지 않으면 예외를 발생시킨다.
4. verify(username, password, verified)를 호출한다. (이때 verify()는 PassportStrategy의 callback() 메서드입니다.)
5. 호출된 PassportStrategy의 callback() 메서드는 마지막 파라미터에 있는 done() 콜백 함수를 받는다.

    const done = params[params.length - 1]

6. LocalStrategy에서 작성한 validate()를 호출하고 결괏값을 validateResult에 저장한다.
     const validateResult = await this.validate(...params);
7. 만약 오류가 발생하지 않았다면, 첫 번째 인자로 null, 두 번째 인자로 validateResult를 넣어 done()을 호출한다.
    done(null, validateResult)
8. 만약 오류가 발생했다면 첫 번째 인자로 err, 두 번째 인자로 null을 넣어 done()을 호출한다.
    done(err, null)
9. done()은 verified(err, user, info)이므로 7~8번은 최종적으로 아래 함수 로직을 따라 실행된다.

  - err가 있는 경우 
      return self.error(error)

  - user가 존재하지 않는 경우

      return self.fail(info)

  - 그 외의 경우

      self.success(user, info)

-----------------------------------------------------------

 

10. handleRequest(err, user, info, context, status)가 호출된다.

  - handleRequest()는 passport.authenticate()에게 콜백으로 전달되어서 passport.authenticate() 콘텍스트에서 실행된다. 따라서 handleRequest() 메서드에서 오류를 throw 할 경우 passport.authenticate()가 오류 처리를 한다.

  - 오류를 throw 하지 않는 한 user(두 번째 파라미터)를 반환한다.

11. handleRequest()가 user 개체를 반환하면 canActivate()는 request 개체에 사용자 개체를 추가한다. 이렇게 하면 경로 처리기(핸들러)에서 사용자 정보에 액세스 할 수 있다.

  - /** canActivate()에서 이렇게 하면 ..*/ request[user] = user

  - /** 핸들러에서 이렇게 쓸 수 있음 ..*/ console.log(requset.user)

  - 실제 canActivate() 메서드에는 아래와 같이 적혀 있습니다.

      request[options.property || defaultOptions.property] = user;

12. canActivate()가 true를 반환하여 모든 동작을 마치면, 해당 라우트 핸들러인 test() { return "OK" }가 실행된다.

  - 만약 false를 반환하면 요청을 거부합니다. Guard의 canActivate()에서 return flase를 한 것과 동일합니다.

 

참고로 LocalStrategy와 그의 부모 전략(이 경우 passport-local의 전략)은 사전에 결합되어 애플리케이션 모듈의 PassportModule에 전역적으로 등록됩니다. 이 작업은 전략을 전역적으로 사용할 수 있도록 만드는 PassportModule의 register() 메서드를 사용하여 수행됩니다. AuthGuard는 passport.authenticate()를 호출할 때 PassportModule에 전역적으로 등록된 전략 중에서 일치하는 이름(이 경우 'local'. 만약 LocalStrategy에서 전략 이름을 'local2'로 지었다면 LocalAuthGuard에서도 'local2'로 찾아야 함)을 가진 전략을 찾아 사용합니다. 따라서 AuthGuard가 passport.authenticate()를 통해 Strategy를 사용하기 위해서는 LocalStrategy와 Strategy가 사전에 결합된 뒤 PassportModule을 사용하여 전역적으로 등록해야 합니다. 그래서 아래처럼 PassportModule을 AuthModule의 imports 배열에 넣은 것입니다.

 

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

 

요약

passport는 애플리케이션의 인증을 도와주는 Node.js용 미들웨어이며, 각 인증에 알맞은 다양한 전략을 제공해 줍니다. 우리는 해당 전략을 확장하거나 그대로 사용하면 됩니다. 게다가 NestJS는 nest/passport 라이브러리를 통해 NestJS에서 passport를 사용하기 더 쾌적하도록 PassportStrategy, AuthGuard, PassportModule 등의 다양한 도구들을 제공해 줍니다.

 

passport-local 전략을 아무것도 안 고치고 그대로 사용하고 싶으면 아래처럼 작성하면 됩니다.

 

<auth/local.strategy.ts>
@Injectable()
export class LocalStrategy extends PassportStrategy(
  /** passport-local Strategy */Strategy,
  'local') {
  constructor() {
    super();
  }

  async validate(username: string, password: string): Promise<any> {
    return {username, password}
  }
}



<auth/local-auth.guard.ts>
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}



<auth/auth.controller.ts>
@Controller('/auth')
export class AppController {

  @UseGuards(LocalAuthGuard)
  @Post('login')
  async login(@Request() req) {
    console.log(req.user); // { username: 'test_name', password: 'test_pwd' }
    return 'hello user!';
  }
}



<auth/auth.module.ts>
@Module({
  imports: [PassportModule],
  controllers: [AuthController],
  providers: [LocalStrategy],
})
export class AuthModule {}

 

 

 

 

 


https://github.com/jaredhanson/passport-local/blob/master/lib/strategy.js

 

GitHub - jaredhanson/passport-local: Username and password authentication strategy for Passport and Node.js.

Username and password authentication strategy for Passport and Node.js. - GitHub - jaredhanson/passport-local: Username and password authentication strategy for Passport and Node.js.

github.com

https://github.com/nestjs/passport/blob/master/lib/passport/passport.strategy.ts

 

GitHub - nestjs/passport: Passport module for Nest framework (node.js) 🔑

Passport module for Nest framework (node.js) 🔑. Contribute to nestjs/passport development by creating an account on GitHub.

github.com

https://docs.nestjs.com/security/authentication

 

Documentation | NestJS - A progressive Node.js framework

Nest is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with TypeScript and combines elements of OOP (Object Oriented Programming), FP (Functional Programming), and FRP (Functional Rea

docs.nestjs.com

'NestJs' 카테고리의 다른 글

service에서의 repository 의존 역전하기 - 클린 아키텍처 적용하기  (0) 2023.03.28
Entity -> DTO 변환  (0) 2023.03.23
NestJS - @Module(), @Injectable(), @InjectRepository()  (0) 2023.03.17
NestJs - Guard  (0) 2023.03.09
NestJS 소개  (0) 2022.10.23