들어가기전에…

지난 장에서 쿠키 방식의 인증을 구현해보았다. 하지만, 쿠키만으로 인증하면 위변조와 탈취의 위험에서 자유롭지 못하다. 좋은 방법은 쿠키리스 방식, 즉 서버에서 인증을 하고 해당 정보를 서버의 특정 공간에 저장해두는 ‘세션’방식이 있다.

세션을 사용할 때도 쿠키를 사용하지만, 쿠키는 세션을 찾는 정보만 저장(예: 세션의 아이디)하고, 중요 정보는 세션에 모두 넣는데, 이러한 세션 방식에 대해 익히고 직접 구현해보려고 한다.

추가 되는 것들…

지난 장에서는 가드 하나를 로그인과 인증 모두에서 사용했다. 하지만, 이번 절에서는 가드 두 개와 인증을 처리하기 위한 파일 여러개를 만든다.

인증 로직 구현 부분은 ‘패스포트’라는 인증 로직을 쉽게 분리해서 개발하는 라이브러리를 사용한다. 패스포트 사용 시 인증 로직은 ‘스트래티지’ 파일을 생성해서 사용한다.

패스포트에서 스트래티지는 인증 로직 수행을 담당하는 클래스를 의미한다.

기존과 달라질 점은 가드 안에 인증 로직을 두는 것이 아니라, 인증 로직을 처리하는 별도의 스트래티지 파일이 푤아하다. 예를 들어, id, password를 주었을 때 올바른 정보인지 판단하는 로직이나, 쿠키에서 값을 읽어서 인증을 위한 올바른 데이터가 들어 있는지 등을 검증하는 로직들 말이다.

또한 세션 사용 시 세션에서 데이터를 읽어오고 저장하므로 세션에 데이터를 저장하고 읽어올 ‘세션 시리얼라이저 파일’도 필요하다.

정리하자면, 1. 가드, 2. 패스포트, 3. 스트래티지, 4. 세션 시리얼라이저가 서로 협력해서 사용자 신원을 확인하고, 인증 정보롤 저장하고 읽어와서 다시 인증하는 작업을 한다.

필자가 말하길 이러한 구조가 복잡하지만 이해하면 분담이 잘되서 유지보수에 유리하다는데, 한번 살펴보자

image-20230831082444472

<참조: p.410(패스포트와 세션을 사용한 인증 과정) Node.js 백엔드 개발자 되기, 골든래빗, 박승규 지음>

1번. 가드를 통과한 요청은 스트래티지에 전달되어 사용자 신원 검증을 한다

2번. 스트래티지는 세션 정보를 읽기 위해 세션 시리얼라이저 세션에 있는 인증 정보를 요청한다

3번. 세션 시리얼라이저는 세션에 있는 인증 정보를 스트래티지에 돌려준다

4번 스트래티지는 세션에 있는 인증 정보를 확인해 사용자 인증 유무를 가드에 결괏값으로 변환한다

그래서, 다음과 같은 순서로 진행된다

  1. 라이브러리 설치 및 설정
  2. 로그인과 인증에 사용할 가드 구현
  3. 세션 시리얼라이저 구현
  4. 로컬 스트래티지 파일 만들기
  5. auth.module.ts 설정 추가
  6. 컨트롤러에 라우팅 메서드 추가
  7. 테스트

1. 라이브러리 설치 및 설정

순서 1. passport 라이브러리 설치

유저 아이디와 패스워드로 인증할 때 스트래티지는 passport-local을 사용한다. 이 때, passport-local은 username과 password로 인증할 수 있는 전략을 사용하는 모듈이다.

세션 저장에는 express-session을 사용한다.

@types/passport-local 과 @types/express-session은 타입스크립트의 타입 정보를 담고 있는 라이브러리이다.

npm i @nestjs/passport passport passport-local express-session
npm i -D @types/passport-local @types/express-session

순서 2. 세션 사용위해 main.ts 파일에 설정을 추가

// src > main.ts

... 생략 ...

import * as session from 'express-session'; // 임포트 추가
import * as passport from 'passport'; // 임포트 추가

