Node.js/NestJS

[NestJS] Passport.js와 session을 사용해 로그인 인증 구현하기

서크호 2023. 9. 3. 16:47

전제 조건

- cookieParser 를 임포트해 main.ts에 설정을 추가해주었다. : app.use(cookieParser();

- 회원가입 서비스 : ex) signUp, join (메서드 명은 당연히 자유)와 같은 서비스 메서드를 만들어 db에 등록이 가능하다.

- 유저 조회 서비스 : ex) findOne, getUser (메서드 명은 당연히 자유) 와 같은 서비스 메서드를 만들어 가입여부 체크가 가능하다.

 

왜 쓸까?

사용자의 브라우저에 쿠키만 가지고 인증을 하면 위변조, 탈취의 위험이 있다.

그래서 서버에서 인증하고 인증 정보를 서버의 특정 공간에 저장해두는게 바람직하다. => 이게 세션

 

쿠키 = 세션을 찾는 정보 : ex) 유저아이디, 유저이메일 같이 단순한 식별 정보만 저장

세션 = 인증에 필요한 중요한 정보 : ex) 패스워드 같은 중요 정보만 사용 

의 구조를 만들어 주고 사용해보자. 서버에 부하를 주는 단점이 있지만 보안적으로는 더 안전하다!

 

Passport.js ?

- Express의 인증 미들웨어 라이브러리

- 인증 로직을 쉽게 분리해서 개발할 수 있다.

- 이 글에서는 안하지만 OAuth, jwt 를 이용한 인증에 사용도 가능하다.

- 스트래티지('전략') 이라는 개념이 있어서 뭔 소리인가 하지만 그냥 인증 로직 수행 클래스(인증 방법) 이라고 이해하면 될 것 같다.

- 이 글에서는 username(아이디), userpassword(비번) 를 이용하는 LocalStrategy 클래스를 만들어 사용한다. 

간단한 구조

1. 가드에서 LocalStrategy 클래스에 id, pw로 사용자 검증 요청

2. LocalStrategy에서 Session 에 있는 인증 정보 요청

3. Session에 있는 정보를  LocalStrategy에 전송

4. LocalStrategy가 세션에 있는 정보를 확인해 인증 유무를 가드에 전달

5. 이 구조를 모두 구현할 필요 없이 라이브러리로 편하게 불러올 수 있으니 설정 파일만 조금 바꿔주면 된다!

 

각각의 class 이다, 시작 : 로그인 요청

시작

1. 각종 디펜던시를 설치한다.

- dependencies

    "@nestjs/passport",
    "express-session",
    "passport",
    "passport-local"

 

-devDependencies

    "@types/express-session",
    "@types/passport-local",

 

2. 세션을 사용하기 위해 main.ts 파일을 수정

- session, passport 를 import하는 부분, app.use로 사용하는 부분만 보면 된다.

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import * as dotenv from 'dotenv';
import * as path from 'path';
import { ValidationPipe } from '@nestjs/common';
import * as cookieParser from 'cookie-parser';
import * as session from 'express-session';
import * as passport from 'passport';

dotenv.config({
  path: path.resolve(
    process.env.NODE_ENV === 'real'
      ? '.real.env'
      : process.env.NODE_ENV === 'stage'
      ? '.stage.env'
      : '.dev.env',
  ),
});
async function bootstrap() {
  console.log(process.env.NODE_ENV);
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe());
  app.use(cookieParser());
  app.use(
    session({
      secret: process.env.SESSION_SECRET_KEY, // 세션 암호화에 사용하는 키로 절대 노출되서는 안된다.
      resave: false, // 세션을 항상 저장할 지 여부라 일단 false
      saveUninitialized: false, // 세션이 저장되기 전에는 초기화 하지 않은 상태로 세션을 미리 만들어 저장할지? 일단 false
      cookie: { maxAge: 3600000 }, // 쿠키 유효시간 = 일단 1시간 주었다.
    }),
  );
  app.use(passport.initialize());
  app.use(passport.session());
  await app.listen(3000);
}
bootstrap();

 

3. 가드 및 스트래지 파일 만들어 놓기

 

 

네이밍은 자유다!

 

4. auth.guard.ts 작성

- LocalAuthGuard 는 로그인 시 사용할 가드이다.

- AuthenticatedGuard 는 로그인 후 인증 확인 용 가드이다.

 

- AuthGuard 라이브러리 코드로 타고 가보면 저 'local'이 타입이라는걸 알 수 있다.

- 로컬 스트래티지를 사용하기 위해 'local' 이라는 인자를 던져준다.

- 가드를 사용하려면 반드시 canActivate 메서드를 구현해야 한다.

- super.canActivate 는 passport-local의 로직을 구현한 메서드를 실행한다. Local.strategy.ts에 작성한다.

- super.login 은 로그인 처리를 하고 세션을 저장한다. 저장하고 꺼내오는 방법은 session.serializer.ts 에 정의한다.

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

@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {
  async canActivate(context: any): Promise<boolean> {
    const result = (await super.canActivate(context)) as boolean;
    const request = context.switchToHttp().getRequest();
    await super.logIn(request);			// 세션 저장
    return result;
  }
}

