NestJS Auth - Authentication

이게되네·2021년 2월 14일
6

NestJS Auth

목록 보기
3/4
post-thumbnail

이전 글까지는 Authentication을 준비하는 단계였습니다. 이번글에서는 JWT를 이용한 회원가입과 로그인 과정 및 사용자 인증(Authentication)** 과정을 다루어 보도록 하겠습니다.

참고 🔍

Index 📋

  • Sign Up (회원가입)
    • User Module
    • User Service
    • User Controller
    • Hashing Password
  • Sign In (로그인)
    • Verify User
    • Token Generate
    • Verify Token
    • Guard Decorator

Sign Up (회원가입) 📝

User Module

  • Register Repository to use User entity
    User Module에서 TypeOrmModule의 forFeature() 메서드를 사용하여 현재 scope(User)에서 어떤 repository를 등록할 것 인지 결정합니다.
// src/user/user.module.ts

import { Module } from '@nestjs/common';
import { UserService } from './user.service';
import { UserController } from './user.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entity/user.entity';

@Module({
  imports: [TypeOrmModule.forFeature([User])],
  providers: [UserService],
  controllers: [UserController],
})
export class UserModule {}

User Service

  • Inject Repository
    Module에 등록하면 우리는 현재의 범위(User)에서 UsersRepository@InjectRepository() decorator를 사용하여 UserService안에 inject 할 수 있습니다.
// src/user/user.service.ts

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entity/user.entity';

@Injectable()
export class UserService {
  constructor(
    @InjectRepository(User)
    private readonly usersRepository: Repository<User>,
  ) {}
}
  • 먼저 필요한 package를 설치하고, DTO(Data Transfer Object)를 통해 Validation 작업을 합니다.

참고 🔍

$ npm i class-validator class-transformer
// src/main.ts

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true,
    }),
  );

  await app.listen(3000);
}
bootstrap();
  • Define DTO(Data Transfer Object)
// src/user/dtos/create-user.dto.ts

import { IsEmail, IsString } from 'class-validator';

export class CreateUserRequestDto {
  @IsEmail()
  username: string;

  @IsString()
  password: string;

  @IsString()
  name: string;
}
  • Insert User Object to Database
    user repository의 save() method를 통해 database에 object를 insert 해줍니다. 그전에 등록할 계정이 이미 존재한다면 error를 반환합니다.
// src/user/user.service.ts

import { ForbiddenException, HttpException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CreateUserRequestDto } from './dtos/create-user.dto';
import { User } from './entity/user.entity';

@Injectable()
export class UserService {
  constructor(
    @InjectRepository(User)
    private readonly usersRepository: Repository<User>,
  ) {}

  async create(data: CreateUserRequestDto) {
    const isExist = await this.usersRepository.findOne({
      username: data.username,
    });
    if (isExist) {
      throw new ForbiddenException({
        statusCode: HttpStatus.FORBIDDEN,
        message: [`이미 등록된 사용자입니다.`],
        error: 'Forbidden',
      });
    }
    try {
      await this.usersRepository.save(data);
    } catch (error) {
      return {
        ...error,
      };
    }
    return {
      statusCode: HttpStatus.CREATED,
    };
  }
}

User Controller

  • Create User End Point
    • UserController가 POST 요청으로 body에 사용자를 생성할 데이터를 http://localhost:{PORT}/user로 받습니다.
    • 추가로 생성자로 UserService instance를 inject 합니다.
// src/user/user.controller.ts

import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { CreateUserRequestDto } from './dtos/create-user.dto';
import { UserService } from './user.service';

@Controller('user')
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Post()
  @HttpCode(HttpStatus.CREATED)
  create(@Body() data: CreateUserRequestDto) {
    return this.userService.create(data);
  }
}

Hashing Password 🧶

사용자 등록하기 위해 DB에 Insert 되기 전, 비밀번호를 hashing 하는 작업을 해보도록 하겠습니다.

  • Install bcrypt package
$ npm install bcrypt
  • TypeORM Listeners and Subscribers
    TypeORM - Listeners and Subscribers 를 참고하시면 TypeORM에 특별한 기능이 있습니다.

    Any of your entities can have methods with custom logic that listen to specific entity events. You must mark those methods with special decorators depending on what event you want to listen to.

    모든 Entity들은 특정 entity event를 기다리고 있는 사용자 정의 로직 메서드를 가질 수 있습니다.

    저는 @BeforeInsert() 라는 데코레이터를 사용하여, 사용자를 등록하여 DB에 Insert 되기 전, 비밀번호를 hashing 하는 작업을 해보도록 하겠습니다.

