[nestjs] restapi + prisma 06 / 최종

in kr •  2 years ago 

이전 글

PART5

NestJS REST API에서 JWT 인증을 구현하는 방법을 배웁니다.

이 장에서는 Passport라는 패키지를 사용하여 API에 인증을 추가하는 방법을 배웁니다.

먼저 Passport라는 라이브러리를 사용하여 JWT(JSON Web Token) 기반 인증을 구현합니다.

다음으로 bcrypt 라이브러리를 사용하여 해싱하여 데이터베이스에 저장된 암호를 보호합니다.

auth 모듈 생성

npx nest generate resource

  • auth
  • rest api
  • no

passport 설치 및 설정

npm install --save @nestjs/passport passport @nestjs/jwt passport-jwt
npm install --save-dev @types/passport-jwt
//src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { PrismaModule } from 'src/prisma/prisma.module';

export const jwtSecret = 'zjP9h6ZI5LoSKCRj';

@Module({
  imports: [
    PrismaModule,
    PassportModule,
    JwtModule.register({
      secret: jwtSecret,
      signOptions: { expiresIn: '5m' }, // e.g. 30s, 7d, 24h
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService],
})
export class AuthModule {}

login 구현

mkdir src/auth/dto
touch src/auth/dto/login.dto.ts
//src/auth/dto/login.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';

export class LoginDto {
  @IsEmail()
  @IsNotEmpty()
  @ApiProperty()
  email: string;

  @IsString()
  @IsNotEmpty()
  @MinLength(6)
  @ApiProperty()
  password: string;
}
mkdir src/auth/entity
touch src/auth/entity/auth.entity.ts
//src/auth/entity/auth.entity.ts
import { ApiProperty } from '@nestjs/swagger';

export class AuthEntity {
  @ApiProperty()
  accessToken: string;
}

http://localhost:3000/api/auth/login 로 POST 메시지를 아래와 같이 보내면 토큰을 받을 수 있습니다.

// request
{
  "email": "[email protected]",
  "password": "password-sabin"
}

// response
{
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsImlhdCI6MTY5MDE2NjY0NywiZXhwIjoxNjkwMTY2OTQ3fQ.fGwjwZGuB1DBDtPkzh5Wk9ZTOXInVZYa5zMDQ5Fn6-0"
}

touch src/auth/jwt.strategy.ts

JWT 인증전략 구현

passport 패키지를 직접 사용하지 않고 @nestjs/passport 래퍼 패키지와 상호 작용하여 내부에서 passport 패키지를 호출합니다.
@nestjs/passport로 전략을 구성하려면 PassportStrategy 클래스를 확장하는 클래스를 만들어야 합니다.

JWT 전략 관련 옵션 및 구성을 생성자의 super() 메서드에 전달합니다.
JWT 페이로드를 기반으로 사용자를 가져오기 위해 데이터베이스와 상호 작용하는 validate() 콜백 메서드입니다.
사용자가 발견되면 validate() 메서드는 사용자 개체를 반환할 것으로 예상됩니다.

import { ExtractJwt, Strategy } from 'passport-jwt';
//src/auth/jwt.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';

import { PassportStrategy } from '@nestjs/passport';
import { UsersService } from 'src/users/users.service';
import { jwtSecret } from './auth.module';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
  constructor(private usersService: UsersService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: jwtSecret,
    });
  }

  async validate(payload: { userId: number }) {
    const user = await this.usersService.findOne(payload.userId);

    if (!user) {
      throw new UnauthorizedException();
    }

    return user;
  }
}

PassportStrategy 클래스를 확장하는 JwtStrategy 클래스를 만들었습니다.
PassportStrategy 클래스는 전략 구현전략 이름 이라는 두 가지 인수를 사용합니다.
여기에서는 passport-jwt 라이브러리의 미리 정의된 전략을 사용하고 있습니다.

생성자의 super() 메서드에 몇 가지 옵션을 전달하고 있습니다.
jwtFromRequest 옵션은 요청에서 JWT를 추출하는 데 사용할 수 있는 메서드를 예상합니다.
이 경우 API 요청의 Authorization 헤더에 베어러 토큰을 제공하는 표준 접근 방식을 사용합니다.
secretOrKey 옵션은 전략에 JWT를 확인하는 데 사용할 암호를 알려줍니다.
passport-jwt 저장소에서 읽을 수 있는 더 많은 옵션이 있습니다.

passport-jwt의 경우 Passport는 먼저 JWT의 서명을 확인하고 JSON을 디코딩합니다.
그런 다음 디코딩된 JSON이 validate() 메서드로 전달됩니다.
JWT 서명이 작동하는 방식에 따라 앱에서 이전에 서명하고 발급한 유효한 토큰을 받을 수 있습니다.
validate() 메서드는 사용자 개체를 반환할 것으로 예상됩니다.
사용자를 찾을 수 없으면 validate() 메서드에서 오류가 발생합니다.

