본문 바로가기

Back-End

[Nest.js] NestJS가 뭐야?: NodeJS 프레임워크 공부하기

반응형

providers

  • services, repositories, factories, helpers 등이 있다.
  • 종속성에 의해 inject(주입)할 수 있다.
  • provider 객체의 생성 및 연결은 enst runtime 시스템에 위임될 수 있다(자동으로 해줌).
  • 컨트롤러는 HTTP 요청을 처리하고, 복잡한 작업은 provider에게 위임을 한다.
  • provider는 module에서 선언하는 일반 javascript class이다.
  • CatsService는 CatsController의 constructor를 통해 주입된다. 여기서 private를 사용하면 선언과 초기화가 동시에 이뤄진다.

 

middleware

  • 애플리케이션에서 공통 처리 담당
  • 인증, 로깅 등을 처리한다
  • 요청과 응답 객체를 변경할 수 있다
  • 요청의 validation을 체크하여 오류를 처리할 수 있다.

 

미들웨어 사용법

  • @Injectable 데코레이션 사용
  • NestMiddle 인터페이스를 implements하여 사용한다.
  • Module의 class 내부에 configure를 사용해서 선언한다. 이때 NestModule 인터페이스를 implements 한다.
// logger.mmiddleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';

@Injectable()
export class LoggerMiddleware implements NestMiddleware { // NestMiddleware를 implements!
  use(req: Request, res: Response, next: NextFunction) {
    console.log('Request...');
    next();
  }
}
// app.module.ts
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { LoggerMiddleware } from './common/middleware/logger.middleware';
import { CatsModule } from './cats/cats.module';

@Module({
  imports: [CatsModule],
})
export class AppModule implements NestModule { // NestModule 인터페이스를 implements!
  configure(consumer: MiddlewareConsumer) { // configure를 사용하여 선언!
    consumer
      .apply(LoggerMiddleware)
      .forRoutes('cats');
  }
}
  • .forRoutes('cats') => 라우트별로 처리가 가능(라우트 'cats'를 받을 때 해당 모듈을 사용)

또한 아래처럼 특정 라우트에서만 사용도 가능하다.

.forRoutes({ path: 'cats', method: RequestMethod.GET });

패턴 기반(정규식 패턴)의 라우팅도 지원된다.

.forRoutes({ path: 'ab*cd', method: RequestMethod.ALL });

 

MiddlewareConsumer

  • 헬퍼클래스
  • 미들웨어 관리를 위해 NestJS에서 제공
  • 여러 스타일로 미들웨어를 설정할 수 있다.
  • forRoutes()
    • 단일 문자열, 여러 문자열, RouteInfo 객체, 컨트롤러 클래스 및 여러 컨트롤러 클래스를 사용할 수 있다.
// app.module.ts
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { LoggerMiddleware } from './common/middleware/logger.middleware';
import { CatsModule } from './cats/cats.module';
import { CatsController } from './cats/cats.controller.ts';

@Module({
  imports: [CatsModule],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(LoggerMiddleware)
      .forRoutes(CatsController);
  }
}
  • catsController에서 미들웨어를 사용하겠다고 작성.
  • apply: 안에 여러 미들웨어를 지정할 수 있다.
consumer.apply(cors(), helmet(), logger).forRoutes(CatsController);

 

exclude

  • 특정 라우트 제외하기
consumer
  .apply(LoggerMiddleware)
  .exclude(
    { path: 'cats', method: RequestMethod.GET },
    { path: 'cats', method: RequestMethod.POST },
    'cats/(.*)',
  )
  .forRoutes(CatsController);
  • GET, POST 메서드에는 LoggerMiddleware를 처리하지 않겠다

 

functional middleware

  • class 미들웨어 -> functional 미들웨어로 변경하면 간단하게 작성 가능
    • class 는 implements NestMiddleware 써야하는데 functional은 안써도 됨
    • req, res, next를 바로 파라미터로 작성해주면 됨
// logger.middleware.ts
import { Request, Response, NextFunction } from 'express';

export function logger(req: Request, res: Response, next: NextFunction) {
  console.log(`Request...`);
  next();
};
  • 위처럼 logger function으로 작성하면 Module에서는 아래와 같이 작성
consumer
  .apply(logger)
  .forRoutes(CatsController);

 

Global 미들웨어

  • 모든 경로에서 사용하는 미들웨어
  • 단, Global 미들웨어에서는 DI 컨테이너에 액세스할 수 없다. 즉, class로 정의된 미들웨어는 사용할 수 없다.
  • 따라서 functional 미들웨어를 사용한다.
const app = await NestFactory.create(AppModule);
app.use(logger);
await app.listen(3000);

 

TypeORM 

  • NestJS를 데이터베이스에 연결시켜줌
  • 주로 TypeORM이 많이 사용됨
  • 왜냐하면 NestJS와 TypeORM 둘 다 ts를 사용하기 때문

 

사전 준비

  1. DB인 MySQL 설치
  2. MySQL Workbench로 'test' schema를 생성하기

 

typeorm 세팅하기

  • nest-typeorm 프로젝트 생성