// src/user/entity/user.entity.ts

// ...
@Entity({ name: 'users' })
export class User {
  // ...
 
  @BeforeInsert()
  async hashPassword(): Promise<void> {
    try {
      this.password = await bcrypt.hash(this.password, 10);
    } catch (e) {
      console.log(e);
      throw new InternalServerErrorException();
    }
  }
}

Sign In (로그인) 🔐

Token Generate 🎫

아이디와 비밀번호로 로그인을 받으면 우리의 서버에서 계정 정보를 검증하고 맞다면 sigined 토큰을 발급 해줍니다. (토큰에는 중요한 개인정보가 담기면 안됩니다! 그저 발급한 토큰이 우리의 서버에서 정상적으로 발급된 토큰임을 증명하기 위함입니다.)

(나중에 사용자는 인증이 필요한 End point에 요청하기 위해 발급받은 토큰을 이용하게 됩니다.)

  • Set Up
$ npm i jsonwebtoken

$ nest generate module jwt
$ nest generate service jwt

그리고 JWT Module을 option으로 privateKey를 받는 동적모듈로 만들기 위해 option interface와 di token 값(jwt constant)을 만들어줍니다.

참고 🔍

  • JWT Interface
export interface JwtModuleOptions {
  privateKey: string;
}
  • JWT Constant
export const CONFIG_OPTIONS = 'CONFIG_OPTIONS';
  • JWT Module
    JWT Module을 @Global() decorator로 전역에서 사용할 수 있도록 하고, forRoot() static method로 Dynamic Module을 반환하도록 합니다.
@Module({})
@Global()
export class JwtModule {
  static forRoot(options: JwtModuleOptions): DynamicModule {
    return {
      module: JwtModule,
      exports: [JwtService],
      providers: [
        {
          provide: CONFIG_OPTIONS,
          useValue: options,
        },
        JwtService,
      ],
    };
  }
}
  • App Module
    AppModuleJWTModule을 import 하도록 합시다. privateKey는 JWT 생성과 검증에 필요한 비밀키를 지정합니다. (예: mYsEcReTkEy0011)
// src/app.module.ts

@Module({
  imports: [

    JwtModule.forRoot({
      privateKey: process.env.PRIVATE_KEY, // JWT private Key 아무거나. 
    }),
  ]
})
// ...
  • JWT Service (토큰 발행)
    JwtService에는 토큰 발행을 담당하는 sign method를 정의해줍니다. 토큰에는 userId (uuid)를 담아서 줍니다.
import { Inject, Injectable } from '@nestjs/common';
import * as jwt from 'jsonwebtoken';
import { CONFIG_OPTIONS } from './jwt.constants';
import { JwtModuleOptions } from './jwt.interface';

@Injectable()
export class JwtService {
  constructor(
    @Inject(CONFIG_OPTIONS) private readonly options: JwtModuleOptions,
  ) {}

  // 로그인 성공하면 token을 만들어 보냄
  sign(userId: string): string {
    return jwt.sign({ id: userId }, this.options.privateKey);
  }
}

Verify User

id와 password를 받아 사용자를 확인하는 기능을 만듭니다.

  • user entity
    User Entity에 비밀번호를 검증하는 helper method를 추가합니다.

참고 🔍

// src/user/entity/user.entity.ts

@Entity({ name: 'users' })
export class User {
  // ...
  
  async checkPassword(inputPassword: string): Promise<boolean> {
    try {
      return await bcrypt.compare(inputPassword, this.password);
    } catch (error) {
      console.log(error);
      throw new InternalServerErrorException({
        ...error.response,
      });
    }
  }
}
  • DTO
export class SignInRequestDto {
	username: string;
  	password: string;
}

export class SignInResponseDto {
	statusCode: number;
  	token?: string;
  	error?: string;
    message?: string;
}
  • User Service
    사용자임을 확인하고 token을 발행해줍니다.
// src/user/user.service.ts

@Injectable()
export class UserService {
  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
    private readonly jwtService: JwtService,
  ) {}

  // ...
  
  async signIn({
    username,
    password,
  }: SignInRequestDto): Promise<SignInResponseDto> {
    try {
      const user = await this.userRepository.findOne({ username });
      if (!user) {
        throw new NotFoundException({
          error: 'Not Found',
          message: ['사용자를 찾지 못했습니다.'],
        });
      }
      const passwordCorrect = await user.checkPassword(password);
      if (!passwordCorrect) {
        throw new BadRequestException({
          error: 'Bad Request',
          message: ['비밀번호가 틀렸습니다.'],
        });
      }

      const token = this.jwtService.sign(user.id);
      return {
        statusCode: 201,
        token,
      };
    } catch (error) {
      return {
        statusCode: error.status,
        ...error.response,
      };
    }
  }
}
  • User Controller
    POST 요청으로 request body로는 usernamepassword를 받습니다.
