[TIL/Nest] 2025/04/24

원민관·2025년 4월 24일

[TIL]

목록 보기
172/201

(무단 배포 시 엄벌에 처하지 않음, 배워서 남 주자!)

Setup session based authentication with NestJS 🚀

프랑스의 소프트웨어 엔지니어 Aurélien Brabant(=>어떻게 읽는지 모름) 형님 개인 블로그에서, session based authentication 관련 글을 읽게 되었습니다.

형님께서는 JWT에 대한 훌륭한 문서는 많지만, session을 사용하여 인증을 구현하는 문서는 다소 부족하다고 느껴져서 해당 글을 쓰게 되었다고 합니다. 저도 형님의 의견에 동의하는 바입니다.

형님의 글에 저의 의견도 덧붙여 완성된 형태의 글을 작성해 보겠습니다.

혹여, 제가 참고한 원문을 보고 싶으시다면 다음 링크를 참조해 주세요. 당연히 프랑스어는 아닙니다.

reference: https://aurelienbrabant.fr/blog/session-based-authentication-with-nestjs#authentication-stack

1. Authentication stack ⚙️

인증 시스템을 구축하기 위해, 사용자를 인증하고 자동으로 직렬화 및 역직렬화(자세한 내용은 후에 설명)하는 데 도움을 줄 Passport 라이브러리를 사용할 것이라고 합니다. 또한 NestJS와 잘 통합되도록 다양한 유틸리티를 제공하는 @nestjs/passport 라이브러리도 사용할 예정이라고 하네요.

2. Application boilerplate ⚙️

2-1. Creating a Users module ✍️

Nest CLI를 사용하여 가상의 사용자 목록을 관리할 새로운 모듈과 서비스를 생성합니다.

nest g module users
nest g service users

하드코딩 된 사용자 목록이 있고, 해당 사용자들을 조회하는 기능이 포함된 서비스 로직을 작성합니다.

import { Injectable } from '@nestjs/common';

export interface User {
  username: string;
  password: string;
}

@Injectable()
export class UsersService {
  private readonly mockedUsers: User[] = [
    {
      username: 'Alice',
      password: 'pass',
    },
    {
      username: 'Bob',
      password: 'pass',
    },
  ];

  findByUsername(username: string): User | null {
    return this.mockedUsers.find((user) => user.username === username) ?? null;
  }
}

user에 대한 서비스 로직을 작성했으니, 해당 서비스를 모듈에 등록합시다.

import { Module } from '@nestjs/common';
import { UsersService } from './users.service';

@Module({
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

2-2. Creating an authentication module ✍️

인증 절차를 관리할 모듈을 포함한 관련 보일러 플레이트를 Nest CLI를 사용하여 생성합니다.

nest g module auth
nest g service auth
nest g controller auth

이제 validateUser() 함수를 생성할 것입니다. 유효한 사용자인지를 확인하는 역할을 합니다. 사용자보다는 사용자 인증과 더 밀접한 관련이 있으므로, UsersService가 아닌 AuthService에 명시적으로 구현하는 것이 더욱 적절하겠습니다.

import { Injectable, UnauthorizedException } from '@nestjs/common';
import { User, UsersService } from 'src/users/users.service';

@Injectable()
export class AuthService {
  constructor(private readonly usersService: UsersService) {}

  validateUser(username: string, password: string): User {
    const user = this.usersService.findByUsername(username);

    if (!user || user.password !== password) {
      throw new UnauthorizedException('Invalid credentials');
    }

    return user;
  }
}

방금 전 유저 서비스에 작성했던 findByUsername() 함수를 활용해서 유저에 대한 validation을 진행합니다.

그런데 이런 방식으로 사용자를 저장하고 비밀번호를 비교하는 것은 보안상 매우 취약하며, 절대 프로덕션 환경에서는 사용해서는 안 됩니다.

일반적으로는, 비밀번호를 데이터베이스에 저장하기 전에 해시 처리하고, 사용자가 로그인할 때 입력한 평문 비밀번호를 동일한 해시 알고리즘으로 처리한 후 저장된 해시값과 비교하게 됩니다.

3. Passport integration ⚙️

Passport는 strategy를 통해 인증 프로세스를 처리하는 라이브러리로, 요청 객체에 여러 유용한 함수와 데이터를 주입해 주는 기능도 함께 제공합니다.

몇 가지 의존성을 설치해야 하겠습니다.

npm install @nestjs/passport passport passport-local

타입스크립트를 사용 중이기에 다음 의존성들도 설치합니다.

npm install --save-dev @types/passport @types/passport-local

3-1. Creating our local strategy ✍️

Passport Strategy를 구현할 차례입니다.

@nestjs/passport에서 제공하는 PassportStrategy 유틸리티 클래스를 상속받는, 새로운 Injectable 클래스(=LocalStrategy)를 생성합니다.

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) {
  constructor(private readonly authService: AuthService) {
    super();
  }
}

기본값인 username 대신 요청 본문에서 email 필드를 사용하도록 passport에 지시하려면, 아래와 같이 super를 호출할 수 있습니다.

super({
    usernameField: 'email'
})

이제 auth service에서 구현했던 validateUser()를 LocalStrategy 클래스에 통합해 봅시다.

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly authService: AuthService) {
    super();
  }

  validate(username: string, password: string): User {
    return this.authService.validateUser(username, password);
  }
}

