NestJS 帶你飛! 시리즈 번역 23# Authentication(상)

2023. 6. 25. 00:30개발 문서 번역/NestJS

728x90
이 포스팅은 「NestJS 기초실무 가이드 : 강력하고 쉬운 Node.js 웹 프레임워크로 웹사이트 만들기」
(서명: NestJS 基礎必學實務指南:使用強大且易擴展的 Node.js 框架打造網頁應用程式)
책이 출간되었습니다.

 

웹사이트의 다양한 기능을 사용할 때 더 많은 사용 경험을 위해 회원 가입이 필요한 경우가 많습니다.

(ex: google, facebook 등...) 이런 계정을 사용하는 방식은 매우 중요한 요소이며, 현재는 떼놓을 수 없는 필수 요소로 자리잡았습니다. 

 

애플리케이션의 회원가입 방식은 무척이나 다양합니다.

(ex: 일반적인 회원가입, facebook 회원가입, google 회원가입)

매 회원가입 방식에는 고유한 전략(Strategy, 策略) 이 존재하는데요.

어떻게 계정 인증(Authentication, 帳號認證)의 전략을 구성할지는 애플리케이션에게 있어 매우 중요한 요소중 하나입니다.

우리 개발자들은 각종 전략들이 하나의 묶여 표준화되어 동작하기를 원합니다. 

이때 유용한 도구 몇개를 사용하여 해결할 수 있습니다.

Node 진영에는 가장 유명한 Passport.js (약칭: passport)가 있으며 Nest에서는 해당 모듈로 패키징하여 제공합니다. 개발자가 Nest에서 가장 가볍게 쓸 수 있는 라이브러리 역시 passport일것입니다. 해당 모듈의 이름은 PassportModule 입니다.

 

passport 소개

 

passport는 다양한 인증 방식을 관리하기 위해 전략 패턴을 채택하고 있습니다.
전략의 구조는 크게 두가지로 나뉘는데 바로 passportpassport strategy입니다.
passport는 사용자 인증을 처리하기 위해 사용되는 반면
passport strategy는 인증 전략을 처리하기 위해 사용됩니다. 그렇기 때문에 둘중 하나가 빠져서는 안됩니다.

passport 생태계에선 Facebook, Google, local 인증 전략 등...을 완벽하게 처리할 수 있는 전략들을 제공하고 있습니다.

 

Nest에서는 passport strategyGuard와 함께 사용됩니다.
AuthGuard를 통해 strategy를 래핑하여, Nest의 Guard와 완벽한 조화를 이루고 있습니다.

 

 

 

 

passport 설치

npm을 통해 passport를 설치할 수 있습니다. Nest가 래핑한 모듈과 passport 를 설치해야 합니다.

$ npm install @nestjs/passport passport
주의: 현재는 passport만 설치하였습니다. 앞전에 passport strategy가 필요하다고 언급한적이 있으며, 이 passport strategy까지 사용해야만 완벽한 인증 과정을 거칠 수 있습니다. 이 부분은 뒤에서 따로 설치한 후 설명하겠습니다.

 

회원가입 구현

회원가입을 구현하기 전 먼저 사용자가 회원으로 가입할 수 있게끔 계정등록 API를 설계해야 합니다.

여기서는 MongoDB를 DB로 사용하고, 앞전 포스팅에서의 기술을 사용하여 데이터베이스를 조 하겠습니다.

주의: 먼저 AppModule에서 MongooseModule을 등록하는 부분은 생략하겠습니다.
자세한 내용은 이전 글인 "MongoDB 연결"을 참고해주세요.

 

Schema 정의

회원가입이라면 유저와 가장 밀접한 관련이 있기 때문에 먼저 Userschema로 사용자의 자료 구조를 정의하겠습니다.

src/common/models 경로에 user.model.ts 파일을 생성한 후 유저의 schema, Document, schemamodelDefinition 을 이곳에 정의해줍니다.