@Controller('users')
export class UserController {
  constructor(private readonly userService: UserService) {}
  
  
  @Post('/sign-in')
  signIn(@Body() data: SignInRequestDto): Promise<SignInResponseDto> {
    return this.userService.signIn(data);
  }
}

Authentication

로그인된 사용자가 인증이 필요한 서버의 어느 한 End point로 요청하기위해 발행된 토큰을 request header의 x-jwt라는 이름으로 담아 요청합니다.

이 때 우리는 모든 요청에 대해 header를 검사하는 JwtMiddleware를 만들어 줍니다.

Verify Token

  • jwt service (토큰 검증)
    verify method를 추가해줍니다.
// src/jwt/jwt.service.ts

@Injectable()
export class JwtService {
  // ...
  
  verify(token: string) {
    return jwt.verify(token, this.options.privateKey);
  }
}
  • user service (사용자 찾기)
// src/user/user.service.ts


@Injectable()
export class UserService {
  // ...

  async findById(id: string): Promise<User> {
    return await this.userRepository.findOne(id);
  }
}
  • JWT Middleware
    JWT Middleware에서는 header의 x-jwt토큰을 검증하여 request object에 'user': User를 추가합니다.
    다음으로 next() method를 호출하여 다음 함수로 넘어갑니다. (header에 x-jwt이 없다면 user를 추가하지 않고 다음으로 넘어갑니다.)
// src/jwt/jwt.middleware.ts

@Injectable()
export class JwtMiddleware implements NestMiddleware {
  constructor(
    private readonly userService: UserService,
    private readonly jwtService: JwtService,
  ) {}

  async use(req: Request, res: Response, next: NextFunction) {
    if ('x-jwt' in req.headers) {
      const token = req.headers['x-jwt'];
      const decoded = this.jwtService.verify(token.toString());
      if (typeof decoded === 'object' && decoded.hasOwnProperty('id')) {
        try {
          const user = await this.userService.findById(decoded['id']);
          req['user'] = user;
        } catch (err) {
          console.log(err);
        }
      }
    }
    next();
  }
}
  • App Module
    App Module에 JWT Middleware를 모든 요청으로 적용시켜줍니다.
// src/app.module.ts

@Module({
  // ...
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(JwtMiddleware)
      .forRoutes({ path: '/', method: RequestMethod.ALL });
  }
}

참고 🔍

Auth Guard

  • Set up
$ nest generate guard auth auth
  • Auth Guard
@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {

    const request = context.switchToHttp().getRequest();
    const user: User = request['user'];
      
    if (!user) return false;
    return true;
  }
}

Use Guards

  • Auth Guard 적용
    @UserGuards() decorator를 사용하여 우리가 만든 AuthGuard를 적용해줍니다.
    token검증을 성공하면 Get Me!를 받고, 검증에 실패하면 UnAuthorized Error를 받습니다.
// src/user/user.controller.ts

@Controller('users')
export class UserController {
  // ...
  
  @Get('/me')
  @UseGuards(AuthGuard)
  getMe() {
    return 'Get Me!';
  }
}

참고 🔍

  • Custom Decorator
    이전에 Middleware에서 request object에 user를 넣었었습니다. 이를 next() method로 다음으로 넘어온 함수에서 사용하기 위해 Custom Decorator로 정의한 @AuthUser()를 사용합니다.
// src/auth/auth-user.decorator.ts

import { createParamDecorator, ExecutionContext } from '@nestjs/common';

export const AuthUser = createParamDecorator(
  (data: unknown, context: ExecutionContext) => {
    const request = context.switchToHttp().getRequest();
    const user = request['user'];
    return user;
  },
);

참고 🔍


정리

사용자 인증 / 인가 부분은 많은 기술 내용들과 작업들을 담고 있기 때문에 글을 쓰면서도 아직 부족하다는 느낌을 많이 받았습니다. 추후에 수정을 하면서 채워나가도록 하겠습니다...  
༼;´༎ຶ + ༎ຶ༽ 

profile
BackEnd Developer

0개의 댓글