async function bootstrap() {
  ... 생략 ...
  app.use(
    session({
      secret: 'very-important-secret', // 1. 세션 암호화에 사용되는 키
      resave: false, // 2. 세션을 항상 저장할지 여부
      // 3. 세션이 저장되기 전에는 초기화하지 않은 상태로 세션을 미리 만들어 저장
      saveUninitialized: false,
      cookie: { maxAge: 3600000 }, // 4. 쿠키 유효기간 1시간
    })
  );
  // 5. passport 초기화 및 세션 저장소 초기화
  app.use(passport.initialize());
  app.use(passport.session());
  // ... 5
  await app.listen(3000);
}
bootstrap();

1번. 내부적으로 세션은 암호화를 하는데 사용하는 키. 절대 유출되지 않도록 관리해야 함

2번. resave는 세션을 항상 저장할지 여부를 나타냄. HTTP 요청이 올 때마다 세션을 새로 저장하면 효율이 떨어질 수 있으므로 false로 설정

3번. saveUninitialized 옵션은 세션이 저장되기 전에 빈 값을 저장할지 여부를 나타냄. 인증이 되지 않은 사용자 정보도 빈 값으로 저장하므로 false로 설정해 불필요한 공간을 차지하지 않게 함

4번. 세션을 찾는데 사용할 키값을 쿠키에 설정. 해당 쿠키의 유효기간을 1시간으로 정함. 단위는 ms

5번. 패스포트 초기화 및 세션 저장소를 초기화.세션의 저장소를 따로 지정하지 않았으므로 서버의 메모리에 저장


2. 로그인과 인증에 사용할 가드 구현

image-20230831095933940

로그인에 사용할 가드(LoginAuthGuard)와 로그인 후 인증에 사용할 가드(AuthenticatedGuard)를 별개로 만들어서 사용한다.

  • LoginAuthGuard: HTTP 요청으로 받은 email과 password 정보로 유효한 user 정보가 있는지 확인해, 유효할 경우 유저의 정보를 세션에 저장한다.
  • AuthenticatedGuard: HTTP 요청에 있는 쿠키를 찾아 쿠키에 있는 정보로 세션을 확인해 로그인이 완료된 사용자인지 판별한다.

순서 1. 로그인용 가드와 인증용 가드 추가

auth.guard.ts에 로그인 시 사용할 가드(LocalAuthGuard)와 로그인 후 인증 확인 시 사용할 가드(AuthenticatedGuard)를 아래와 같이 작성한다

// src > auth > auth.guard.ts

import { CanActivate, Injectable, ExecutionContext } from "@nestjs/common"; // 임포트 추가
import { AuthGuard } from "@nestjs/passport"; // 1. 패스포트를 사용하는 AuthGuard 임포트
... 생략 ...

@Injectable()
export class LoginGuard implements CanActivate {
	... 생략 ...
}

@Injectable()
// 2. AuthGuard 상속
export class LocalAuthGuard extends AuthGuard('local') {
    async canActivate(context: any): Promise<boolean> {
        const result = (await super.canActivate(context)) as boolean;
        // 3. 로컬 스트래티지 실행
        const request = context.switchToHttp().getRequest();
        await super.logIn(request); // 4. 세션 저장
        return result;
    }
}

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

1번. NestJS에서는 패스포트를 편하게 사용하도록 @nestjs/passport를 제공한다. 패스포트 인증에 가드를 사용할 수 있도록 감싸둔 AuthGuard를 제공하는 라이브러리다

2번. 패스포트는 인증 로직을 스트래티지라는 개념으로 구현한다. id, password로 인증을 처리할 때는 passport-local을 사용한다. AuthGuard(‘local’)은 로컬 스트래티지를 사용한다. 이외의 스트래티지로 passport-jwt와 passport-google-oauth20 등이 있다.

3번. 가드를 사용하려면 canActivate를 구현해야 한다. AuthGuard를 상속받았으니 super.canActivate()에서는 passport-local의 로직을 구현한 메서드를 실행한다. 로직을 구현한 메서드(local.stretegy.ts) 파일은 뒤에서 구현하겠다

