Nest에서 passport로 인증 구현할 일이 생겼는데, 죄다 jwt 예제 밖에 없더라❗️ 다른 strategy 예제 찾아보다 빡쳐서 제가 알아낸 내용 제가 올리려구요.👿
회사에서 NestJS로 API 서버를 구현하던 중, 인증필터를 구현하는 작업을 하게 되었습니다.
Nest는 node 기반이고, node에는 이미 너무 유명한 인증 라이브러리가 있자나요.
바로 passport!
그래서 저도 passport를 사용해서 인증을 구현하려고 했습니다. 다행히 NestJS 공식 문서에 passport를 사용해서 인증을 구현하는 예제가 있습니다. 그런데....
예제에서는 passport-jwt를 사용하네요. jwt 좋죠... 근데 저는 jwt가 아니라 단순 액세스 토큰을 발급하여 커스텀 인증 로직을 태우는 작업을 해야했습니다.
공식 문서 예제가 jwt로 되어 있어서 그런지... 다른 레퍼런스를 찾아보면 전부 jwt로 인증을 구현하는 방법만 나오고 다른 전략을 사용하는 예제는 잘 안나오더군요...
결국 Github에 올라와있는 NestJS에서 passport를 편하게 쓸 수 있도록 추상화해놓은 nest/passport, 그리고 제가 쓰려는 passport 전략인 passport-http-bearer의 소스코드를 하나하나 뜯어보기로 했습니다...
아래의 클래스는 요청이 들어왔을 때, 인증을 처리하는 부분이고. async validate() 함수가 호출되어 인증 여부를 판단하게 됩니다. 이 클래스를 구현한 후 미들웨어단에 등록하면 요청이 올 때마다 인증 여부를 체크할 수 있죠.
이 클래스는 각 passport 라이브러리의 Strategy를 상속을 받는 것 보니, 각 strategy에 맞게 구현해야한다는 것을 유추해볼 수 있습니다.
https://docs.nestjs.com/security/authentication#implementing-passport-jwt
코드를 보면 validate는 인자로 payload를 받습니다. 대충 유추를 해보면 payload라는 인자는 요청에서 jwt를 받는 것 같아요.
자 그럼 이것은 passport-jwt를 사용할 때의 상황이고, 그렇다면 여기서 생기는 궁금점이
1) 다른 passport 전략을 사용할 때에는 validate는 어떤 인자를 받는가?
였어요. 예제가 jwt밖에 없다보니, 다른 passport 전략을 사용했을 때에는 validate(payload:any)라는 형태로 사용하면 되는지 장담을 할 수가 없으니깐요.
그리고 constructor를 봤을 때, 부모 생성자에 여러 jwt를 가져오는 방법에관련된 여러 옵션들을 주는 것 같은데, 그러면
2) 다른 전략을 사용할 때에는 나는 어떤 옵션을 주어야하는가?
라는 의문 또한 저를 여권지옥으로 떨어뜨린 주범이었죠.
이러한 의문을 해결하려면 저 클래스가 내부적으로 어떤 인풋을 허용하고 어떻게 동작하는지를 알아야했어요.
그래서 하나하나 함수의 원형을 찾아보며 (그리고 이를 바득바득 갈면서🦷, 아니 이세상에 사람이 얼마나 많은데 이거 하나 정리해 놓은 사람이 한명도 없냐고. 아니 솔찍히 화가 나지 않아요? 누가 한명만 공유를 해놨으면 금방 할거를 며칠째 주구장창 삽질하고 있는데. 하지만 화를 내서 달라지는게 없다는걸 알면서 현타를 느끼며...^^)
하하...^^ 오픈소스를 뜯어봤습니다ㅎㅎ
일단 validate가 내부적으로 어떻게 돌아가는지는 PassportStrategy라는 클래스를 뜯어봄으로서 확인할 수 있었습니다.
//auth.jwt.strategy.ts
...
import { PassportStrategy } from '@nestjs/passport';
...
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
...
}
PassportStrategy는 저희가 구현하려는 클래스가 상속을 받는 클래스입니다. import를 보고 nestjs/passport를 Github에서 찾아봤죠. 해당 리포지토리에서 validate를 검색했더니 아래와 같은 코드를 찾을 수 있었습니다.
//passport/lib/passport/passport.strategy.ts
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);
}
};
/**
* Commented out due to the regression it introduced
* Read more here: https://github.com/nestjs/passport/issues/446
const validate = new.target?.prototype?.validate;
if (validate) {
Object.defineProperty(callback, 'length', {
value: validate.length + 1
});
}
*/
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;
}
하나씩 해석해보겠습니다...
이 함수는 인자로 T 템플릿을 받아요(Strategy: T). 사용하는 형태로 보면 T는 사용하려는 passport의 Strategy가 되겠네요!
오 이 클래스에 validate가 있어요! 저희가 찾던 validate인것 같습니다. 이 validate가 어디에서 사용되는지 조금 더 보는게 좋을것 같아요.
이 클래스는 내부에 Strategy를 상속 받는 MixinStrategy라는 inner 클래스가 정의되어 있네요. 아래 return을 보니 PassportStrategy라는 함수는 MixinStrategy라는 클래스를 리턴해요. 이것으로 알 수 있는 점은, 저희가 위에서 실제로 상속을 받았던 클래스는 MixinStrategy였다는 것이죠!
validate가 호출되는 것은 바로 이 클래스의 constructor의 callback 함수입니다. 이 callback함수는 super(...args, callback)이라는 코드를 통해 부모 생성자의 인자로 들어가는데요. 여기서 args는 저희가 개발할 클래스의 생성자에서 부모 생성자에 넣어준 값입니다. 또 다시 부모 생성자로 넘겨주네요. 그럼 일단 부모 클래스의 부모 클래스를 찾아봐야겠죠? 이 클래스의 부모인 Strategy, 저의 상황에서는 passport-http-bearer의 strategy를 살펴볼 필요가 있겠습니다.
그래서 passport-http-bearer의 Strategy의 소스 코드까지 뜯.어.봅.니.다...
// passport-http-bearer/lib/strategy.js
/**
* Module dependencies.
*/
var passport = require('passport-strategy')
, util = require('util');
*
* @constructor
* @param {Object} [options]
* @param {Function} verify
* @api public
*/
function Strategy(options, verify) {
if (typeof options == 'function') {
verify = options;
options = {};
}
if (!verify) { throw new TypeError('HTTPBearerStrategy requires a verify function'); }
passport.Strategy.call(this);
this.name = 'bearer';
this._verify = verify;
this._realm = options.realm || 'Users';
if (options.scope) {
this._scope = (Array.isArray(options.scope)) ? options.scope : [ options.scope ];
}
this._passReqToCallback = options.passReqToCallback;
}
/**
* Inherit from `passport.Strategy`.
*/
util.inherits(Strategy, passport.Strategy);
...
/**
* Expose `Strategy`.
*/
module.exports = Strategy;
저희는 생성자를 봅니다. function Strategy(options, verify)를 보면 저희가 PassportStrategy에서 호출한 부모 생성자에 의해 callback함수가 verify로 들어가나 봅니다.
추가로,
this._realm = options.realm || 'Users';
if (options.scope) {
this._scope = (Array.isArray(options.scope)) ? options.scope : [ options.scope ];
}
this._passReqToCallback = options.passReqToCallback;
생성자의 위 코드를 보면, options라는 값에서 realm, scope, passReqToCallBack이라는 값을 꺼내오는 것을 볼 수 있습니다. 아하! passport-http-bearer에서는 realm, scope, passReqToCallBack을 옵션으로 줄 수 있나보네요. 사실 이렇게 보니 제가 하려는 것과 크게 관련이 있지는 않은 것 같아서 이런 옵션들이 있구나, 옵션 값을 이렇게 찾을 수 있었구나 정도 생각하고 넘어가면 될것 같습니다.
this._verify = verify;
라는 코드에 의해 verify는 _verify가 되네요. 그럼 _verify는 어디서 호출이 될까요. 바로 같은 파일 아래에 정의되어 있는 authenticate라는 함수에서 호출이 됩니다.
// passport-http-bearer/lib/strategy.js
...
/**
* Authenticate request based on the contents of a HTTP Bearer authorization
* header, body parameter, or query parameter.
*
* @param {Object} req
* @api protected
*/
Strategy.prototype.authenticate = function(req) {
var token;
if (req.headers && req.headers.authorization) {
var parts = req.headers.authorization.split(' ');
if (parts.length == 2) {
var scheme = parts[0]
, credentials = parts[1];
if (/^Bearer$/i.test(scheme)) {
token = credentials;
}
} else {
return this.fail(400);
}
}
if (req.body && req.body.access_token) {
if (token) { return this.fail(400); }
token = req.body.access_token;
}
if (req.query && req.query.access_token) {
if (token) { return this.fail(400); }
token = req.query.access_token;
}
if (!token) { return this.fail(this._challenge()); }
var self = this;
function verified(err, user, info) {
if (err) { return self.error(err); }
if (!user) {
if (typeof info == 'string') {
info = { message: info }
}
info = info || {};
return self.fail(self._challenge('invalid_token', info.message));
}
self.success(user, info);
}
if (self._passReqToCallback) {
this._verify(req, token, verified);
} else {
this._verify(token, verified);
}
};
/**
* Build authentication challenge.
*
* @api private
*/
Strategy.prototype._challenge = function(code, desc, uri) {
var challenge = 'Bearer realm="' + this._realm + '"';
if (this._scope) {
challenge += ', scope="' + this._scope.join(' ') + '"';
}
if (code) {
challenge += ', error="' + code + '"';
}
if (desc && desc.length) {
challenge += ', error_description="' + desc + '"';
}
if (uri && uri.length) {
challenge += ', error_uri="' + uri + '"';
}
return challenge;
};
authenticate(req)함수는 req를 인자로 받네요. 이 친구는 클라이언트에서 보내는 request 객체겠죠?? 좀 더 아래 코드를 살펴보니까 이런 코드가 있어요
if (req.headers && req.headers.authorization) {
var parts = req.headers.authorization.split(' ');
if (parts.length == 2) {
var scheme = parts[0]
, credentials = parts[1];
if (/^Bearer$/i.test(scheme)) {
token = credentials;
}
} else {
return this.fail(400);
}
}
아하! authenticate 함수는 request의 autorization 헤더의 값을 확인해서 schema가 bearer이면, 값을 파싱하여 token에 bearer token 값을 할당하네요!
그리고 _verify에 token을 넘겨주어 저희가 작성한 validate 함수가 token을 받아 validate에 우리가 커스텀해준 인증 로직을 타게 되는 것이었어요!
1) 다른 passport 전략을 사용할 때에는 validate는 어떤 인자를 받는가?
의 정답은
=> request의 헤더의 bearer token을 받는다.
였구요. 그렇기 때문에 저희는 validate(token: string)와 같은 시그니처로 개발을 하면되겠죠.
사실 함수의 원형 자체는 validate(payload: any)로 해도 문제는 없어요. 다만, 저희가 코드를 뜯어보면서 얻은 소중한 성과는 인자의 타입을 명확하게 추측할 수 있게되었다는 것이죠(인자로 객체가 오게 되면, 객체의 속성값까지도 알아야하니까요!). 개발을 하는데 더 확신을 가질 수 있다는 것도요!
또한,
2) 다른 전략을 사용할 때에는 나는 어떤 옵션을 주어야하는가?
는
각 strategy의 생성자를 보면 파악할 수 있다 라고 정리할 수 있을것 같습니다. passport-http-bearer같은 경우에는 realm, scope, passReqToCallBack라는 값을 옵션으로 줄 수가 있었습니다. 코드를 좀 더 파보면 어렵지 않게 이 옵션들이 어떻게 사용되는지를 파악할 수 있을것 같습니다. 저는 찾아보고나니 크게 필요가 없어서 패스~ 했습니다ㅎ
그래서 저는 결과적으로 어떻게 개발했냐 하면은
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { MemberService } from '../member/member.service';
import { Strategy } from 'passport-http-bearer';
@Injectable()
export class AdminAuthStrategy extends PassportStrategy(Strategy) {
constructor(private readonly memberService: MemberService) {
super();
}
async validate(token: string) {
return await this.memberService.findAdminByToken(token); // 커스텀 인증 로직 함수
}
}
이런식으로 개발을 했습니다.
어... 크게 뭐 수정한건 없네요... 작업한 내용만 보면 결국 고작 글자 몇개 수정하려고 이 짓을 했나 싶기도 하지만😂, 내부 구조를 알았으니, 확신을 가지고 작업을 할 수 있었다는게 엄청 큰 성과이지 않을까요! 그리고 이제 passport의 다른 strategy를 사용하더라도 더 수월하게 작업할 수 있겠죠ㅎㅎ
(실제로 얼마 전에는 passport-cookie를 가지고 작업할 일이 있었는데, 하루만에 작업을 끝냈답니다 헿)
막연하게 오픈소스 코드를 읽는것에 대한 거부감이 있었습니다. 오픈소스를 사용할 때에는 "여러 사람이 사용하니까"라는 이유로 막연한 신뢰감을 가지고 썼었던것 같아요. 사용 방법도 구글에 검색해보면 곧 잘 나왔구요. 감히 내가 전세계의 사람들이 기여한 오픈소스의 코드를 이해할 수 있을까?라는 두려움도 있었던것 같습니다.
근데 이렇게 한번 뜯어보고 나니까. 오픈소스를 가지고 개발하는 것에 대한 막연한 두려움이 해소가 된것 같아요. 앞으로 다른 오픈소스를 사용하면서 문제가 생겼을 때, 스택 오버플로우에서 수동적으로 다른 사람들의 해답을 적용시키기 보다. 빠르게 코드를 확인해봄으로써, 더 명확하게 문제를 해결할 수 있을 것 같아요.
그리고 앞으로 꽤 많은 시간을 개발자로 보낼텐데, 마냥 남들의 답변만 보고 개발을 할 순 없잖아요? 세상에 회사는 많고, 상황도 많고, 요구사항은 더 다양할거에요. 앞으로 가면 갈 수록 그 요구사항에 맞는 레퍼런스를 찾는 것은 더 어려워지겠죠. 그럼에도 문제를 해결해야겠죠. 개발자는 문제를 푸는 사람들이니까요. 그러한 면에서 이번 경험은 그런 보편적이지 않은 문제들도 잘 해결 수 있는 능동적인 개발자로 한걸음 더 성장할 수 있었던 좋은 계기였던것 같습니다😁
NestJS는 node 기반의 프레임워크이고 사실은 express 혹은 fastify위에서 작동합니다. 그렇기 때문에 express나 fastify에서 개발하는 것과 Nest에서 개발하는 것 사이에는 일맥상통한 부분들이 있습니다.
아래 코드는 passport-http-bearer를 express의 미들웨어에 등록하는 예제입니다.
무언가 익숙하지 않으신가요?
function의 매개변수가 validate의 인자와 같습니다. done을 콜백이니까 제외하구요. 그리고 함수의 구현 내용도 유효한 사용자인지를 검증하는 로직이기 때문에 validate의 함수 내용과 같다고 할 수 있겠죠.
즉, 굳이 코드를 뜯어보지 않아도 node에서 passport를 등록하는 방법만 안다면 nest에서도 어렵지 않게 strategy를 사용할 수 있을것 같습니다ㅎㅎ 👍