//src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { PrismaModule } from 'src/prisma/prisma.module';
import { UsersModule } from 'src/users/users.module';
import { JwtStrategy } from './jwt.strategy';

export const jwtSecret = 'zjP9h6ZI5LoSKCRj';

@Module({
  imports: [
    PrismaModule,
    PassportModule,
    JwtModule.register({
      secret: jwtSecret,
      signOptions: { expiresIn: '5m' }, // e.g. 7d, 24h
    }),
    UsersModule,
  ],
  controllers: [AuthController],
  providers: [AuthService, JwtStrategy],
})
export class AuthModule {}

이제 다른 모듈에서 JwtStrategy를 사용할 수 있습니다. JwtStrategy 클래스에서 UsersService가 사용되고 있기 때문에 가져오기에 UsersModule도 추가했습니다.

JwtStrategy 클래스에서 UsersService에 액세스할 수 있도록 하려면 UsersModule의 내보내기에도 추가해야 합니다.

// src/users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
import { PrismaModule } from 'src/prisma/prisma.module';

@Module({
  controllers: [UsersController],
  providers: [UsersService],
  imports: [PrismaModule],
  exports: [UsersService],
})
export class UsersModule {}
//src/auth/jwt-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

AuthGuard 클래스는 전략의 이름을 예상합니다. 이 경우 이전 섹션에서 구현한 jwt라는 JwtStrategy를 사용하고 있습니다.

이제 이 가드를 데코레이터로 사용하여 엔드포인트를 보호할 수 있습니다. UsersController의 경로에 JwtAuthGuard를 추가합니다.

@UseGuards(JwtAuthGuard) 를 controller 에 추가하여 jwt 인증을 추가합니다.

// src/users/users.controller.ts
import {
  Controller,
  Get,
  Post,
  Body,
  Patch,
  Param,
  Delete,
  ParseIntPipe,
  UseGuards,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { ApiCreatedResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { UserEntity } from './entities/user.entity';
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';

@Controller('users')
@ApiTags('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  @ApiCreatedResponse({ type: UserEntity })
  async create(@Body() createUserDto: CreateUserDto) {
    return new UserEntity(await this.usersService.create(createUserDto));
  }

  @Get()
  @UseGuards(JwtAuthGuard)
  @ApiOkResponse({ type: UserEntity, isArray: true })
  async findAll() {
    const users = await this.usersService.findAll();
    return users.map((user) => new UserEntity(user));
  }

  @Get(':id')
  @UseGuards(JwtAuthGuard)
  @ApiOkResponse({ type: UserEntity })
  async findOne(@Param('id', ParseIntPipe) id: number) {
    return new UserEntity(await this.usersService.findOne(id));
  }

  @Patch(':id')
  @UseGuards(JwtAuthGuard)
  @ApiCreatedResponse({ type: UserEntity })
  async update(
    @Param('id', ParseIntPipe) id: number,
    @Body() updateUserDto: UpdateUserDto,
  ) {
    return new UserEntity(await this.usersService.update(id, updateUserDto));
  }

  @Delete(':id')
  @UseGuards(JwtAuthGuard)
  @ApiOkResponse({ type: UserEntity })
  async remove(@Param('id', ParseIntPipe) id: number) {
    return new UserEntity(await this.usersService.remove(id));
  }
}

@ApiBearerAuth() 를 추가 하면, 이제 인증 보호 엔드포인트에는 Swagger 🔓에 잠금 아이콘이 있어야 합니다.

현재 이러한 끝점을 테스트할 수 있도록 Swagger에서 직접 "인증"하는 것은 불가능합니다.
이를 위해 main.ts의 SwaggerModule 설정에 .addBearerAuth() 메서드 호출을 추가할 수 있습니다.

// src/main.ts

import { NestFactory, Reflector } from '@nestjs/core';
import { AppModule } from './app.module';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { ClassSerializerInterceptor, ValidationPipe } from '@nestjs/common';

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

  app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
  app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));

  const config = new DocumentBuilder()
    .setTitle('Median')
    .setDescription('The Median API description')
    .setVersion('0.1')
    .addBearerAuth()
    .build();
  const document = SwaggerModule.createDocument(app, config);
  SwaggerModule.setup('api', app, document);

  await app.listen(3000);
}
bootstrap();

암호 해싱