4번. super.login() 에서는 로그인을 처리하는데, 여기서 세션을 저장한다. 세션을 저장하고 꺼내오는 방법은 session.serializer.ts 파일에 작성한다

5번. AuthenticatedGuard는 로그인 후 인증이 되었는지 확인할 때 사용한다. 세션에 데이터를 저장하고 돌려주는 응답(response) 값에 connect.sid라는 이름의 쿠키를 만든다. 이후의 요청에 해당 쿠키값을 같이 전송하면 세션에 있는 값을 읽어서 인증 여부를 확인할 때 사용하는 가드다


3. 세션에 정보를 저장하고 읽는 세션 시리얼라이저 구현

image-20230831100131374

직전에 작성한 request.isAuthenticated() 함수는 세션에서 정보를 읽어온다. 아직은 세션에 정보 저장과 정보 읽기 기능을 구현하지 않았으므로 올바르게 동작하지 않는다. 세션 저장 및 읽기를 위한 코드를 작성한다

// src > auth > session.serializer.ts

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

@Injectable()
// 1. PassportSerializer 상속 받음
export class SessionSerializer extends PassportSerializer {
    constructor(private userService: UserService) { // 2. userService를 주입받음
        super();
    }

    // 3. 세션에 정보를 저장할 때 사용
    serializeUser(user: any, done: (err: Error, user: any) => void): any {
        done(null, user.email); // 세션에 저장할 정보
    }

    // 4. 세션에서 정보를 꺼내올 때 사용
    async deserializeUser(
        payload: any,
        done: (err: Error, payload: any) => void,
    ): Promise<any> {
        const user = await this.userService.getUser(payload);
        // 5. 유저 정보가 없는 경우 done() 함수에 에러 전달
        if (!user) {
            done(new Error('No User'), null);
            return;
        }
        const { password, ...userInfo } = user;

        // 6. 유저 정보가 있다면 유저 정보 반환
        done(null, userInfo);
    }
}

1번. SessionSerializer는 PassportSerializer를 상속받는다. PassportSerializer는 serializeUser(), deserializeUser(), getPassportInstance()를 제공하는데 여기서는 ‘serializeUser()’, ‘deserializeUser()’만 사용한다.

  • serializeUser() : 세션에 정보를 저장한다
  • deserializeUser(): 세션에서 가져온 정보로 유저 정보를 반환한다
  • getPassportInstance(): 패스포트 인스턴스를 가져온다. 패스포트 인스턴스의 데이터가 필요한 경우 사용한다

2번. 세션에는 유저를 식별하는데 사용할 최소한의 정보인 email만 저장한다. UserService에서 email로 유저 정보를 가져와야 하므로 UserService를 주입한다

3번. serializeUser()는 세션에 정보를 저장할 때 사용한다. user 정보는 LocalAuthGuard의 canActivate() 메서드에서 super.login(request)를 호출할 때 내부적으로 request에 있는 user 정보를 꺼내서 전달하면서 serializeUser()를 실행한다.

4번. deserializeUser()는 인증이 되었는지 세션에 있는 정보를 가지고 검증할 때 사용한다. payload는 세션에서 꺼내온 값이며 세션의 값을 확인해보려면 console.log(request.session)으로 확인할 수 있다. serializeUser()에서 email만 저장했기 때문에 해당 정보가 payload로 전달된다. 식별하는 데 email만 있으면 되기 때문에 userService.getUser(payload)로 해당하는 유저가 있는지 확인할 수 있다

5번. 유저 정보가 없으면 매개변수로 받은 done() 메서드에 Error를 전달한다. 세션 정보가 없으면 403 에러를 발생시킨다

6번. 유저 정보가 있다면 패스워드를 뺀 나머지 정보를 전달한다

여기까지 시리얼라이저를 구현해 세션에 값을 저장하고 가져올 수 있게 되었다. 이어서 AuthGuard(‘local’)의 실제 인증 로직을 담는 LocalStrategy를 만든다


4. email, password 인증 로직이 있는 LocalStrategy 파일 만들기

image-20230831100059146