nest new nest-typeorm
  • cats의 module, controller, service 생성
nest g mo cats
nest g co cats
nest g s cats

 

typeorm module 추가하기

typeorm을 사용하기 위해서 app.module.tsimports에 typeorm module을 추가한다.

// app.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { CatsModule } from './cats/cats.module';
import { Cat } from './cats/entity/cats.entity';
import { AuthModule } from './auth/auth.module';

@Module({
  imports: [
    // TypeORM 연결
    TypeOrmModule.forRoot({
      type: 'mysql',
      host: 'localhost',
      port: 3306,
      username: 'root',
      password: 'root',
      database: 'test',
      entities: [Cat], // TypeORM에서 'Cat' entity를 사용할 수 있게 됨
      synchronize: true, // entity 추가하면 table이 자동으로 생성되게 해줌. 개발에서만 사용하도록 할 것
    }),
    CatsModule,
    AuthModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
  • MySQL을 사용하고,
  • localhost의 3306번 포트로 들어가고,
  • synchronous: true -> entity를 만들면 table을 자동으로 생성하게 해주는 옵션이므로, 개발 모드에서만 사용하도록 한다.

 

TypeORM Entity 설정하기

Cat Entity 생성하기

cats 폴더 내에 entity 폴더와 cats.entity.ts 파일을 생성한다.

DB에 생성할 table과 동일한 column들을 만들어준다.

// entity/cats.entity.ts
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';

@Entity()
// database에 Cat 이라는 테이블 생성
export class Cat {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @Column()
  age: number;

  @Column()
  breed: string;
}
  • @Enitty() 괄호 내에 테이블명을 적지 않으면, class명과 동일한 테이블이 생성된다.
  • 즉, 테이블명은 'Cat'이 된다.
  • 첫 번째 컬럼은 @PrimaryGeneratedColumn() 으로 생성한다.
  • 두 번째 컬럼 이후로는 @Column() 으로 생성한다.

 

생성한 Cat Entity를 모듈에 추가하기

app.module.ts의 entities에 Cat을 추가한다. 그러면 이제 typeorm에서 Cat entity를 사용할 수 있게 된다.

 

Cat Module에서 imports, exports 하기

// cats.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';
import { Cat } from './entity/cats.entity';

@Module({
  imports: [TypeOrmModule.forFeature([Cat])],
  exports: [TypeOrmModule],
  controllers: [CatsController],
  providers: [CatsService],
})
export class CatsModule {}

 

TypeORM으로 Database CRUD하기 : 조회, 생성, 삭제

repository 사용하기

cats.service.ts에 Cat Entity를 repository(=catsRepository)로 사용하도록 한다.

 

Cat Entity를 repository로 사용할 수 있도록 catsRepository 변수에 정의하였다.

TypeORM의 레퍼시토리를 이용하면 find(), findOne(), save(), delete(), ... 등 다양한 메서드를 사용할 수 있다. 이 메서드들을 사용하면 TypeORM이 자동으로 SQL문으로 변형하여 DB에 데이터를 조회, 생성, 수정, 삭제 등을 할 수 있다.

 

메서드들을 사용해보자. 다음 코드들은 cats.service.ts 에서 작성한다.

 

find()

모든 값을 찾을 수 있다.

  findAll(): Promise<Cat[]> {
    return this.catsRepository.find();
  }
  • TypeORM의 메서드들(find(), findOne(), delete(), ... 등)을 사용하면 Promise로 리턴한다.
  • 반환받은 Promise를 풀면 Cat Entity 형식의 데이터들이 배열로 있는 형태이다.
  • 따라서 리턴 형식은 Promise:<Cat[]> 이 된다.

 

findOne()

데이터 하나를 찾는다.

  findOne(id: number): Promise<Cat> {
    return this.catsRepository.findOne(id);
  }

 

save()

데이터를 생성한다.

  async create(cat: Cat): Promise<void> {
    await this.catsRepository.save(cat);
  }
  • 리턴값이 없으므로 <void>이고, async await으로 실행이 끝날 때까지 기다린다.

 

delete()

데이터를 삭제한다.

  async remove(id: number): Promise<void> {
    await this.catsRepository.delete(id);
  }
  • 위와 같은 이유로 <void>, async await 사용.

 

Cats Contoller 작성하기

service를 사용하기 위해서 constructor에 선언한다.

// cats.controller.ts

...

@Controller('cats')
export class CatsController {
  constructor(private catsService: CatsService) {}

service와 연결하여 작성해준다.

// cats.controller.ts
import {
  Body,
  Controller,
  Delete,
  Get,
  Param,
  Post,
  Put,
} from '@nestjs/common';
import { CatsService } from './cats.service';
import { Cat } from './entity/cats.entity';

@Controller('cats')
export class CatsController {
  constructor(private catsService: CatsService) {}

  @Get()
  findAll(): Promise<Cat[]> {
    return this.catsService.findAll();
  }

  @Get(':id')
  findOne(@Param('id') id: number): Promise<Cat> {
    return this.catsService.findOne(id);
  }

  @Post()
  create(@Body() cat: Cat) {
    return this.catsService.create(cat);
  }

  @Delete(':id')
  remove(@Param('id') id: number) {
    return this.catsService.remove(id);
  } 
}

 

서버를 실행하고 잘 되는지 확인해본다.

test 스키마에 'Cat' 테이블이 생성된 것을 확인할 수 있다.

따란~ cat.entity.ts 에서 작성한 값인 id, name, age, breed가 잘 있다.

 

Postman으로 값 넣기 (두개 넣어 봄)

GET으로 확인해보니 잘 들어갔다.

id 값으로도 호출해봄. 잘 나옴.

삭제도 잘 되는지 확인해봄. 1을 삭제해보자.

그리고 다시 조회. 1이 잘 삭제되었다.

 

이렇게 TypeORM으로 데이터를 조회, 생성, 삭제를 해보았다.

이제 업데이트도 해보자.

 

 

TypeORM으로 Database CRUD하기 : 수정

update()

데이터를 수정한다.

async update(id: number, cat: Cat): Promise<void> {
    const existedCat = await this.catsRepository.findOne(id);
    if (existedCat) {
      await getConnection()
        .createQueryBuilder() // QueryBuilder로 쿼리를 만들어줄 수 있음
        .update(Cat) // Cat 테이블을 업데이트 할 것임
        .set({
          // 업데이트 할 내용 작성
          name: cat.name,
          age: cat.age,
          breed: cat.breed,
        })
        .where('id = :id', { id })
        .execute(); // 마지막으로 실행을 작성해주어야 함
    }
  }
  • 먼저 수정할 데이터가 있는지부터 확인한다.
  • getConnection: 이전에 createConnection 메서드로 생성된 connection을 가져온다. 현재의 경우에는 main.module.ts 의 imports에서 TypeOrmModule.forRoot 로 가져와서? createConnection이 필요없다 (?)
  • createQueryBuilder: 쿼리를 만들 때 사용
  • id = :id ????
// cats.controller.ts
  @Put(':id')
  update(@Param('id') id: number, @Body() cat: Cat) {
    this.catsService.update(id, cat);
    return `This action updates a #${id} cat`;
  }

 

이제 잘 실행되는지 확인해보자.

업데이트 잘 됨을 확인함.

SQL Workbench에서도 확인해보자. 쿼리문을 작성하고 실행할 때는 ctrl+enter.

 

 

NestJS 인증 프로젝트: 로그인 구현

1. 회원가입

USER 테이블 구조

 

해야할 것

DB에 유저가 있는지 확인하고 있으면 pass, 없으면 유저를 생성한다.

 

auth 생성

auth 이름으로 module, controller, service 생성한다.

nest g mo auth
nest g co auth
nest g s auth

 

user entity 생성

DB에 user 테이블이 필요하므로 user Entity 폴더 및 파일 생성한다. 그래야만 TypeORM으로 MySQL의 DB에 바로 맵핑하여 접근하고 다룰 수 있기 때문.

// user/user.entity.ts
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';

@Entity('user')
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  username: string;

  @Column()
  password: string;
}

 

user entity repository 생성

TypeORM의 repository를 사용하기 위해서 user Entity repository 생성해준다.

  • 이전에는 cats.service.ts내에서 repository를 선언하고 사용했다. 하지만 이번에는 user.repository.ts 파일을 추가하여 분리해서 작성한다.
  • before (이전 cats service)
// cats.service.ts
@Injectable()
export class CatsService {
  constructor(
    @InjectRepository(Cat)
    private catsRepository: Repository<Cat>, // Cat이라는 entity를 repository로 사용할 수 있게 됨
  ) {}
  • after (user repository)
  • typeorm의 EntityRepository 데코레이터를 사용한다. 괄호 내에는 entity를 입력한다.
// user.repository.ts
import { EntityRepository, Repository } from 'typeorm';
import { User } from './entity/user.entity';

@EntityRepository(User)
export class UserRepository extends Repository<User> {}
  • User Entity로 repository를 생성한다. 이제 MySQL에 있는 DB 중 user 테이블을 다룰 수 있다.

 

user dto 생성

user dto를 생성한다. 왜냐하면 controller에서 getAll, getOne과 같이 유저가 주는 정보를 받아오는 형식이 필요하기 때문이다.

  • 폴더 및 파일 생성
// dto/user.dto.ts
export class UserDTO {
  username: string;
  password: string;
}

 

user service 생성

user Service 생성한다. auth service 내에서 (1)유저의 중복 여부를 체크하고, (2)유저를 생성해야 하는 함수를 나눠서 만들어야하기 때문이다.

  • constructor에 @InjectRepository 데코레이터로 생성한 UserRepository를 inject(주입)하고 private로 userRepository를 작성한다.
    • ➡️ 왜냐하면 userRepository를 사용해서 DB로부터 데이터를 읽고 또 필요에 따라 데이터를 추가해야하기 때문이다!
  • 이렇게 작성하므로써 달라진 점은 user repository 파일이 생성되었다는 것, 그리고 user service 내에서 user entity를 사용할 수 있도록 작성된 "Repository<User>"를 user repository에서 작성해주었다는 것. user service에서는 "UserRepository"를 바로 작성해주기만 하면 된다.
  • before (cat service)
// cats.service.ts
@Injectable()
export class CatsService {
  constructor(
    @InjectRepository(Cat)
    private catsRepository: Repository<Cat>, // Cat이라는 entity를 repository로 사용할 수 있게 됨
  ) {}
  • after (user service)
// user.service.ts
@Injectable()
export class UserService {
  constructor(
    @InjectRepository(UserRepository)
    private userRepository: UserRepository,
  ) {}

 

user service 생성 - (1) 유저가 있나 없나?

findOneOptions

  • typeorm에서 제공하는 기능
  • DB에서 특정 필드의 값이 맞는 값을 리턴한다.
  • 속성값: where, select, order, relations, join, cache, ...
  async findByFields(
    options: FindOneOptions<UserDTO>,
  ): Promise<UserDTO | undefined> {
    return await this.userRepository.findOne(options);
  }
  • 특정 필드 내용들을 작성하여 특정 유저 정보를 리턴한다. 따라서 리턴 타입은 UserDTO. 찾고자 하는 유저가 없는 경우는 undefined.

 

user service 생성 - (2) 유저 생성하기 (유저가 없는 경우에)

  async save(userDTO: UserDTO): Promise<UserDTO | undefined> {
    return await this.userRepository.save(userDTO);
  }
  • userDTO로 넘어온 값을 save한다.

 

auth service 작성 - 유저 등록하기

auth service에서는 user service의 함수들을 가져다 쓸 것이다. (해당 유저가 있는지 없는지, 그리고 유저 생성하기)

// auth.service.ts
import { Injectable } from '@nestjs/common';
import { UserService } from './user.service';

@Injectable()
export class AuthService {
  constructor(private userService: UserService) {}
}

유저를 등록하는 registerUser 도 작성해보자.

// auth.service.ts
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { UserDTO } from './dto/user.dto';
import { UserService } from './user.service';

@Injectable()
export class AuthService {
  constructor(private userService: UserService) {}

  async registerUser(newUser: UserDTO): Promise<UserDTO> {
    let userFind: UserDTO = await this.userService.findByFields({
      where: { username: newUser.username },
    });
    if (userFind) {
      // 사용자가 이미 있는 경우, 에러 날림
      throw new HttpException('Username already used!', HttpStatus.BAD_REQUEST);
    }
    // 사용자가 없는 경우, 저장
    return await this.userService.save(newUser);
  }
}

 

auth controller 작성 - 유저 등록하는 API 생성

import { Body, Controller, Post, Req } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UserDTO } from './dto/user.dto';

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  @Post('/register')
  async registerAccount(
    @Req() req: Request,
    @Body() userDTO: UserDTO,
  ): Promise<any> {
    return await this.authService.registerUser(userDTO);
  }
}

 

auth module 작성

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { UserRepository } from './user.repository';
import { UserService } from './user.service';

@Module({
  imports: [TypeOrmModule.forFeature([UserRepository])],
  exports: [TypeOrmModule],
  controllers: [AuthController],
  providers: [AuthService, UserService],
})
export class AuthModule {}
  • user repository를 사용한다고 imports에 작성해주고,
  • exports 는 왜 작성해주지
  • AuthController에서 UserService를 가져와서 사용하므로 providers에 UserService를 작성해준다.

 

app module 수정

마지막으로 user entity도 사용될 수 있도록 entities에 추가해준다.

 

이제 Postman으로 잘 실행되는지 확인해보자.

POST 잘 됨.

여기서 똑같은 유저를 또 생성하면 에러 잘 나옴.

 

 

 

2. 로그인 - 아이디/패스워드 체크

auth service 작성

auth service에 유저가 입력한 username과 password에 해당되는 유저가 DB에 있는지 확인한다.

// auth.service.ts
async validateUser(userDTO: UserDTO): Promise<UserDTO | undefined> {
    let userFind: UserDTO = await this.userService.findByFields({
      where: { username: userDTO.username },
    });
    if (!userFind || userDTO.password !== userFind.password) {
      throw new UnauthorizedException();
    }
    return userFind;
  }

 

auth controller 작성

작성한 auth service의 validateUser를 이용하여 login을 추가한다.

  @Post('/login')
  async login(@Body() userDTO: UserDTO): Promise<any> {
    return await this.authService.validateUser(userDTO);
  }
  • 결과값이 user가 나오면 정상처리이고, 아니면 에러.

 

Postman으로 테스트해보자.

username과 password를 맞게 넣었을 경우 -> 유저 정보 값을 리턴받음

비밀번호가 틀린 경우 -> Unauthorized 에러 발생

 

3. 비밀번호 암호화(bcrypt)

비밀번호 암호화의 필요성

비밀번호를 그대로 저장하면 보안이 위험하다. 따라서 반드시 암호화하여 저장해야만 한다.

 

BCrypt

  • 암호화할 때 많이 사용되는 패키지
  • 설치
    • typescript를 사용하고 있으므로 @types/bcrypt도 설치해야 한다.
npm i --save bcrypt @types/bcrypt

 

비밀번호 암호화하기

bcrypt 패키지를 사용하기 위해 import 해준다.

// user.service.ts
import * as bcrypt from 'bcrypt';

 

회원 정보를 저장하기 이전에, 받아온 비밀번호를 암호화하는 로직을 작성하자.

  • bcrypt.hash
    • 첫 번째 파라미터: 암호화할 데이터 값
    • 두 번째 파라미터: 몇 번 암호화할 것인지 암호화 수행할 횟수
// user.servicec.ts
async transformPassword(user: UserDTO): Promise<void> {
    user.password = await bcrypt.hash(user.password, 10);
    return Promise.resolve();
  }

 

save 로직을 수정하자.

// user.service.ts
async save(userDTO: UserDTO): Promise<UserDTO | undefined> {
    await this.transformPassword(userDTO);
    return await this.userRepository.save(userDTO);
  }

 

Postman으로 암호화가 잘 되었는지 확인하자.

로그인 시 암호화된 비밀번호 비교하기

이제 로그인할 때 암호화된 값을 비교해야 한다. auth.service.ts에서 validateUser를 수정하자.

  • bcrypt.compare
    • 첫 번째 파라미터: 입력 받은 비밀번호
    • DB에서 조회한 비밀번호
// auth.service.ts
import * as bcrypt from 'bcrypt';

  async validateUser(userDTO: UserDTO): Promise<UserDTO | undefined> {
    let userFind: UserDTO = await this.userService.findByFields({
      where: { username: userDTO.username },
    });
    const validatePassword = await bcrypt.compare(
      userDTO.password,
      userFind.password,
    );
    if (!userFind || !validatePassword) {
      throw new UnauthorizedException();
    }
    return userFind;
  }

 

Postman을 통해서 잘 실행되는지 확인해보자.

에러 없이 잘 작동한다. 만약 비번을 다르게 작성했다면,

에러 발생해서 로그인할 수 없다.

 

NestJS 인증 프로젝트: 인증

1. JWT 토큰 사용법과 토큰 생성하기

토큰을 이용한 API 호출 방법

REST API를 호출할 때, 토큰을 이용하면 사용자 인증과 권한을 체크할 수 있다.

  1. Client -> Server로 로그인을 요청함
  2. Server에서 로그인을 체크하면 토큰을 생성하고, Server -> Client로 accessToken을 전달함
  3. Client는 받은 accessToken을 쿠키에 저장해놓음. 그러다가 Client -> Server로 API를 호출하면 Header에 accessToken을 담아서 호출함
  4. Server는 accessToken을 확인하여 인증여부, 권한 등을 체크하고 알맞은 결과 값을 Server -> Client로 return함

 

즉,

클라이언트에서 로그인을 할 때 권한에 맞는 토큰을 받고,

API를 호출할 때마다 토큰을 보내면 서버에서 토큰을 체크하고 API를 받을 권한에 충족하면 API 결과를 받을 수 있음.

이렇게 Client와 Server 사이에 토큰을 이용하여 인증하고, API를 사용할 수 있다.

 

JWT(Json Web Token)

  • 구분자 "."로 구분하여 3개 값이 들어있는 형태로 구성된다. 각 구성은 JSON 형태임.
  1. header: 알고리즘(alg), 토큰 타입(typ) 정의
  2. payload: 우리가 저장하고 싶은 데이터 값(id, username, ...), 토큰 생성 시간(iat), 토큰 만료 시간(exp)
  3. verify signature: 검증용 값(secret key)

 

JWT 설치

npm i --save @nestjs/jwt

 

JwtModule imports

현재 auth에서 JWT를 사용할 것이다. 따라서 auth.module.ts에서 JWT module을 imports해준다.

import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { UserRepository } from './user.repository';
import { UserService } from './user.service';

@Module({
  imports: [
    TypeOrmModule.forFeature([UserRepository]),
    JwtModule.register({
      secret: 'SECRET_KEY',
      signOptions: { expiresIn: '300s' },
    }),
  ],
  exports: [TypeOrmModule],
  controllers: [AuthController],
  providers: [AuthService, UserService],
})
export class AuthModule {}
  • register: JwtModule을 등록함
  • secret: (=verify signature) 검증할 secret key를 입력
  • signOptions > expiresIn : 만료 시간 설정

 

Payload Interface

payload는 우리가 담을 데이터 값이므로, payload 형식을 정의해야만 한다. 따라서 payload의 interface를 생성해주어야 한다.

'security' 폴더를 생성하고, 그 안에 'payload.interface.ts' 파일을 생성한다.

// payload.interface.ts
export interface Payload {
  id: number;
  username: string;
}
  • payload에는 id와 username 데이터를 보낸다.

 

JWT 토큰 생성 (validateUser 수정)

auth.service.ts에서 로그인하는 부분에서 validateUser 부분을 수정해주자.

  1. payload 값 지정 (형식: Payload)
  2. 생성한 token을 JSON 형식으로 리턴

 

this.userService.findByFields에서 username을 조회하면 user entity값(=userFind)을 전달받는다. 이 값을 payload에 담아서 JSON 형식으로 보내줄 것이다.

우선 JwtService를 constructor에 추가한다.

// auth.service.ts
@Injectable()
export class AuthService {
  constructor(
    private userService: UserService,
    private jwtService: JwtService,
  ) {}

auth Module에서 JwtModule을 등록했으므로 jwtService를 사용할 수 있다.

 

// auth.service.ts
async validateUser(
    userDTO: UserDTO,
  ): Promise<{ accessToken: string } | undefined> {
    let userFind: User = await this.userService.findByFields({
      where: { username: userDTO.username },
    }); // user Entity값을 넘겨받음

    // 회원 검증
    const validatePassword = await bcrypt.compare(
      userDTO.password,
      userFind.password,
    );
    if (!userFind || !validatePassword) {
      throw new UnauthorizedException();
    }

    // 넘겨받은 user Entity(userFind)를 payload에 담아줌
    const payload: Payload = { id: userFind.id, username: userFind.username };
    return {
      accessToken: this.jwtService.sign(payload),
    }; // accessToken으로 리턴
  }
  • payload 내에는 id가 들어가야하는데, userFind는 형식이 UserDTO이므로 id를 가지고 있지 않다. 따라서 User 형식으로 변경해준다.
  • userFind는 userService의 findByFields 메서드로 가져오므로, 해당 파일에 있는 값도 UserDTO -> User로 형식을 변경한다.

 

 

// user.service.ts 
 // 사용자 검색
  async findByFields(options: FindOneOptions<User>): Promise<User | undefined> {
    return await this.userRepository.findOne(options); // options에 들어온 내용으로 검색
  }
  • 토큰은 this.jwtService.sign(데이터값) 으로 발급받을 수 있다.

 

auth controller

// auth.controller.ts
  @Post('/login')
  async login(@Body() userDTO: UserDTO, @Res() res: Response): Promise<any> {
    const jwt = await this.authService.validateUser(userDTO);
    res.setHeader('Authorization', 'Bearer ' + jwt.accessToken);
    return res.json(jwt);
  }
  • authService의 validateUser로 가져온 jwt를 클라이언트에 넘겨주어야 한다. 따라서 @Res 데코레이터를 추가한다.
  • 헤더에 키 'Authorization'에 accessToken 값을 넣어준다. 값은 'Bearer + accessToken' 을 넣는다.
  • 마지막으로 res를 클라이언트로 리턴한다.

 

Postman으로 확인하자.

  • 먼저 계정을 생성한다.

  • 계정이 생성됨을 확인함

  • 응답(res)로 받은 accessToken을 복사하여 JWT 페이지에서 확인. 근데 signature를 작성하지 않았으므로 하단에 'invalid signature'라고 뜬다.

  • auth Module 내에서 JwtModule에서 작성해주었던 secret key인 'SECRET_KEY'를 입력하면 'signature verified'라고 뜬다.

  • 이전에 setHeader로 res의 header에 값을 넣어주었으므로 확인해보자.

  • 'Authroization : Bearer + accessToken' 값이 잘 추가되었음을 확인할 수 있다.

 

 

2. JWT 토큰 인증(Guard)

사용자가 토큰을 이용해서 요청을 보냈을 때, 토큰을 검증하고 인증해보자.

❗해당 부분은 이해하기 정말 어려웠다. 강의를 아무리 들어도 이해가 전혀 되지 않았다. 그래서 Node.js 책을 읽고 생활 코딩의 cookie, session, passport 강의를 모두 보면서 개념에 대해서 대충 이해를 하였다. Nodejs에서 passport를 이해한 후 이제 NestJS에서 passport를 이해하면 되겠다 싶어서 다른 설명을 찾아보다가, '따라하면서 배우는 NestJS' 강의에서 passport에 대해 설명해주는 내용을 보니 이해가 정말 잘 되었다. 코드기어 강의에서는 passport에 대해 안다는 가정 하에 설명을 자세하게 해주지 않았는데, '따라하면서 배우는 NestJS'에서는 하나하나 다 꼼꼼히 설명해주어서 그제서야 이해가 가더라.. 이후에 다시 코드기어 강의를 들으니까 코드가 눈에 읽히고 이해갔다.

 

Guard

  • 라우팅 전에 작동하는 일종의 미들웨어
  • Nestjs에서 JWT 토큰을 이용하여 인증할 때에는 'Guard'를 사용함
  • 해당 기능은 passport에서 지원해준다.

 

❗passport 란? 

  • 가장 유명한 node.js authentication(인증) library
  • 다양한 인증 매커니즘(session, jwt 등)을 각 모듈로 패키지화하여 제공하고 있으므로 편리하게 인증을 구현할 수 있음

[ 참고 자료: https://chanyeong.com/blog/post/28 ]

 

과정 간단하게 나타내기

  1. 토큰 값을 인증
  2. req를 처리
  3. 사용자 정보 가져옴

 

To do

  1. JWT 토큰 값을 검증을 위해 JwtStrategy를 생성함 ➡️ validate에서, 받은 payload에 있는 값으로 사용자 정보를 조회함
  2. AuthGuard 생성함 ➡️ JwtStrategy를 실행시켜서 req에 사용자 정보 값을 받아올 수 있음
  3. controller에 @UseGuards(AuthGuard) 를 작성함 ➡️ 라우팅 되기 전에 해당 미들웨어(AuthGuard)가 작동하도록 함

 

자, 이제 해보자!

필요한 라이브러리 설치

npm i --save @nestjs/passport @types/passport-jwt
  • passport : passport의 핵심 기능을 포함함
  • passport-jwt: jwt를 사용한 passport 인증

 

JwtStrategy 생성하기

security 폴더 내에 passport.jwt.strategy.ts 파일을 생성한다.

// security/passport.jwt.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy, VerifiedCallback } from 'passport-jwt';
import { AuthService } from '../auth.service';
import { Payload } from './payload.interface';

@Injectable()
// Strategy: 그냥 passport의 strategy(전략)이 아닌, passport-jwt의 strategy를 넣어줌
export class JwtStrategy extends PassportStrategy(Strategy) {
  // user를 불러올 것이기 때문에 AuthService 불러옴
  constructor(private authService: AuthService) {
    // super: 부모 생성자 호출.
    // 즉, PassportStrategy에 있는 jwtFromRequest, ignoreExpiration, secretOrKey 각각에 아래 해당하는 값들을 넘겨준다.
    // 이와 같은 파생 클래스(extends를 사용하는 class)는 super() 함수가 먼저 호출되어야만 this 키워드를 사용할 수 있다.
    // 그렇지 않으면 참조 오류가 발생함.
    super({
      secretOrKey: 'SECRET_KEY',
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken, // 토큰 종류: Bearer Token
      ignoreExpiration: true,
    });
  }

  // validate: JwtStrategy가 제공하는 함수.
  // Jwt token에서 payload를 가져오고, done 함수를 가져옴
  // done : (1) 에러 (2) req에 추가하여 넘겨줄 값
  async validate(payload: Payload, done: VerifiedCallback): Promise<any> {
    const user = await this.authService.tokenValidateUser(payload);

    // 전략 실패
    if (!user) {
      return done(
        new UnauthorizedException({ message: 'user does not exist' }),
        false, // req 거부됨
      );
    }
    // 전략 성공
    return done(null, user); // 에러 없이 처리됨 (-> AuthGuard로 user 전달 및 실행)
  }
}

this.authService에서 tokenValidateUser 메서드를 생성해야 한다. 여기서는 payload의 값을 읽고, 특정 유저를 가져온다.

// auth.service.ts
  async tokenValidateUser(payload: Payload): Promise<User | undefined> {
    return await this.userService.findByFields({
      where: { id: payload.id },
    });
  }
  • payload.id로 유저를 조회해온다.

 

auth Module에 추가하기

  • PassportModule을 imports하고,
  • JwtStrategy를 providers에 추가한다. (그래야 AuthController의 UseGuard에서 실행이 되니까.)

 

AuthGuard 작성하기

security 폴더 아래에 auth.guard.ts를 생성한다.

// security/auth.guard.ts
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard as NestAuthGuard } from '@nestjs/passport';
import { Observable } from 'rxjs';

@Injectable()
export class AuthGuard extends NestAuthGuard('jwt') {
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    return super.canActivate(context); // req에 정보를 넣어줌
  }
}
  • canActivate를 사용해야만 함. 여기에 context를 넣으면, jwt 토큰을 해석하고 payload값을 JwtStrategy를 실행해준다.

 

/authenticate 라우터 생성하기

auth.controller.js에 라우터를 jwt 토큰을 인증하는 /authenticate 라우터를 생성한다.

// auth.controller.ts
...
  // 인증을 받았는지 확인하는 라우터
  @Get('/authenticate')
  @UseGuards(AuthGuard) // 유저 계정 인증 미들웨어
  // 아래에서 req.user를 읽어오려면,
  // 1. UseGuards에서 canActive한지 봄. (canActivate하면 PassportStrategy 실행)
  // 2. 그리고 PassportStrategy(JwtStrategy)의 validlate를 실행시켜서 req에 user를 넘겨 받음
  // -> auth module에서 providers에 JwtStrategy를 추가했으므로 JwtStrategy가 실행 가능
  isAuthenticated(@Req() req: Request): any {
    const user: any = req.user;
    return user;
  }

 

즉, 과정은

라우터 /authenticate 

➡️UseGuards로 AuthGuard 실행

➡️Jwt Token 해석하고 canActivate를 실행해줌. 그러면 토큰 값을 해석한 결과 중 하나인 payload 값을 JwtStrategy에 넘겨줌

➡️JwtStrategy을 실행. validate에서 payload를 받고 그 값으로 auth.service의 메서드를 통해 유저 데이터를 받아옴. 받아온 유저는 return함

➡️controller에 도달. req.user 에 받아온 유저 데이터가 있음

 

3. 권한 관리(RoleGuard)

로그인 후 권한별로 접근을 제어해줘야 한다(authorization).

  • 권한 구분: admin, user
  • 사용자 별로 어떤 권한이 있는지 db에 저장되어 있어야 함
  • 메뉴 접근 시에 사용자의 권한을 체크하여 사용 가능 여부에 따라 접근을 허가 혹은 거부함

 

user_authority 테이블 생성

  • db에 사용자 권한을 관리하는 테이블인 user_authority을 생성한다. 구조는 다음과 같다.

 

테이블 초기 세팅

테이블에 column을 세팅한다.

CREATE TABLE IF NOT EXISTS test.user_authority (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  user_id BIGINT NOT NULL,
  authority_name ENUM('ROLE_USER', 'ROLE_ADMIN') NOT NULL,
  PRIMARY KEY (id))
ENGINE = InnoDB

값도 넣어준다.

insert into test.user_authority (user_id, authority_name) values (1, 'role_user');
insert into test.user_authority (user_id, authority_name) values (1, 'role_admin');
insert into test.user_authority (user_id, authority_name) values (2, 'role_user');

 

user_authority Entity 생성하기

  • 한 사용자가 여러 개의 authority를 가질 수도 있다.
  • 따라서 user_authority 테이블에서는 user 테이블과 ManyToOne으로 Join을 해주어야 한다.
    • Many: user_authority / One: user table

 

entity 폴더 아래에 user-authority.entity.ts 파일을 생성한다.

// user-authority.entity.ts
import {
  Column,
  Entity,
  JoinColumn,
  ManyToOne,
  PrimaryGeneratedColumn,
} from 'typeorm';
import { User } from './user.entity';

@Entity('user_authority')
export class UserAuthority {
  @PrimaryGeneratedColumn()
  id: number;

  @Column('int', { name: 'user_id' }) // db의 column명
  userId: number;

  @Column('varchar', { name: 'authority_name' })
  authorityName: string;

  // 여러 개의 user_authority가 하나의 user를 가짐(N:1)
  // -> 이렇게 하면 UserAuthority 테이블을 불러올 때(조회), user_id(외래키)도 함께 가져온다
  // User: 관계 설정할 table명
  // authorities: User table에서 관계 설정할 column명
  @ManyToOne((type) => User, (user) => user.authorities)
  @JoinColumn({ name: 'user_id' }) // join할 column명
  user: User;
}

user.entity.ts에서도 관계 설정을 해주어야 한다.

// user.entity.ts

  ...
  // OneToMany: 사용자 하나에 여러 권한을 가짐(1:N)
  // UserAuthority: 관계 설정할 table 명
  // user: UserAuthority table에서 관계 설정할 column 명
  @OneToMany((type) => UserAuthority, (userAuthority) => userAuthority.user, {
    // eager: User table을 조회할 때, UserAuthority와 join된 데이터까지 같이 가져오는 option
    eager: true,
  })
  authorities?: any[];
  • authorities는 배열 형태이므로 any[].

 

repository 폴더를 생성하고, user.repository.ts도 옮긴다.

그리고 app.module.ts에서 약간의 코드를 수정한다.

  • logging: 로그에 쿼리문이 보이게 하는 Option

 

repository 폴더에 user-authority.repository.ts 파일을 생성한다.

// repository/user-authority.repository.ts
import { EntityRepository, Repository } from 'typeorm';
import { UserAuthority } from '../entity/user-authority.entity';

@EntityRepository(UserAuthority)
export class UserAuthorityRepository extends Repository<UserAuthority> {}​

그리고 app.module.ts에서 TypeOrmModule의 entities에 UserAuthority를 추가해준다.

 

auth.module.ts에서 repository도 추가해준다.

 

또한 payload.interface.ts 에도 authorities 속성이 있어야 한다. 따라서 추가해준다.

 

role-type.ts 파일을 생성하고 role을 enum으로 작성해주자. DB에서 정의한 것과 동일하게 만들어주면 된다.

생성된 이 파일은 controller 등에서 어떤 타입을 지정해줄 떄 사용될 것이다.

 

decorator 폴더를 생성하고, role.decorator.ts 파일을 생성한다.

이는 @Roles(RoleType.ADMIN) 와 같은 형식으로 사용된다.

import { SetMetadata } from '@nestjs/common';
import { RoleType } from '../role-type';

export const Roles = (...roles: RoleType[]): any => SetMetadata('roles', roles);

 

 

 

... => 무ㅓ라는지 1도 모르겠다 설명을 왜 하나도 안해주고 걍 코드만 적지

다른 강의 보고 이해하고 다시 와야겠음

 

 

 

 

 

 

반응형

'Back-End' 카테고리의 다른 글

[Node.js] 라우트 처리하기  (0) 2023.07.25
Node.js + Express + TypeScript  (0) 2023.07.22
redis란?  (0) 2022.03.08
logger  (0) 2022.03.07
[작성중]elasticsearch putty  (0) 2021.10.29