Skip to content
On this page

NestJS Passport.js를 이용한 인증

수정하기
문서 생성 2022-11-09 21:23:14 최근 수정 2022-11-09 21:33:48

Passport

Passport는 가장 널리 사용되는 node.js 인증 라이브러리로 미니 프레임워크로 생각하면 된다. 한마디로 인증을 편하게 관리하기 위한 패키지

구현 중인 Strategy에 따라 커스텀할 수 있는 몇 가지 기본 단계로 인증 프로세스를 추상화하는 장점이 있다.

여기서 Strategy 란 사용자(user)의 요청(request)을 다양한 제공자(provider)를 기반으로 인증하는(authenticating) 개념, 방법을 의미한다. strategy는 여러 종류가 있다. (500개가 넘는다.) ex. passport-amazon 은 앱이 아마존 자격증명서를 통해 로그인하는 것을 허가해주는 등…)

커스터마이제이션 파라미터와 커스텀 코드를 콜백함수 형태로 제공해서 구성한다. 콜백함수는 Passport가 적절한 시간에 호출한다.

NestJS에서는 @nestjs/passport 모듈을 사용해 NestJS 스타일로 Passport를 구현할 수 있다. 그래서 Nest 응용 프로그램에 쉽게 통합이 가능하다.

Vanilla Passport

@nestjs/passport 를 알아보기 전 vanilla passport가 어떻게 작동하는지 생각해보자. 🤔

passport가 실제로 하는 일은 session 객체 내부에 passport 프로퍼티를 만들고, 값으로 쿠키와 식별자를 매칭해 저장한다.(serialize). 이후 매 요청시에 세션에 저장된 식별자를 이용해 유저의 데이터를 찾아 request.user 에 해당 데이터를 저장한다.(deserialize)

Passport Strategy 중 하나인 passport-local (https://www.passportjs.org/packages/passport-local/)는 username password로 인증하는 방식이다.

이 strategy는 콜백이 필요하다. 여기서 사용자가 존재여부 ( 또는 새 사용자를 생성하는지) 및 자격 증명이 유효한지 여부를 확인한다. 그리고 검증이 완료되면 user 를 리턴한다. (실패하면 null 을 반환한다.)

passport.use(new LocalStrategy(
function(username, password, done) {
User.findOne({ username: username }, function (err, user) {
if (err) { return done(err); }
if (!user) { return done(null, false); }
if (!user.verifyPassword(password)) { return done(null, false); }
return done(null, user);
});
}
));

router 에서 passport.authenticate(local, callback) 이 실행되면 strategy callback 이 실행되고 done 함수에 전달된 인자들이 passport.authenticate 의 콜백 인자로 전달된다.

passport.authenticate 는 자동으로 req.login() 을 호출한다. (http://www.passportjs.org/concepts/authentication/login/)

**login() 함수는 login session을 설정한다.**

로그인 작업이 완료되면 user가 req.user 에 할당된다.

router.post('/login', (req, res, next) => {
// 이 부분 실행
passport.authenticate('local', (err, user, info) => {
console.log(err, user, info);
if (err) {
console.error(err);
return next(err);
}
if (info) {
return res.status(401).send(info.reason);
}
return req.login(user, loginErr => {
if (loginErr) {
return next(loginErr);
}
const fillteredUser = { ...user.dataValues };
console.dir(fillteredUser);
delete fillteredUser.password;
return res.json(fillteredUser);
});
})(req, res, next);
});

Passport local 구현하기

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;
}
}

위 코드에는 configuration option이 없어서 constructorsuper() 만 호출하고 있다.

옵션을 전달할 수도 있다. 전달하지 않으면 기본값은 usernamepassport를 참조하고 있다.

인증을 위해 다른 속성 이름을 전달받고 싶으면 다음과 같이 할 수 있다.

super({ usernameField: 'email' })

또 코드에는 validate 함수가 구현되어 있는데 각 Strategy에 대해 Passport는 이 검증 함수를 호출한다. local-strategy의 경우 Passport는 validate 메서드에 validate(username: string, password: string): any 시그니처를 사용할 것을 기대한다.

이 코드에선 대부분의 검증 작업은 this.authService.validateUser에서 수행될 것이다.

그리고 모듈에서 Passport 기능을 사용할 수 있도록 정의해줘야 한다. *.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 {}

Guards 와 함께

Guards: route handler가 요청을 처리할지 여부를 결정한다.

생각해보면 인증 관점에서 앱은 두 가지 상태로 존재할 수 있다.

  1. user/client가 로그인되지 않은 상태 (인증되지 않음)
  2. user/client가 로그인된 상태

1번의 경우(사용자가 로그인하지 않은 상태)에는 두 가지 다른 기능을 수행해야 한다.

  • 인증되지 않은 사용자가 액세스할 수 있는 경로 제한하기 (유저 정보 수정 페이지 등…)
  • 사용자가 로그인을 시도할 때 인증 단계를 시작하기

인증을 시작하기 위해 username/password POST 요청을 해야 한다. 여기서 POST /auth/login 라우트가 그걸 처리한다고 생각해보자. 그럼 password-local strategy는 어떻게 활용해야할까?