인증 방법은 다양하다. 다양한 방법을 패키지 하나에 담을 필요는 없기 때문에 패스포트에서는 이를 strategy라는 별개의 패키지로 모두 분리하여 담는다. 이중 id, password로 인증하는 기능은 passport-local 패키지에서 제공한다. 아래 표는 인증 유형별 스트래티지 이다.

인증 방법 패키지명 설명
Local passport-local 유저명과 패스워드를 사용해 인증
OAuth passport-oauth 페이스북, 구글, 트위터 등의 외부 서비스에서 인증
SAML passport-saml SAML 신원 제공자에서 인증, OneLogin, Okta 등
JWT passport-jwt JSON Web Token을 사용해 인증
AWS Cognito passport-cognito AWS의 Cognito user pool을 사용해 인증
LDAP passport-ldapauth LDP 디렉터리를 사용해 인증

<참조: p.416(패스포트와 세션을 사용한 인증 과정) Node.js 백엔드 개발자 되기, 골든래빗, 박승규 지음>

순서 1. email, password 인증 로직이 있는 LocalStrategy 파일을 작성

NestJS에서는 PassportStrategy(Strategy)를 상속받은 클래스에 인증 로직을 작성한다.

// src > auth > local.strategy.ts

import { Injectable } 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) { // 1. PassportStrategy 믹스인
    constructor(private authService: AuthService) {
        super({ usernameField: 'email' }); // 2. 기본값이 username이므로 email로 변경해줌
    }

    // 3. 유저 정보의 유효성 검증
    async validate(email: string, password: string): Promise<any> {
        const user = await this.authService.validateUser(email, password);
        if (!user) {
            return null; // 4. null이면 401 에러 발생
        }
        return user; // 5. null이 아니면 user 정보 반환
    }
}

1번. PassportStrategy(Strategy)는 믹스인이라고 불리는 방법이다. 컴포넌트를 재사용할 때 상속을 많이 사용하지만 해당 클래스의 모든 것을 재사용해야 하는 불편함이 있다. 클래스의 일부만 확장하고 싶을 때는 믹스인을 사용한다

※ 믹스인(mixin) / 트레잇(trait): 클래스에 새로운 기능을 추가하기 위해, 필요한 메서드를 가지고 있는 작은 클래스들을 결합해 기능을 추가하는 방법

2번. local-strategy에는 인증 시 사용하는 필드명이 username, password로 정해져있다. 그런데, 실습에서는 username이 아니라 email, password로 인증하게 되므로 usernameField 이름을 email로 바꿔준다

3번. validate() 메서드에서는 전달한 email과 password가 올바른지 검증한다. 이미 만들어둔 authService의 validateUser() 메서드를 사용한다

4번. 유저가 없으면 즉 user가 null이면 null을 반환한다. 이때 클라이언트에 401 에러를 보낸다

5번. 유저 정보가 있으면 validateUser()에서 받은 user 정보를 반환한다

지금까지 인증과 세션 저장, 읽기 코드를 모두 완성했다. 마지막으로 만들어둔 파일들의 설정을 auth.modules.ts에 추가한다


5. auth.module.ts에 설정 추가하기

만들어둔 LocalStrategy, SessionSerializer를 다른 클래스에서 사용할 수 있게 프로바이더에 등록한다. 그리고 PassportModule에 세션을 사용하도록 설정한다

// src > auth > auth.module.ts

import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { UserModule } from 'src/user/user.module';

import { PassportModule } from '@nestjs/passport'; // 추가
import { SessionSerializer } from './session.serializer'; // 추가
import { LocalStrategy } from './local.strategy'; // 추가

@Module({
  // 1. 패스포트 모듈(PassportModule.register()) 추가
  imports: [UserModule, PassportModule.register({ session: true })],
  // ... 1
  controllers: [AuthController],
  // 2. 프로바이더(LocalStrategy, SessionSerializer) 설정 추가
  providers: [AuthService, LocalStrategy, SessionSerializer]
  // ... 2
})
export class AuthModule { }

1번. PassportModule의 기본 설정은 세션 설정이 false로 되어 있다. {session: true}를 사용해 세션을 사용할 수 있게 한다