@Injectable()
export class AuthenticatedGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    return request.isAuthenticated(); // 세션에서 정보를 읽어서 인증 확인
  }
}

 

5. session.serializer.ts 작성

 

- PassportSerializer 를 상속받는데 serializeUser, deserializerUser 는 필수로 정의해줘야 한다. 

- 세션에 저장할 때 최소한의 정보이자 중복 불가 값인 USER_ID만 넣어준다.

- 세션에서 정보를 꺼내 올 떄 유저가 있다면 USER_PW를 뺀 나머지 정보만 리턴해준다. (굳이 구조분해 할당한 이유)

import { Injectable } from '@nestjs/common';
import { PassportSerializer } from '@nestjs/passport';
import { UserService } from 'src/user/user.service';

@Injectable()
export class SessionSerializer extends PassportSerializer {
  constructor(private userService: UserService) {
    super();
  }

  // 세션 저장 메서드
  serializeUser(user: any, done: (err: Error, user: any) => void): any {
    done(null, user.USER_ID);
  }
  
  // 세션에서 정보를 꺼내올 때 사용하는 메서드
  async deserializeUser(
    payload: any,
    done: (err: Error, payload: any) => void,
  ): Promise<any> {
    const user = await this.userService.findById(payload);
    if (!user) {
      done(new Error('유저가 없습니다'), null);
      return;
    }
    const { USER_PW, ...userInfo } = user;
    done(null, userInfo);
  }
}

 

6. local.strategy.ts 작성

- id, pw 를 이용해 인증하기에 'passport-local' 패키지를 불러온다.

- PassportStrategy(Strategy) -> 믹스인으로 클래스의 일부만 확장시킨다.

- 지금 만드는 클래스는 constructor의 기본값이 usernameField='username', passwordField='password' 이다. 이 말이 무슨 말이냐면 클래스가 생성되고 안에 메서드를 실행할 때 따로 설정을 해주지 않으면 들어오는 인자 ( {} ) 의 'username' , 'password'로 검증을 하기 때문에 내가 쓰는 키 명으로 바꿔주었다. 자신이 만드는 서비스의 Input req가 'username', 'password' 면 그대로 둬도 상관 없다!

- 밑에 validate 메서드로 유저의 정보가 있으면 그대로 유저의 정보를 반환한다.

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

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private authService: AuthService) {
    super({ usernameField: 'USER_ID', passwordField: 'USER_PW' });
  }

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

 

7. auth.module.ts 설정 추가

- 위에 코드들을 처음보면 무슨 소리인가 싶겠지만 한 두 번 백지에서 시작하다보면 어느정도 감이 잡힐 것이다.

- 일단 인증 모듈에 임포트랑, 프로바이더 수정해주자

- 패스포트 모듈의 세션 설정이 기본이 false라 true로 바꿔준다.

- LocalStrategy, SessionSErializer 모두 다른 곳에서 사용하는 클래스라 프로바이더에서 등록해줘야 한다. 이거 안해주면 에러난다.

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UserModule } from 'src/user/user.module';
import { PassportModule } from '@nestjs/passport';
import { LocalStrategy } from './local.strategy';
import { SessionSerializer } from './session.serializer';

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

 

8. 테스트해보자.

- auth.controller.ts 에 사용할 라우터 추가해주자.

- sessionLoginTest = 우리가 만든 로그인 가드를 잘타서 정보가 인증이 정상 완료된 사용자면 그대로 유저 정보가 리턴 된다.

- sessionAuthTest = 로그인 된 사용자는 이 주소로 요청하면 그대로 유저 정보를 또 볼 수 있다.

// auth.controller.ts

@UseGuards(LocalAuthGuard)
  @Post('/sessionLoginTest')
  login3(@Request() req) {
    return req.user;
  }

  @UseGuards(AuthenticatedGuard)
  @Get('/sessionAuthTest')
  testGuardWithSession(@Request() req) {
    return req.user;
  }

 

- 테스트에 대한 결과값은 글을 잘 읽어봤다면 알 것이다 ㅎㅎ

- rest-client를 이용한 테스트에 작성 예시이다.

- 혹시나 안되면 처음부터 천천히 다시 해보길 바란다. 생소한 메서드들이 많아 헷갈릴 수도 있다.

### 세션 로그인 - 맞는 계정으로 시도
POST http://localhost:3000/auth/sessionLoginTest HTTP/1.1
Content-Type: application/json

{
    "USER_ID": "회원가입 되어 있는 아이디",
    "USER_PW": "회원가입 되어 있는 비밀번호"
}

### 세션 로그인 - 틀린 계정으로 시도
POST http://localhost:3000/auth/sessionLoginTest HTTP/1.1
Content-Type: application/json

{
    "USER_ID": "회원가입 되어 있지 않은 아이디",
    "USER_PW": "회원가입 되어 있지 않은 비밀번호"
}

### 로그인 되어 있는 상태로 들어오는게 맞는지 아닌지 테스트
GET http://localhost:3000/auth/sessionAuthTest HTTP/1.1