3-2. Configuring the AuthModule ✍️

3-2-1. Providing the strategy ✅

LocalStrategy를 AuthModule에 등록합시다.

import { Module } from '@nestjs/common';
import { UsersModule } from '../users/users.module.ts';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { LocalStrategy } from './local.strategy';

@Module({
    imports: [UsersModule],
  providers: [AuthService, LocalStrategy],
  controllers: [AuthController],
})
export class AuthModule {}

UsersService의 의존성 주입이 제대로 작동하려면, UsersModule도 import 되었는지 확인해야 합니다.

3-2-2. Importing the Passport module ✅

전체 Passport 설정이 제대로 작동하려면, @nestjs/passport 패키지에서 export된 PassportModule을 import 해야 합니다.

import { PassportModule } from '@nestjs/passport';

@Module({
    imports: [PassportModule]
})

현재는 세부적인 세션 설정이 되어 있지 않기 때문에 위 방법이 작동할 수 있지만, 이것만으로 충분한 것은 아닙니다.

실제 세션을 다룰 것이라고 Passport 모듈에 지시하고, 이를 위해 내부적으로 몇 가지 설정을 조정할 수 있게 해야 합니다. register라는 정적 메서드를 호출하는 방식을 적용하면 되겠습니다.

@Module({
    imports: [
        PassportModule.register({
            session: true
        })
    ]
})

3-3. Authentication with passport using NestJS guards ✍️

auth.controller.ts에 대한 설정을 해보겠습니다.

import { Controller, Get, Post } from '@nestjs/common';

@Controller('auth')
export class AuthController {
  @Post('login')
  login() {
    /* we are inside this method when user logged in successfully using username and password */
  }

  @Post('logout')
  logout() {
    /* destroys user session */
  }

  @Get('protected')
  protected() {
    return {
      message: 'This route is protected against unauthenticated users!',
    };
  }
}

3-3-1. Using the AuthGuard ✅

어떤 라우트에서든 Strategy를 사용하여 Passport 인증을 요청하려면, @nestjs/passport 패키지에서 export된 AuthGuard를 사용합니다.