2번. LocalStrategy와 SessionSerializer 모두 다른 곳에서 사용하는 클래스들이므로 프로바이더로 등록해야 한다. 등록하지 않으면 클래스를 찾지 못해서 에러가 난다


6. 컨트롤러에 라우팅 메서드 추가

auth.controller.ts에 라우팅 메서드를 추가한다

// src > auth > auth.controller.ts

... 생략 ...
import { LoginGuard, AuthenticatedGuard, LocalAuthGuard } from './auth.guard'; // 추가

	... 생략 ...
    
    // 1. 세션 로그인
    @UseGuards(LocalAuthGuard)
    @Post('login3')
    login3(@Request() req) {
        return req.user;
    }

    // 2. 세션 인증
    @UseGuards(AuthenticatedGuard)
    @Get('test-guard2')
    testGuardWithSession(@Request() req) {
        return req.user;
    }
}

7. 테스트

image-20230831135807950

image-20230831135924502


image-20230831140010385


섹션 방식의 회원가입과 로그인 정리

image-20230831142344134

<참조: p.410(패스포트와 세션을 사용한 인증 과정) Node.js 백엔드 개발자 되기, 골든래빗, 박승규 지음>

1번. 유저가 서버에 login 요청을 보낸다

2번. 유저가 보낸 요청은 AuthController에 있으며 @UseGuards(LocalAuthGuard) 데코레이터 붙어있다. LocalAuthGuard가 먼저 실행된다. Guard이므로 canActivate() 메서드가 구현되어 있으며, LocalAuthGuard는 AuthGuard(‘local)을 상속받았으므로 canActivate() 메서드 내에서 부모의 canActivate()를 호출하도록 super.canActivate()를 실행한다.

3번. super.canActivate()는 LocalStrategy의 validate() 메서드를 실행한다. validate() 메서드에서는 유저의 email과 password 정보를 사용해 유효한 유저인지 확인한다.

4번. LocalStrategy의 validate() 메서드는 성공하면 true를, 실패하면 401 에러를 반환한다

5번. super.logIn()을 호출한다.

6번. super.logIn()은 SessionSerializer의 serializeUSer()를 실행하며 세션에 유저 정보를 저장한다

7번. 인증 및 세션 저장이 완료되면 login3() 메서드의 몸체가 실행되어 클라이언트에게 응답값을 전송한다


핵심 용어 정리

  • SQLite: 데이터 하나의 파일에 저장되는 매우 가벼운 관계형 데이터베이스
  • 파이프: NestJS에서 유효성 검증에 사용되는 미들웨어. main.ts에 전역 pipe를 설정해 유효성 검증을 추가했다
  • 쿠키: 클라이언트(브라우저)에 인증정보를 저장하는 공간
  • JWT: Json Web Token의 약자로 모바일이나 웹에서 인증에 사용하는 암호화된 토큰을 의미
  • 세션: 서버에 유저 인증을 위한 정보를 저장하는 공간
  • 패스포트: Node.js 애플리케이션에 인증을 쉽게 붙일 수 있게 만들어주는 라이브러리
  • 가드: NestJS의 미들웨어 중 하나로 인증을 확인하는 데 사용
  • 믹스인: 클래스에 필요한 메서드를 가지고 있는 작은 클래스들을 결합해 기능을 추가한 방법

회원가입과 인증을 마무리하며…

  • 로그인용 가드와, 인증용 가드를 생성하고, 패스포트 라이브러리로 로컬-스트래티지를 작성하여, 시리얼라이즈에서 세션 정보를 넣고 빼는게 복잡했다. 쿠키를 사용할지 쿠키리스로 인증할지 고민된다. 왜냐면, 블로그를 이전할 생각인데 그 이전한 블로그의 회원가입 기능을 추가할 것이기 때문이다. 통합로그인(OAuth) 기능도 넣고 싶은데, 다음 장에서 다룬다고 하니, 거기에서 다루는게 쿠키인지 섹션인지에 따라 정해질것 같긴 하다

참고

다음에 다루게 될 것

  • OAuth를 사용한 구글 로그인 인증하기(1) - 스트래티지(Google)로 가드 설정으로 소셜 로그인

댓글남기기