import { ModelDefinition, Prop, raw, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';

export type UserDocument = User & Document;

@Schema()
export class User {

  @Prop({
    required: true,
    minlength: 6,
    maxlength: 16
  })
  username: string;
  
  @Prop({
    required: true
  })
  email: string;

  @Prop({
    type: raw({
      hash: String,
      salt: String
    }),
    required: true
  })
  password: Record<string, any>;
}

export const UserSchema = SchemaFactory.createForClass(User);

export const USER_MODEL_TOKEN = User.name;

export const UserDefinition: ModelDefinition = {
  name: USER_MODEL_TOKEN,
  schema: UserSchema,
};

username, emailpassword 총 3개의 필드를 정의했습니다.

그중 password는 중첩 객체로 정의를 하였는데 그 이유는 비밀번호가 바로 데이터베이스에 저장이되는걸 원치 않으며, 암호학 기중 솔팅이라는 기술로 암호화를 시키기 위해서입니다.

 

솔팅 암호화

솔팅 암호화는 패스워드 관리에 자주 쓰이는 기술 중 하나입니다.

원리는 간단합니다. "입력값(input, 輸入值)""특정 값(salt, 某個特定值)"으로 암호화를 시킵니다. salthash를 데이터베이스에 저장하기만 하면 원래 평문으로 암호를 저장하는 문제를 해결할 수 있습니다. 그러나 왜 두개의 값을 저장해야 할까요?

먼저 복호화 원리에 대해 설명을 해야합니다. 사용자가 로그인을 할 때 usernamepassword 두개의 값을 받습니다.
이때 사용자가 입력한 username으로 데이터베이스 내의 자료와 대응하는 자료를 찾아 password의 값이 정확한지를 검증하고 passwordsalt를 다시 한번 암호화 하여 계산된 값을 hash와 비교하기만 하면 됩니다.

값이 일치한다면 사용자가 입력한 비밀번호가 회원가입시 입력한 비밀번호와 일치함을 의미합니다.

 

그렇다면 솔팅 암호화를 한번 구현해보겠습니다.
src/core/utils 경로 아래에 common.utility.ts 파일을 생성한 후 정적 메서드 encryptBySalt를 구현하겠습니다.
해당 메서드는 2개의 파라미터 input, salt를 받으며, salt의 기본값은 randomBytes로 계산되어 나온 값입니다.
inputsaltpbkdf2Sync를 통해 SHA-256 알고리즘으로 암호화와 반복문(iteration, 迭代)이 실행됩니다. 

아래의 코드에선 1000번의 반복문이 실행되어 최종적으로 hashsalt를 반환합니다.

import { randomBytes, pbkdf2Sync } from 'crypto';

export class CommonUtility {

  public static encryptBySalt(
    input: string,
    salt = randomBytes(16).toString('hex'),
  ) {
    const hash = pbkdf2Sync(input, salt, 1000, 64, 'sha256').toString('hex');
    return { hash, salt };
  }

}

 

모듈 설계

schema와 암호화 연산 부분을 완성하였다면 회원가입 API부분을 만들 수 있게되었습니다.

UserModuleAuthModule 2개의 Module을 만들어보겠습니다.

먼저 UserModule은 사용자와 관련있는 정보를 처리하며

AuthModule은 인증과 관련된 작업을 처리합니다.
일반적으로 AuthModule은 반드시 UserModule을 

의존하게 되어있습니다. 사용자가 있어야만 그에 대한 정보를 처리할 수 있으니까요!

 

사용자 모듈

CLI를 통해 src/features 아래 UserModuleUserService를 생성하겠습니다.

$ nest generate module features/user
$ nest generate service features/user

UserModule은 사용자의 데이터를 조작하기 때문에 MongooseModule으로 model을 정의해주어야 하고 AuthModule은 반드시 UserModule을 의존하기 때문에 UserService를 내보내 AuthModuleUserService를 통해 사용자의 데이터들을 조작할 수 있게 해주어야 합니다.

import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';

import { UserDefinition } from '../../common/models/user.model';
import { UserService } from './user.service';

@Module({
  imports: [MongooseModule.forFeature([UserDefinition])],
  providers: [UserService],
  exports: [UserService],
})
export class UserModule {}

사용자 데이터의 구조를 DTO로 정의해봅시다.

DTO를 통해 파라미터 유형 정의와 간단히 자료의 유효성 검증을 진행해보겠습니다.

src/features/user/dto 아래 create-user.dto.ts를 생성해보겠습니다.

import { IsNotEmpty, MaxLength, MinLength } from 'class-validator';

export class CreateUserDto {
  @MinLength(6)
  @MaxLength(16)
  public readonly username: string;

  @MinLength(8)
  @MaxLength(20)
  public readonly password: string;

  @IsNotEmpty()
  public readonly email: string;
}

바로 AppModule에서 의존성 주입을 통해 ValidationPipe를 활성화 시켜보겠습니다.

import { APP_PIPE } from '@nestjs/core';
import { Module, ValidationPipe } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';

import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UserModule } from './features/user/user.module';
import { AuthModule } from './features/auth/auth.module';