**@nestjs/passport 모듈은 이 기능을 수행하는 빌트인 Guard를 제공한다!**

이 Guard는 Passport strategy를 실행하고 자격 증명 검색, 확인 기능 실행, 사용자 속성만들기 등을 실행한다.

그리고 인증되지 않은 사용자가 액세스할 수 있는 경로를 제헌하기 위해서도 Guard를 사용할 수 있다.

예시로 /auth/login route를 살펴보자.

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;
}
}

Passport local strategy의 디폴트 이름은 local 이다.

@UseGuard() 데코레이터에서 해당 이름을 참조해 passport-local 패키지에 의해 제공된 코드와 연결할 수가 있다.

여기서 Passport의 또 다른 기능을 확인할 수 있다. Passport는 validate() 메서드에서 반환한 값을 기반으로 user object를 자동으로 생성해서 Request object에 req.user로 할당한다.

AuthGuard는 다음과 같이 자체 클래스를 만들어 사용하는 것이 좋다고 한다.

import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}
@UseGuards(LocalAuthGuard)
@Post('auth/login')
async login(@Request() req) {
return req.user;
}

기타

Strategy 옵션 객체를 생성자에 전달해 구성하기

기본적으로 usernamepassword 를 전달해야 validate() 메서드가 실행된다. 그래서 username 대신 idemail 속성을 사용하는 경우 다음과 같이 usernameField 의 값을 해당 이름으로 변경해주면 된다. 아래 코드는 email 로 설정한 것이다.

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

Strategy에 이름 붙이기

export class GithubStrategy extends PassportStrategy(Strategy, 'github')

위와 같이 github라 이름 붙이면 @UseGuards(AuthGuard('github')) 와 같이 사용할 수 있다.

session 사용하기

사용자가 로그인할 때 사용자가 세션으로 다른 경로에 액세스할 수 잇도록 세션에 사용자를 저장해야 한다.

세션에 사용자를 저장하기 전 사용자를 serialize 해야 한다. 그리고 세션이 끝나면 사용자를 deserialize 해야 한다.

우선 세션에 대한 기본 옵션이 false 이므로 PassportModule을 추가할 때 session: true 옵션을 넣어줘야 한다.

@Module({
imports: [PassportModule.register({ session: true })],
controllers: [AuthController],
providers: [
AuthService,
UsersService,
LocalStrategy,
SessionSerializer,
GithubStrategy,
],
})
export class AuthModule {}

user 객체가 serialized / deserialized 처리를 하도록 하는 로직이 필요하다.

serialized는 사용자의 정보를 가져와 압축/최소한으로 만드는 것이다. 대부분의 경우 사용자의 id를 사용한다.

반대로 deserialized는 session에 저장된 값을 이용해 사용자를 찾은 후 HTTP Request로 리턴한다.

session.serializer.ts 를 추가해 다음과 같이 코드를 작성한다.

import { Injectable } from "@nestjs/common"
import { PassportSerializer } from "@nestjs/passport"
@Injectable()
export class SessionSerializer extends PassportSerializer {
serializeUser(user: any, done: (err: Error, user: any) => void): any {
done(null, user)
}
deserializeUser(
payload: any,
done: (err: Error, payload: string) => void
): any {
done(null, payload)
}
}

SessionSerializer를 모듈의 providers 에 추가한다.

Nest는 이걸 인스턴스화할 것이고 passport.serializeUserpassport.deserializeUser 를 호출할 것이다.

import { Module } from "@nestjs/common"
import { PassportModule } from "@nestjs/passport"
import { UsersModule } from "src/users/users.module"
import { AuthService } from "./auth.service"
import { LocalStrategy } from "./local.strategy"
import { SessionSerializer } from "./session.serializer"
@Module({
imports: [UsersModule, PassportModule.register({ session: true })],
providers: [AuthService, LocalStrategy, SessionSerializer],
})
export class AuthModule {}
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {
async canActivate(context: ExecutionContext) {
const result = (await super.canActivate(context)) as boolean;
const request = context.switchToHttp().getRequest();
await super.logIn(request);
return result;
}
}

이제 AuthGuard 클래스에서 super.logIn(request)를 호출하면 세션을 얻을 수 있다. (logIn 의 호출이 끝나면 passport.serializeUser 가 호출된다.)

로그인 여부 확인

import { CanActivate, ExecutionContext, HttpException, HttpStatus, Injectable } from '@nestjs/common';
@Injectable()
export class AuthenticatedGuard implements CanActivate {
async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest();
if(!request.isAuthenticated()) throw new HttpException('로그인 상태가 아닙니다.', HttpStatus.FORBIDDEN)
return request.isAuthenticated();
}
}

이 guard는 세션이 사용 중일 때 passport가 request 객체에 추가하는 메서드인 request.isAuthenticated() 를 호출한다.

사용자의 세션 ID가 있는 쿠키가 있기 때문에 사용자가 매 요청시 username과 password를 전달하는 대신 이 기능을 사용하면 된다.

로그아웃

로그아웃은 단순하다. req.session.destroy(); 을 호출한다.

@Get('/logout')
logout(@Request() req): any {
req.session.destroy();
return { msg: 'The user session has ended' }
}

참고자료