bcrypt.hash 함수는 해시 함수에 대한 입력 문자열과 해싱 라운드 수(비용 요소라고도 함)라는 두 가지 인수를 허용합니다. 해싱 횟수를 늘리면 해시를 계산하는 데 걸리는 시간이 늘어납니다. 여기서 보안과 성능 사이에는 절충안이 있습니다. 해싱 횟수가 많을수록 해시를 계산하는 데 더 많은 시간이 걸리므로 무차별 암호 대입 공격을 방지하는 데 도움이 됩니다. 그러나 더 많은 해싱 라운드는 사용자가 로그인할 때 해시를 계산하는 데 더 많은 시간을 의미하기도 합니다.

bcrypt는 또한 솔팅이라는 다른 기술을 자동으로 사용하여 해시를 무차별 대입하기 어렵게 만듭니다. 솔팅은 해싱 전에 임의의 문자열을 입력 문자열에 추가하는 기술입니다. 이렇게 하면 각 암호의 솔트 값이 다르기 때문에 공격자는 미리 계산된 해시 테이블을 사용하여 암호를 해독할 수 없습니다.

또한 암호를 데이터베이스에 삽입하기 전에 암호를 해시하도록 데이터베이스 시드 스크립트를 업데이트해야 합니다.

npm install bcrypt
npm install --save-dev @types/bcrypt
// src/users/users.service.ts
import { Injectable } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { PrismaService } from 'src/prisma/prisma.service';
import * as bcrypt from 'bcrypt';

export const roundsOfHashing = 10;

@Injectable()
export class UsersService {
  constructor(private prisma: PrismaService) {}

  async create(createUserDto: CreateUserDto) {
    const hashedPassword = await bcrypt.hash(
      createUserDto.password,
      roundsOfHashing,
    );

    createUserDto.password = hashedPassword;

    return this.prisma.user.create({
      data: createUserDto,
    });
  }

  findAll() {
    return this.prisma.user.findMany();
  }

  findOne(id: number) {
    return this.prisma.user.findUnique({ where: { id } });
  }

  async update(id: number, updateUserDto: UpdateUserDto) {
    if (updateUserDto.password) {
      updateUserDto.password = await bcrypt.hash(
        updateUserDto.password,
        roundsOfHashing,
      );
    }

    return this.prisma.user.update({
      where: { id },
      data: updateUserDto,
    });
  }

  remove(id: number) {
    return this.prisma.user.delete({ where: { id } });
  }
}

// prisma/seed.ts
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcrypt';

// initialize the Prisma Client
const prisma = new PrismaClient();

const roundsOfHashing = 10;

async function main() {
  // create two dummy users
  const passwordSabin = await bcrypt.hash('password-sabin', roundsOfHashing);
  const passwordAlex = await bcrypt.hash('password-alex', roundsOfHashing);

  const user1 = await prisma.user.upsert({
    where: { email: '[email protected]' },
    update: {
      password: passwordSabin,
    },
    create: {
      email: '[email protected]',
      name: 'Sabin Adams',
      password: passwordSabin,
    },
  });

  const user2 = await prisma.user.upsert({
    where: { email: '[email protected]' },
    update: {
      password: passwordAlex,
    },
    create: {
      email: '[email protected]',
      name: 'Alex Ruheni',
      password: passwordAlex,
    },
  });

  // create three dummy posts
  // ...
}

// execute the main function
// ...

npx prisma db seed 를 실행하면, 위 2명의 user 의 암호가 업데이트 되는 것을 확인할 수 있다

사용자 유효성 검증

해시된 암호를 비교하여 동일한지 여부를 검증

//src/auth/auth.service.ts
import { AuthEntity } from './entity/auth.entity';
import { PrismaService } from './../prisma/prisma.service';
import {
  Injectable,
  NotFoundException,
  UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';

@Injectable()
export class AuthService {
  constructor(private prisma: PrismaService, private jwtService: JwtService) {}

  async login(email: string, password: string): Promise<AuthEntity> {
    const user = await this.prisma.user.findUnique({ where: { email } });

    if (!user) {
      throw new NotFoundException(`No user found for email: ${email}`);
    }

    const isPasswordValid = await bcrypt.compare(password, user.password);

    if (!isPasswordValid) {
      throw new UnauthorizedException('Invalid password');
    }

    return {
      accessToken: this.jwtService.sign({ userId: user.id }),
    };
  }
}

PART5 끝

이 장에서는 NestJS REST API에서 JWT 인증을 구현하는 방법을 배웠습니다.
또한 암호 솔팅 및 인증을 Swagger와 통합하는 방법에 대해서도 배웠습니다.

참조링크

Authors get paid when people like you upvote their post.
If you enjoyed what you read here, create your account today and start earning FREE STEEM!
Sort Order:  

[광고] STEEM 개발자 커뮤니티에 참여 하시면, 다양한 혜택을 받을 수 있습니다.

image.png

Upvoted! Thank you for supporting witness @jswit.