@UseGuards(AuthGuard('local'))
@Post('login')
login(@Req() request: any) {
  return request.user; /* the user we are returning in our local strategy's validate method
}

요청을 진행해 봅시다.

curl -X POST -H 'Content-Type: application/json' -d '{ "username": "Alice", "pass": "pass" }' http://localhost:3000/auth/login

4. Storing the authentication state using sessions ⚙️

사용자가 사용자 이름과 비밀번호로 인증을 완료했다면, 저는 이제 로그인 정보를 일정 기간 동안 어딘가에 저장하고 싶습니다.

NestJS 공식 문서에서는 이를 위해 JSON 웹 토큰(JWT)을 사용합니다. JWT는 클라이언트 측에 저장되며(쿠키나 브라우저의 로컬 스토리지에), 상태가 없다는 뜻의 stateless 특성을 가지기 때문에 한 번 발급된 후에는 취소할 수 없습니다.

JWT와 달리, 세션은 서버 측의 세션 저장소에 저장됩니다. 세션의 특성상, 세션 저장소에서 세션을 삭제하기만 하면 세션을 취소할 수 있어 더 유연한 방식입니다. 세션을 사용한 지속적인 인증 구현에 집중하겠습니다.

본 글에서는 기본적인 express-session 저장소(애플리케이션의 메모리)를 사용하고 있습니다. 해당 방식이 보안에 취약하고, 프로덕션 환경에서 사용하기에는 적합하지 않다는 것은 인지하셔야겠습니다.

4-1. Using a session middleware ✍️

우선 필요한 의존성을 설치합니다.

npm install express-session
npm install --save-dev @types/express-session

이후에는 AuthModule에 대한 추가 설정이 필요합니다.

/* ... */
import { SessionModule } from 'nestjs-session';

@Module({
  imports: [
    UsersModule,
    PassportModule.register({
            session: true
        })
  ],
  providers: [AuthService, LocalStrategy],
  controllers: [AuthController],
})
export class AuthModule implements NestModule {
    configure(consumer: MiddlewareConsumer) {
        consumer
          .apply(
            expressSession({
              secret: 'SOME SESSION SECRET',
              resave: false,
              saveUninitialized: false,
            }),
            passport.session(),
          )
          .forRoutes('*');
      }
}    

passport.session 미들웨어는 요청 객체에 몇 가지 유용한 함수를 주입하고, 해당 세션이 인증된 사용자에 속하는지 확인하는 역할을 합니다.

세션이 실제로 생성되었는지 확인하려면, 다음과 같은 코드를 라우트 핸들러에 추가하면 됩니다.

import { Session } from '@nestjs/common';
import { Session as ExpressSession } from 'express-session';

@Controller('somewhere')
export class SomeController {
    @Get()
    someMethod(@Session() session: ExpressSession) {
        return session;
    }
}

만약 모든 설정이 올바르게 완료되었다면, 해당 라우트를 호출했을 때 다음과 같은 JSON 응답을 받아야 합니다.

{
  "cookie": {
    "originalMaxAge": null,
    "expires": null,
    "httpOnly": true,
    "path": "/"
  }
}

지금까지 설정된 내용을 보면, 세션에 사용자 정보가 포함되지 않았기 때문에, 응답에 사용자 데이터가 포함되지 않습니다. passport가 세션에 사용자 데이터를 첨부하도록 명시하지 않았기 때문입니다. 현재 상태에서는 이 세션이 인증되었는지를 알 수 없습니다.

4-2. Create a user session ✍️

4-2-1. User serialization and deserialization

먼저, 세션을 사용할 때는 passport가 사용자 정보를 직렬화(serialize)하고 역직렬화(deserialize)하는 방법을 알아야 하므로, PassportSerializer를 만들어야 합니다. 이 작업은 세션에 사용자 데이터를 저장하고, 요청 시 해당 데이터를 복원하는 데 필요합니다.

src/auth/user.serializer.ts 파일에 새로운 passport serializer를 생성하는 방법은 다음과 같습니다.

import { Injectable } from '@nestjs/common';
import { PassportSerializer } from '@nestjs/passport';
import { User, UsersService } from '../users/users.service';

@Injectable()
export class UserSerializer extends PassportSerializer {
  constructor(private readonly usersService: UsersService) {
    super();
  }

  serializeUser(user: User, done: Function) {
    done(null, user.username);
  }

  deserializeUser(username: string, done: Function) {
    const user = this.usersService.findByUsername(username);

    if (!user) {
      return done(
        `Could not deserialize user: user with ${username} could not be found`,
        null,
      );
    }

    done(null, user);
  }
}

사용자 직렬화(serialization)는 사용자 객체에서 필요한 최소한의 정보만을 남겨두고, 세션에 저장되는 데이터를 최소화하는 과정입니다. 보통 ID와 같은 최소한의 정보만을 직렬화하여 세션에 저장하고, 나중에 세션에서 복원할 때 역직렬화(deserialization) 과정을 통해 전체 사용자 객체를 다시 불러오는 방식입니다.

이제, UserSerializerAuthModule의 프로바이더 목록에 추가합시다. 이렇게 해야 passport가 세션에서 사용자 정보를 직렬화하고 복원하는 과정에서 UserSerializer를 사용할 수 있습니다.

providers: [AuthService, LocalStrategy, UserSerializer]

4-2-2. Attach user data to session

passport.session 미들웨어는 요청 객체에 사용자를 로그인하거나 로그아웃할 수 있는 몇 가지 메서드를 자동으로 삽입하며, 사용자가 인증되었는지를 확인할 수도 있습니다.

기본적으로 AuthGuard('local')에서 반환되는 가드는 사용자 세션을 지속적으로 생성하는 request.login 메서드를 호출하지 않습니다.

지금까지 사용하던 가드를 확장하여 나만의 가드를 만들면 되겠습니다.

import {
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Request } from 'express';

@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {
  constructor() {
    super();
  }

  async canActivate(context: ExecutionContext): Promise<boolean> {
    await super.canActivate(context);

    const request = context.switchToHttp().getRequest() as Request;

    await super.logIn(request);

    return true;
  }
}

먼저, 확장된 AuthGuard('local') 가드의 로직을 호출하여, 로컬 전략을 호출합니다. 앞서 구현한 대로, super.canActivate를 호출하면 요청 본문에 제공된 자격 증명이 유효하지 않으면 UnauthorizedException이 발생합니다.

확장된 가드의 logIn 메서드를 호출하면 사용자를 직렬화하여 PassportSerializer를 사용해 세션에 저장함으로써 지속적인 로그인 세션을 생성할 수 있습니다.

컨트롤러에서 기존 가드를, 방금 만든 새로운 가드로 교체해야 하겠습니다.

@UseGuards(LocalAuthGuard)
@Post('login')
login(@Req() request: any, @Session() session: ExpressSession) {
    console.log({ session });

    return session;
 }

다시 요청을 보내봅시다.

{
  "cookie": {
    "originalMaxAge": null,
    "expires": null,
    "httpOnly": true,
    "path": "/"
  },
  "passport": {
    "user": "Alice"
  }
}

의도한 대로 동작함을 확인할 수 있습니다.

4-3. Check if session is authenticated ✍️

마지막 단계는 세션이 인증된 사용자에게 속하는지 확인하는 가드를 만드는 것입니다.

passport.session 미들웨어가 요청 객체에 주입한 유틸리티들을 사용하면 이를 쉽게 구현할 수 있습니다. 또 다른 가드를 만들어 보겠습니다.

import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Request } from 'express';

