2023. 6. 25. 00:30ㆍ개발 문서 번역/NestJS
이 포스팅은 「NestJS 기초실무 가이드 : 강력하고 쉬운 Node.js 웹 프레임워크로 웹사이트 만들기」
(서명: NestJS 基礎必學實務指南:使用強大且易擴展的 Node.js 框架打造網頁應用程式)
책이 출간되었습니다.
웹사이트의 다양한 기능을 사용할 때 더 많은 사용 경험을 위해 회원 가입이 필요한 경우가 많습니다.
(ex: google, facebook 등...) 이런 계정을 사용하는 방식은 매우 중요한 요소이며, 현재는 떼놓을 수 없는 필수 요소로 자리잡았습니다.
애플리케이션의 회원가입 방식은 무척이나 다양합니다.
(ex: 일반적인 회원가입, facebook 회원가입, google 회원가입)
매 회원가입 방식에는 고유한 전략(Strategy, 策略) 이 존재하는데요.
어떻게 계정 인증(Authentication, 帳號認證)의 전략을 구성할지는 애플리케이션에게 있어 매우 중요한 요소중 하나입니다.
우리 개발자들은 각종 전략들이 하나의 묶여 표준화되어 동작하기를 원합니다.
이때 유용한 도구 몇개를 사용하여 해결할 수 있습니다.
Node 진영에는 가장 유명한 Passport.js (약칭: passport)가 있으며 Nest에서는 해당 모듈로 패키징하여 제공합니다. 개발자가 Nest에서 가장 가볍게 쓸 수 있는 라이브러리 역시 passport일것입니다. 해당 모듈의 이름은 PassportModule 입니다.
passport 소개
passport는 다양한 인증 방식을 관리하기 위해 전략 패턴을 채택하고 있습니다.
전략의 구조는 크게 두가지로 나뉘는데 바로 passport와 passport strategy입니다.
passport는 사용자 인증을 처리하기 위해 사용되는 반면
passport strategy는 인증 전략을 처리하기 위해 사용됩니다. 그렇기 때문에 둘중 하나가 빠져서는 안됩니다.
passport 생태계에선 Facebook, Google, local 인증 전략 등...을 완벽하게 처리할 수 있는 전략들을 제공하고 있습니다.
Nest에서는 passport strategy는 Guard와 함께 사용됩니다.
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 정의
회원가입이라면 유저와 가장 밀접한 관련이 있기 때문에 먼저 User의 schema로 사용자의 자료 구조를 정의하겠습니다.
src/common/models 경로에 user.model.ts 파일을 생성한 후 유저의 schema, Document, schema와 modelDefinition 을 이곳에 정의해줍니다.
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, email과 password 총 3개의 필드를 정의했습니다.
그중 password는 중첩 객체로 정의를 하였는데 그 이유는 비밀번호가 바로 데이터베이스에 저장이되는걸 원치 않으며, 암호학 기중 솔팅이라는 기술로 암호화를 시키기 위해서입니다.
솔팅 암호화
솔팅 암호화는 패스워드 관리에 자주 쓰이는 기술 중 하나입니다.
원리는 간단합니다. "입력값(input, 輸入值)"과 "특정 값(salt, 某個特定值)"으로 암호화를 시킵니다. salt와 hash를 데이터베이스에 저장하기만 하면 원래 평문으로 암호를 저장하는 문제를 해결할 수 있습니다. 그러나 왜 두개의 값을 저장해야 할까요?
먼저 복호화 원리에 대해 설명을 해야합니다. 사용자가 로그인을 할 때 username과 password 두개의 값을 받습니다.
이때 사용자가 입력한 username으로 데이터베이스 내의 자료와 대응하는 자료를 찾아 password의 값이 정확한지를 검증하고 password와 salt를 다시 한번 암호화 하여 계산된 값을 hash와 비교하기만 하면 됩니다.
값이 일치한다면 사용자가 입력한 비밀번호가 회원가입시 입력한 비밀번호와 일치함을 의미합니다.
그렇다면 솔팅 암호화를 한번 구현해보겠습니다.
src/core/utils 경로 아래에 common.utility.ts 파일을 생성한 후 정적 메서드 encryptBySalt를 구현하겠습니다.
해당 메서드는 2개의 파라미터 input, salt를 받으며, salt의 기본값은 randomBytes로 계산되어 나온 값입니다.
input과 salt는 pbkdf2Sync를 통해 SHA-256 알고리즘으로 암호화와 반복문(iteration, 迭代)이 실행됩니다.
아래의 코드에선 1000번의 반복문이 실행되어 최종적으로 hash와 salt를 반환합니다.
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부분을 만들 수 있게되었습니다.
UserModule과 AuthModule 2개의 Module을 만들어보겠습니다.
먼저 UserModule은 사용자와 관련있는 정보를 처리하며
AuthModule은 인증과 관련된 작업을 처리합니다.
일반적으로 AuthModule은 반드시 UserModule을
의존하게 되어있습니다. 사용자가 있어야만 그에 대한 정보를 처리할 수 있으니까요!
사용자 모듈
CLI를 통해 src/features 아래 UserModule과 UserService를 생성하겠습니다.
$ nest generate module features/user
$ nest generate service features/user
UserModule은 사용자의 데이터를 조작하기 때문에 MongooseModule으로 model을 정의해주어야 하고 AuthModule은 반드시 UserModule을 의존하기 때문에 UserService를 내보내 AuthModule이 UserService를 통해 사용자의 데이터들을 조작할 수 있게 해주어야 합니다.
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 {}
마지막으로 UserService 에 model을 주입하고 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 아래 AuthModule과 AuthController를 생성해보겠습니다.
$ nest generate module features/auth
$ nest generate controller features/auth
AuthModule에 UserModule을 불러와줍니다.
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를 하나 설계하고
UserService의 createUser(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을 사용하면 되고, 해당 strategy와 passport를 적절히 사용하면 완벽할것입니다.
npm을 통해 설치하면 됩니다.
$ npm install passport-local
$ npm install @types/passport-local -D
전략 구현
먼저 UserService에 findUser 메서드를 통해 사용자 데이터를 받아옵니다.
데이터를 받아오는 이유는 사용자가 username과 password를 입력하면 데이터베이스에서 사용자와 입력한 데이터를 찾기 위함입니다.
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
AuthService에 validateUser(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;
}
}
사용자 검증 메서드를 완성한 후에는 passport의 strategy를 붙여주어야 합니다.
Provider를 하나 생성한 후 이름을 strategy라고 명명한 후 해당 strategy가 passport와 연결될 수 있도록 하겠습니다.
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 };
}
}
AuthModule에 PassportModule과 providers 안에 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로 로그인 검증을 구현해보겠습니다.
먼저 AuthController에 signin메서드에 AuthGuard를 적용시키겠습니다.
passport-local 이 strategy를 사용하므로 AuthGuard는 local 문자열을 입력해야 합니다.
passport는 자동으로 LocalStrategy와 연결되고 passport가 LocalStrategy의 validate 메서드에서 반환된 값인
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. AuthGuard를 Guard로 사용하고, 어떠한 strategy를 사용하여 인증을 수행할지 지정할 수있습니다.
4. 암호를 절대로 평문으로 저장해서는 안됩니다. 솔팅 암호화를 권장합니다.
5. passport-local을 사용하여 로컬 계정 로그인 인증을 처리할 수있습니다.
'개발 문서 번역 > NestJS' 카테고리의 다른 글
NestJS 帶你飛! 시리즈 번역 25# Authentication & RBAC (0) | 2023.06.29 |
---|---|
NestJS 帶你飛! 시리즈 번역 24# Authentication(하) (0) | 2023.06.26 |
NestJS 帶你飛! 시리즈 번역 22# MongoDB (0) | 2023.06.20 |
NestJS 帶你飛! 시리즈 번역 21# HTTP Module (0) | 2023.06.18 |
NestJS 帶你飛! 시리즈 번역 20# File Upload (0) | 2023.06.16 |