import MongoConfigFactory from './config/mongo.config';

@Module({
  imports: [
    ConfigModule.forRoot({
      load: [MongoConfigFactory],
      isGlobal: true
    }),
    MongooseModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        uri: config.get<string>('mongo.uri'),
      }),
    }),
    UserModule,
    AuthModule,
  ],
  controllers: [AppController],
  providers: [
    AppService,
    { // 전역 pipe 주입
      provide: APP_PIPE,
      useClass: ValidationPipe,
    },
  ],
})
export class AppModule {}

마지막으로 UserServicemodel을 주입하고 createUser(user: CreateUserDto) 메서드를 통해 사용자를 생성하고 password는 솔팅 암호화 처리가 필요합니다.

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

import { Model } from 'mongoose';

import { CommonUtility } from '../../core/utils/common.utility';
import { UserDocument, USER_MODEL_TOKEN } from '../../common/models/user.model';
import { CreateUserDto } from './dto/create-user.dto';

@Injectable()
export class UserService {
  constructor(
    @InjectModel(USER_MODEL_TOKEN)
    private readonly userModel: Model<UserDocument>,
  ) {}

  createUser(user: CreateUserDto) {
    const { username, email } = user;
    const password = CommonUtility.encryptBySalt(user.password);
    return this.userModel.create({
      username,
      email,
      password,
    });
  }
}

 

import할 때 경로들이 너저분하게 느껴지시나요? 그렇다면 index.ts를 생성하여 내보내기 시 사용될 경로를

통일할 수 있습니다.

export { UserModule } from './user.module';
export { UserService } from './user.service';
export { CreateUserDto } from './dto/create-user.dto';

 

모듈 검증

CLI를 통해 src/features 아래 AuthModuleAuthController를 생성해보겠습니다.

$ nest generate module features/auth
$ nest generate controller features/auth

AuthModuleUserModule을 불러와줍니다.

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

@Module({
  imports: [UserModule],
  controllers: [AuthController],
})
export class AuthModule {}

이어서 AuthController[POST] /auth/signup 라는 API를 하나 설계하고
UserServicecreateUser(user: CreateUserDto) 메서드를 통해 사용자를 생성하는 메서드를 적용시키겠습니다.

import { Body, Controller, Post } from '@nestjs/common';
import { CreateUserDto, UserService } from '../user';

@Controller('auth')
export class AuthController {
  constructor(private readonly userService: UserService) {}

  @Post('/signup')
  signup(@Body() user: CreateUserDto) {
    return this.userService.createUser(user);
  }
}

Postman으로 테스트 해보겠습니다.

 

로컬 로그인 구현

계정 인증과 로그인은 밀접한 관련이 있습니다. 로그인의 과정중에 계정 비밀번호의 검사도 같이 진행됩니다.

검사에 통과한 후에 비로소 로그인 단계에 진입하게 됩니다.
로컬 로그인은 passport-local을 사용하면 되고, 해당 strategypassport를 적절히 사용하면 완벽할것입니다.

npm을 통해 설치하면 됩니다.

$ npm install passport-local
$ npm install @types/passport-local -D

 

전략 구현

먼저 UserServicefindUser 메서드를 통해 사용자 데이터를 받아옵니다.

데이터를 받아오는 이유는 사용자가 usernamepassword를 입력하면 데이터베이스에서 사용자와 입력한 데이터를 찾기 위함입니다.

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

import { FilterQuery, Model } from 'mongoose';

import { CommonUtility } from '../../core/utils/common.utility';
import { UserDocument, USER_MODEL_TOKEN } from '../../common/models/user.model';
import { CreateUserDto } from './dto/create-user.dto';

@Injectable()
export class UserService {
  constructor(
    @InjectModel(USER_MODEL_TOKEN)
    private readonly userModel: Model<UserDocument>,
  ) {}

  createUser(user: CreateUserDto) {
    const { username, email } = user;
    const password = CommonUtility.encryptBySalt(user.password);
    return this.userModel.create({
      username,
      email,
      password,
    });
  }