@Injectable()
export class IsAuthenticatedGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest() as Request;

    return request.isAuthenticated();
  }
}

이제 세션이 인증되었는지 확인하기 위해 그 가드를 사용하여 어떤 경로에도 적용할 수 있습니다. 이전에 만든 /auth/protected 경로에 이를 적용해 봅시다.

@UseGuards(IsAuthenticatedGuard)
@Get('protected')
protected() {
  return {
    message: 'This route is protected against unauthenticated users!',
  };
}

이 경로로 요청이 들어오면, passport.session 미들웨어는 세션 객체를 확인하고 그 안에 직렬화된 사용자가 있는지 확인합니다. 만약 직렬화된 사용자가 있다면, 이전에 만든 PassportSerializer를 사용하여 이를 실제 사용자로 역직렬화하고, 그 사용자 정보를 request.user 속성으로 요청 객체에 주입합니다. 만약 직렬화된 사용자가 없다면, 가드는 경로에 대한 접근을 차단합니다.

4-4. Implementing user logout ✍️

사용자를 로그아웃시키기 위해, passport.session은 요청 객체에 logout 메서드를 주입하여 필요한 모든 작업을 처리합니다.

따라서 로그아웃 라우터를 매우 간단하게 구현할 수 있습니다.

import { Req } from '@nestjs/common';
import { Request } from 'express';x

@UseGuards(IsAuthenticatedGuard)
@Post('logout')
async logout(@Req() request: Request) {
  const logoutError = await new Promise((resolve) =>
    request.logOut({ keepSessionInfo: false }, (error) =>
      resolve(error),
    ),
  );

  if (logoutError) {
    console.error(logoutError);

    throw new InternalServerErrorException('Could not log out user');
  }

  return {
    logout: true,
  };
}

5. Conclusion ⚙️

세션 기반 인증은 매우 강력하고 유연한 인증 방법으로, 서버 측에서 세션을 관리할 수 있어 사용자의 로그인 상태를 제어하기에 적합합니다. 하지만, 실시간 애플리케이션이나 대규모 분산 시스템에서는 세션 관리가 복잡해질 수 있으므로, 경우에 따라 JWT와 같은 토큰 기반 인증 방식을 고려하는 것도 좋습니다.

profile
Write a little every day, without hope, without despair ✍️

0개의 댓글