웹 서비스에서 사용자 인증을 구현할 때, 인증 정보를 요청 컨텍스트에 객체(보통 User)로 담아 컨트롤러 같은 api 핸들러에서 쉽게 꺼내쓸 수 있도록 구현하곤 했습니다. NestJS의 경우 @nestjs/passport 에서 AuthGuard와 PassportStrategy 함수를 제공해주어 개발 과정에서 검증 로직만 구현하면 됩니다. 이번 글에서는 Passport 기반 jwt 인증이 구현되는 원리를 상세히 알아보고자 합니다.
예제 소스코드는 링크에 있습니다.
1. 필요한 라이브러리 및 모듈 정리
passport
Node.js에서 전략 기반 인증(및 인가) 처리를 지원하는 Passport.js 는 npm 저장소 위클리 다운로드 수백만에 이르는 라이브러리입니다. 보통 유저의 인증은 아이디/패스워드뿐만 아닌 jwt, OAuth2 프레임워크 등 다양한 방식으로 인증하게 됩니다. 이같은 인증 방식들을 인증 전략(strategy)이라고 하며 passport는 하나 이상의 인증 전략 패키지와 함께 설치되어 동작합니다. 가장 대표적인 인증 전략은 아래와 같습니다.
전략명(npm package 명) | 설명 |
passport-local | userId, password 기반 인증 |
passport-jwt | jwt 토큰 기반 인증 |
passport-oauth2 | Oauth2 기반 인증 |
* jwt기반의 인증을 사용할 것이므로 passport-jwt 전략을 선택했습니다.
passport는 애플리케이션이 초기화될 때 인증 전략을 등록하고 이후 인가가 필요한 시점에 등록된 인증 전략으로 인증(authenticate())을 수행합니다. 호출 시점에 해당 전략을 찾지 못하면 에러를 발생시키고 프로세스가 종료됩니다.
passport/lib/middleware/authenticate.js
function attempt(i) {
var layer = name[i];
if (!layer) { return allFailed(); }
var strategy, prototype;
if (typeof layer.authenticate == 'function') {
strategy = layer;
} else {
prototype = passport._strategy(layer);
//name 과 일치하는 (passport에서 사용중인) strategy가 없다면 에러발생
if (!prototype) { return next(new Error('Unknown authentication strategy "' + layer + '"')); }
strategy = Object.create(prototype);
}
...하략
}
* passport는 express 에서 동작하는 미들웨어로 설계되어 NestJS에서 직접 사용될 일은 없습니다.
@nestjs/passport
express와 대비되는 특징으로 NestJS는 DI 컨테이너와 데코레이터 패턴을 기반으로하는 구조화된 시스템으로 설계되었습니다. 이 패키지는 express로 설계된 passport를 NestJS의 구조에서 쉽게 사용할 수 있도록합니다. 두가지 함수가 대표적입니다.
1. PassportStrategy
NestJS에서 Passport의 전략을 쉽게 등록할 수 있도록 도와줍니다. PassportStrategy 함수는 믹스인 패턴으로 동적 클래스(StrategyWithMixin)를 만들어 주는데, 이 클래스의 생성자에는 passport에서 인증 전략으로 등록하는 과정이 포함되어 있습니다. 말그대로 전략을 등록해줍니다.
@nestjs/passport/dist/passport/passport.strategy.js
function PassportStrategy(Strategy, name, callbackArity) {
class StrategyWithMixin extends Strategy {
constructor(...args) {
...중략
//전략을 등록
if (name) {
passportInstance.use(name, this);
}
else {
passportInstance.use(this);
}
}
getPassportInstance() {
return passport;
}
}
return StrategyWithMixin;
}
2. AuthGuard
NestJS에는 인증&인가의 책임을 가진 Guards가 있습니다. passport 전략을 이 Guards와 통합해서 사용할 수 있도록 AuthGuard 를 제공합니다. 동적인 MixinAuthGuard 클래스를 생성하면서 canActive() 메서드를 구현하는데 메서드명에서 알 수 있듯 등록된 전략을 찾아 해당 전략으로 자격증명을 검증합니다.
passport-jwt(및 @types/passport-jwt)
PassportStrategy 함수에 인자로 넘겨주어 실제 등록될 인증 전략 인스턴스를 제공합니다. 그리고 http request로부터 jwt를 추출할 수 있도록 ExtractJwt 에서 여러가지 함수를 제공합니다.
passport-jwt/lib/extract_jwt.js
//http로부터 토큰을 추출하기 위한 함수
extractors.fromAuthHeaderWithScheme = function (auth_scheme) {
var auth_scheme_lower = auth_scheme.toLowerCase();
return function (request) {
var token = null;
if (request.headers[AUTH_HEADER]) {
var auth_params = auth_hdr.parse(request.headers[AUTH_HEADER]);
if (auth_params && auth_scheme_lower === auth_params.scheme.toLowerCase()) {
token = auth_params.value;
}
}
return token;
};
};
정리하면 이렇습니다.
@nestjs/jwt
위 소개된 패키지는 모두 유저 요청시 인증하는 것과 관련된 패키지입니다. 이 패키지는 로그인시 jwt 를 발행하기 위한 역할입니다. 따라서 jwt의 만료시간, 시크릿 등 설정을하고 토큰 생성(sign())메서드가 있습니다.
2. 인증 흐름
2-1. NestJS 앱 초기화
AppModule을 시작으로 모듈, 프로바이더 등 데코레이터 패턴으로 등록됩니다. 인증 예제 관련하여 주로 다음 3가지가 있습니다.
2-1-1. JwtStrategy 인스턴스 생성
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
...
}
이때 JwtStrategy가 상속받은 PassportStrategy 함수가 호출되어 전략을 등록(use())하게 됩니다. 앞서 살펴본 @nestjs/passport 의 코드블럭에 해당하는 부분입니다.
2-1-2. JwtAuthGuard 인스턴스가 생성
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
canActivate() 는 AuthGuard에서 제공하는 것을 그대로 사용합니다.
2-1-3. @UseGuard()
@UseGuard() 데코레이터는 가변인자로 받은 Guard 들을 검증하고 Reflect로 이 메서드의 메타데이터에 AuthGuard를 등록합니다.
2-2. 사용자 로그인
@nestjs/jwt 에서 제공하는 JwtService 로 jwt 토큰을 발행합니다.
2-3. 사용자 인증이 필요한 API 호출
사용자가 Authorization 헤더에 JWT를 담아 /users/me GET 요청을 보냅니다. 요청은 ExpressAdapter가 받아 GuardsConsumer 를 통해 이 API에 등록된 가드들의 canActivate() 를 호출하게 됩니다.
@nestjs/core/guards/guards-consumer.js
class GuardsConsumer {
async tryActivate(guards, args, instance, callback, type) {
if (!guards || (0, shared_utils_1.isEmpty)(guards)) {
return true;
}
const context = this.createContext(args, instance, callback);
context.setType(type);
for (const guard of guards) {
const result = guard.canActivate(context);
if (await this.pickResult(result)) {
continue;
}
return false;
}
return true;
}
...하략
}
3. JwtAuthGuard의 canActivate() 메서드 실행
AuthGuard의 canActivate() 를 그대로 호출합니다.
@nestjs/passport/dist/auth.guard.js
canActivate(context) {
return __awaiter(this, void 0, void 0, function* () {
//passport 기본옵션과 사용자 옵션을 병합한 options
const options = Object.assign(Object.assign(Object.assign({}, options_1.defaultOptions), this.options), (yield this.getAuthenticateOptions(context)));
const [request, response] = [
this.getRequest(context),
this.getResponse(context)
];
const passportFn = createPassportContext(request, response);
const user = yield passportFn(type || this.options.defaultStrategy, options, (err, user, info, status) => this.handleRequest(err, user, info, context, status));
request[options.property || options_1.defaultOptions.property] = user;
return true;
});
}
실행 컨텍스트로 부터 request, response를 가지고와 createPassportContext()를 통해 인증 함수를 생성합니다.
3-1. createPassportContext
제가 사용중인 @nestjs/passport:9.0.3 버전에서 createPassportContext 함수는 다음과 같습니다.😂
const createPassportContext = (request, response) => (type, options, callback) => new Promise((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())));
한눈에 들어오지 않아서 다음과 같이 변환했습니다.
function createPassportContext(request, response) {
return function lambda(type, options, callback) {
return new Promise((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())));
};
}
createPassportContext 는 lambda 라는 함수를 리턴합니다. lambda는 제가 임의로 넣은 함수명입니다.
lambda 함수는 프로미스를 리턴합니다. 이 함수 호출시에는 yield 키워드로 resolve 이후 동작을 보장합니다.
프로미스 내부에서 passport.authenticate 를 호출하는데 이는 커링이 적용된 비동기 함수입니다. 호출시 passport.authenticate 내에서 next()로 호출할 callback 도 같이 넘겨줍니다.
3-1-1. passport.authenticate
옵셔널 인자 처리, name 여부를 체크하고 인증을 수행합니다.
passport/lib/middleware/authenticate.js
module.exports = function authenticate(passport, name, options, callback) {
//인자 개수에 따라서 조정
if (typeof options == 'function') {
callback = options;
options = {};
}
options = options || {};
var multi = true;
if (!Array.isArray(name)) {
name = [ name ];
multi = false;
}
return function authenticate(req, res, next) { //인증 수행... }
}
등록된 인증 전략을 찾아 인증을 수행합니다. 여기에서는 passport-jwt에 정의된 Strategy로 수행하며, 이 로직에는 JwtStrategy 의 validate() 함수를 호출하여 인증 성공을 판단하는 부분이 있습니다.
promise resolve 되면, lambda 함수를 호출할 때 넘겨준 callback이 호출됩니다. 이는 AuthGuard에서 제공하는 handleRequest 로 passport 결과가 err거나 user객체가 없을 때 UnauthorizedException을 발생시킵니다.
canActivate(context) {
...
const user = yield passportFn(type || this.options.defaultStrategy, options,
//resolve 되면 호출될 콜백
(err, user, info, status) => this.handleRequest(err, user, info, context, status));
...
}
handleRequest(err, user, info, context, status) {
if (err || !user) {
throw err || new common_1.UnauthorizedException();
}
return user;
}
getRequest(context) {
return context.switchToHttp().getRequest();
}
...
*handleRequest() 를 JwtStrategy 클래스에서 재정의하여 에러 분기를 상세하게 할 필요가 있습니다. 예를들어 인증 전략인 jwt는 보통 refresh token 방식으로 구성하기 때문에 클라이언트에서는 인증 실패 에러가 토큰 만료에 의한 에러인지, 페이로드 조작 등 토큰자체의 검증 실패인지 구분해야할 필요가 있습니다.
*함께 정의된 getRequest() 도 마찬가지로, RESTAPI를 기본으로 하지만, GraphQL 등 타 프로토콜의 경우 재정의하여 사용하기도 합니다.
3-2. request 의 프로퍼티로 저장
passportFn()이 실행되어 user 객체를 가져오고, 최종적으로 request에 저장됩니다. 보통 User 이지만 실무에서 서비스상의 User와 혼동되는 경우 다르게도 사용했던 것 같습니다.
4. 컨트롤러에서 user 정보 가져오기
파라미터 데코레이터 @Request() 등으로 실행 컨텍스트의 request를 쉽게 가져올 수 있습니다. 이 안의 user로 비즈니스 로직을 수행합니다.
3. 회고
NestJS에서 Passport를 활용한 인증은 모듈화 및 DI 컨테이너를 활용한 확장성이 뛰어납니다. 기본적인 JWT 인증에서부터 세션, OAuth, RBAC까지 다양한 방식으로 확장할 수 있고, 내부적으로 Passport의 authenticate()를 NestJS의 Guard 시스템과 통합하여 효율적인 인증을 제공하는 세세한 과정을 보게되어 흥미로웠습니다.
또한 인증과 별개로 npm 패키지 소스를 분석하며 NestJS의 데코레이터 패턴을 사용하는 동작 방식과 그 구현의 핵심인 리플랙션, 함수형 프로그래밍, 프로토타입 기반의 상속 등 좋은 경험이 된 것 같습니다.
'경험과 지식' 카테고리의 다른 글
제네릭, 와일드카드 처음부터 끝까지 이해하기[with PECS, <E extends Comparable<? super E>> E max(Collection<? extends E> c)] (0) | 2023.12.27 |
---|---|
에러 FOR UPDATE cannot be applied to the nullable side of an outer join(postgres) (0) | 2023.12.11 |
트랜잭션 격리수준 4단계 테스트(mysql innoDB) (2) | 2023.12.10 |
JPA N+1문제와 해결방안(2) (0) | 2023.04.10 |
JPA N+1문제와 해결방안(1) (0) | 2022.03.25 |