  findUser(filter: FilterQuery<UserDocument>) {
    return this.userModel.findOne(filter).exec();
  }
}

CLI로 AuthService를 통해 계정을 검증하도록 하겠습니다.

$ nest generate service features/auth

 

AuthServicevalidateUser(username: string, password: string) 메서드를 하나 만들고
username와 대응하는 사용자 데이터를 찾습니다. 그런 후에 입력했던 비밀번호와 salt를 통해 솔팅 암호화를 진행합니다.
결과가 데이터베이스 안의 hash와 일치한다면 해당 사용자의 자료를 반환하고, 없다면 null을 반환하겠습니다.

import { Injectable } from '@nestjs/common';
import { CommonUtility } from '../../core/utils/common.utility';
import { UserService } from '../user';

@Injectable()
export class AuthService {

  constructor(private readonly userService: UserService) {}
  
  async validateUser(username: string, password: string) {
    const user = await this.userService.findUser({ username });
    const { hash } = CommonUtility.encryptBySalt(password, user?.password?.salt);
    if (!user || hash !== user?.password?.hash) {
      return null;
    }
    return user;
  }

}

사용자 검증 메서드를 완성한 후에는 passportstrategy를 붙여주어야 합니다.

Provider를 하나 생성한 후 이름을 strategy라고 명명한 후 해당 strategypassport와 연결될 수 있도록 하겠습니다.

 

src/features/auth 아래 stratgies 라는 폴더를 만들고 local.strategy.ts 라는 파일을 생성해보겠습니다.

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

  async validate(username: string, password: string) {
    const user = await this.authService.validateUser(username, password);
    if (!user) {
      throw new UnauthorizedException();
    }
    return { username: user.username, email: user.email };
  }

}

 

AuthModulePassportModuleproviders 안에 LocalStrategy를 불러오는것을 잊지 마세요 ! 

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

import { AuthController } from './auth.controller';
import { UserModule } from '../user';
import { AuthService } from './auth.service';
import { LocalStrategy } from './strategies/local.strategy';

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

 

AuthGurard 사용하기

strategy를 완성한 후에 API로 로그인 검증을 구현해보겠습니다.

먼저 AuthControllersignin메서드에 AuthGuard를 적용시키겠습니다.

passport-localstrategy를 사용하므로 AuthGuardlocal 문자열을 입력해야 합니다.

passport는 자동으로 LocalStrategy와 연결되고 passportLocalStrategyvalidate 메서드에서 반환된 값인

user 정보를 요청 객체user 속성 내에 작성하여야 합니다.

이렇게 한다면 Controller는 해당 사용자의 정보를 사용할 수 있게 됩니다. 

import { Body, Controller, Post, Req, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

import { Request } from 'express';

import { CreateUserDto, UserService } from '../user';

@Controller('auth')
export class AuthController {
  constructor(private readonly userService: UserService) {}

  @Post('/signup')
  signup(@Body() user: CreateUserDto) {
    return this.userService.createUser(user);
  }

  @UseGuards(AuthGuard('local'))
  @Post('/signin')
  signin(@Req() request: Request) {
      return request.user;
  }
}

Postman을 통해 테스트해보면, 로그인에 성공하였다면 아래와 같은 정보가 반환되는것을 확인할 수 있습니다.

마치며

오늘은 회원 가입 및 로그인 기능을 구현해보았습니다. 또한 Nest에서 passport를 사용하는 방법에 대해 기초적인 부분을 알아보았습니다. 하지만 이들은 로그인 전의 처리 과정에 불과합니다.

그렇다면 로그인 후 인증을 유지하는 방법은 어떻게 구현해야 할까요? 다음 포스팅에서 자세히 설명 하겠습니다!

오늘 학습의 요약본입니다.

1. passport는 전략 방식을 채택하여 계정 인증 방식을 구현하고 있습니다.

2. passport는 전체적인 계정 인증 프로세스를 완료하기 위해서 strategy와 함께 사용합니다.

3. AuthGuardGuard로 사용하고, 어떠한 strategy를 사용하여 인증을 수행할지 지정할 수있습니다.

4. 암호를 절대로 평문으로 저장해서는 안됩니다. 솔팅 암호화를 권장합니다.

5. passport-local을 사용하여 로컬 계정 로그인 인증을 처리할 수있습니다.