nestjs, prisma, postgres ,graphql + nextjs v13 으로 게시판 만들기(1) - 회원가입 로그인(2)

junny·2023년 4월 28일
0
post-thumbnail

알아야할 개념정리

앞선 글에서 passportjs, jwt를 통해서 로그인을 구현한다고 했다.
그럼 passportjs와 jwt가 무엇인지 간단하게만 알아보자(참고로 벨로그에 jwt관련해서 좋은 글들이 많다. 혹시라도 더 알고 싶으면 찾아봅시다)

passport

passportjs는 간단하게 정리하면 로그인 절차를 확실하게 하기 위해서 사용하는 라이브러리이다.
기본적으로 passport는 '전략(Strategy)'을 통해서 인증절차를 한다. 그중 "jwt"를 사용하는 인증 전략을 사용!
jwt 전략을 통해서 인증을 마치면, headers에 유저 정보를 추가해 준다. 이건 아래 로직에서 확인!

jwt

jwt는 JSON Web Token의 약자로 인증에 필요한 정보들을 암호화시킨 JSON 토큰이다.
jwt는 header, payload, signature 3가지로 구분된다.
header에는 어떤 알고리즘을 써서 서명을 했는지와 토큰 유형을 알려준다.
payload는 만료시간, 발행자 같은 서버와 클라이언트가 주고 받는 시스템에 실제 사용될 정보를 가지고 있다.
마지막으로 signature은 서버가 가진 유일한 key값과 header, payload를 합쳐서 header에 있는 알고리즘으로 암호화한 정보를 가지고 있다.
그래서 서버는 signature를 통해서 클라이언트에서 온 토큰이 위조가 됐는지 아닌지를 확인하고 다른 정보가 위조되었다고 하면 인증 거부, 위조가 된 정보가 아니면 인증 할 수 있다.

위는 아주 많이 요약한 방법이다.
https://inpa.tistory.com/entry/WEB-%F0%9F%93%9A-JWTjson-web-token-%EB%9E%80-%F0%9F%92%AF-%EC%A0%95%EB%A6%AC#token_%EC%9D%B8%EC%A6%9D
위의 글에 굉장히 자세하게 설명되어 있으니 꼭 한번 읽어보자!

bcrypt

또 알야 할 개념은 bcrypt이다.
만약 비밀번호를 그대로 db에 저장한다면, db가 유출되었을 때 문제가 되고 db를 보는 사람들한테도 비밀번호를 알면 안된다. 그래서 암호화해서 비밀번호를 저장한다.
이때 사용하는 것이 bcrypt이다. bcrypt는 암호화 방법 중 하나로 강력한 해시 메커니즘을 가지고 있다. 또 반복횟수를 늘려서 연산속도를 늦출 수 있어서 무차별 암호화 공격도 대비를 할 수 있다.

코드 구현

auth.module.ts에 세팅내용 살펴보기

import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { PrismaModule } from 'src/prisma/prisma.module';
import { PrismaService } from 'src/prisma/prisma.service';
import { UserRepository } from 'src/user/user.repository';
import { AuthService } from './auth.service';

@Module({
  imports: [
    PassportModule.register({
      defaultStrategy: 'jwt',
      session: false,
    }),
    JwtModule.register({
      global: true,
      secret: process.env.JWT_SECRET_KEY,
      signOptions: {
        expiresIn: '24h',
      },
    }),
    PrismaModule,
  ],
  providers: [AuthService, UserRepository, PrismaService],
  exports: [AuthService],
})
export class AuthModule {}

authModule 메타데이터에
passport 모듈을 등록하는데 사용할 전략은 'jwt' session은 사용하지 않음
jwt 모듈을 등록하는데 jwt를 전역으로 쓰고 비밀키는 환경변수에 있는 JWT_SECRET_KEY를 사용하며 서명 옵션에서 토큰은 24시간 동안 사용하겠다라는 의미이다.

회원가입

지금 graphql 세팅이 code first니까 graphql 타입부터 만들어주자
이번 프로젝트에는 email, password을 통해 로그인을 구현하려고 한다.

input CreateUserInput {
  email: String!
  password: String!
}


type Mutation {
  createUser(createUserInput: CreateUserInput!): User!
}

input으로 된 type은 graphql에서 사용하는 타입으로 mutation에 들어오는 값에 대한 정보를 담고 있다.

타입을 등록했으면 resolver에서 createUser mutation을 만들자
재실행 하면 src/grahpql에 우리가 만든 타입이 들어있다!(왜그런지는 전 블로그를 보면 나와있다!)

import { Resolver, Query, Mutation, Args } from '@nestjs/graphql';
import { CreateUserInput } from 'src/graphql';
import { UserService } from './user.service';

@Resolver('User')
export class UserResolver {
  constructor(
    private readonly userService: UserService,
  ) {}

  @Mutation('createUser')
  signUp(@Args('createUserInput') createUserInput: CreateUserInput) {
    return this.userService.signUp(createUserInput);
  }

  // 에러를 막기위한 쿼리
  @Query('user')
  me() {
    return '';
  }
}

지금 서비스에 signUp함수가 없다
여기에는 이제 로직이 담긴다
구현해야 할 사항
1. 해당 email을 사용하는 유저가 있는지?
-> 있으면 에러
2. password를 bcrypt로 암호화
3. email과 암호화한 password를 db에 저장

먼저 prisma를 통해 email로 유저를 찾는 로직과 user 정보를 저장하는 로직을 만들어보자. 여러 예제에서 service에서 db를 이용해 처리하는 로직을 서비스에 직접 구현하지만 db에서 하는 행동은 service랑은 구분되어야 한다고 생각한다. 또 그래야만 test로직을 쓰기도 쉬워진다
그렇기 때문에 user.repository.ts 파일을 만들어 로직을 구현하려 한다.

import { Injectable } from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service';
import { CreateUserInput } from './dto/create-user.input';

@Injectable()
export class UserRepository {
  constructor(private readonly prismaService: PrismaService) {}

  async findByEmail(email: string) {
    return await this.prismaService.user.findFirst({
      where: {
        email,
      },
    });
  }

  async create(createUserInput: CreateUserInput) {
    return await this.prismaService.user.create({
      data: createUserInput,
    });
  }
}

로직을 만들었으면 user module providers에 등록해주자!

  • service 로직구현

password를 bcrypt로 암호화하는 로직은 구체적인 구현 사항이니 분리해서 구현을 하자
그럼 먼저 bcrypt로 암호화하는 로직을 구현하자

일단 개인취향으로 기존 값을 바꾸는 것보다는 새롭게 값을 만들어서 하는 걸 선호하기 때문에 builder 패턴을 지원해주는 라이브러이인 builder-pattern 라이브러리를 설치한 다음 빌더 패턴을 써서 새로운 값을 리턴하려고 한다.
npm의 bcrypt 라이브러리는 비동기로 작동을 한다. 그래서 async await을 사용해서 비밀번호를 hash를 한다.
해쉬된 비밀번호와 이메일을 가진 객체를 리턴시킨다.

//auth/util/bcrypt
import * as bcrypt from 'bcrypt';
import { Builder } from 'builder-pattern';

export const encryptPassword = async (createUserInput: CreateUserInput) => {
  const encryptedPassword = await bcrypt.hash(createUserInput.password, 10);
  return Builder<CreateUserInput>()
    .email(createUserInput.email)
    .password(encryptedPassword)
    .build();
};

위 코드에 hash 함수의 두번째 인자로 salt온다. 이 salt는 쉽게 설명하면 해쉬된 값의 경우의 수를 더욱 늘려주는 역할을 한다. 이것은 해커들이 해쉬가 해킹되는 방법으로 비밀번호를 해킹하려고 할 때 경우의 수를 늘려서 비밀번호 해킹을 어렵게 만든다. 너무 짧게 해놔도 문제지만 너무 길게 할 경우 해쉬하는데 시간이 너무 오래 걸리기 때문에 소금을 적당히 뿌려주자!
그리고 10개를 지정하면 해쉬를 할 때마다 랜덤으로 10개 salt를 만들어 준다.
그럼 어떻게 매번 새로운 salt가 생성되면 비밀번호 해시를 대조하는 것에 문제가 될 수 있다고 생각할 수 있다. 하지만 암호화된 password 중에서 일부분이 salt로 쓰고 있고, 이 데이터를 얻어온 다음에 사용자가 입력한 password와 같이 encrypt를 시켜서 비교한다. 그러기 때문에 매번 salt값이 달라져도 비밀번호 확인이 가능하다.

그럼 serive를 구현해보자

import { UserRepository } from './user.repository';
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { CreateUserInput } from './dto/create-user.input';
import { encryptPassword } from '../auth/util/bcrypt';

@Injectable()
export class UserService {
  constructor(private readonly userRepository: UserRepository) {}
  async signUp(createUserInput: CreateUserInput) {
    const isExist = await this.userRepository.findByEmail(
      createUserInput.email,
    );
    if (isExist) {
      throw new HttpException('등록된 유저입니다', HttpStatus.BAD_REQUEST);
    }
    const encryptedPasswordUser = await encryptPassword(createUserInput);
    return this.userRepository.create(encryptedPasswordUser);
  }
}

localhost:포트번호/graphql로 가서 확인을 해보면

잘되는걸 확인 할 수 있다. 물론 리턴값을 이렇게 한 이유는 잘된다는 스샷을 찍기 위해서다. 잘 만들어 졌다는 정도만 리턴할 수 있도록 하자

로그인 구현

이제 로그인을 구현해보자
resolve는 user에 있지만 로직은 auth에서 구현해보자

nest g service auth

user.module에서 providers에 AuthService를 꼭 추가해주자!
자 그럼 user.graphql에 가서 타입을 만들어 주자

input LoginInput {
  email: String!
  password: String!
}

type Mutation {
  createUser(createUserInput: CreateUserInput!): User!
  login(loginInput: LoginInput): AuthEntity!
}

만들었으면 재시작해서 typecript용 타입을 만들어서 revole를 만들자


import { Resolver, Query, Mutation, Args } from '@nestjs/graphql';
import { CreateUserInput, LoginInput } from 'src/graphql';
import { UserService } from './user.service';
import { AuthService } from 'src/auth/auth.service';

@Resolver('User')
export class UserResolver {
  constructor(
    private readonly userService: UserService,
    private readonly authService: AuthService,
  ) {}

  @Mutation('createUser')
  signUp(@Args('createUserInput') createUserInput: CreateUserInput) {
    return this.userService.signUp(createUserInput);
  }
  
  @Mutation('login')
  login(@Args('loginInput') loginUser: LoginInput) {
    return this.authService.login(loginUser);
  }

  // 유저를 만들고 user정보를 호출해볼 쿼리
  @Query('user')
  me() {
    return '';
  }
}

자 이제 auth service에 login을 구현해보자
로직을 생각하면
1. 이메일을 통해 user에 대한 정보를 찾는다
-> 없으면 에러!
2. 입력된 비밀번호와 db에 해싱된 값을 bcrypt라이브러리가 제공해주는 compare 함수를 통해 비교한다
-> 다르면 에러!
3. 같다면 유저 이메일과 db에 저장된 유저의 id를 통해 signature 부분을 만들어 클라이언트에게 반환한다.

먼저 UserRepository를 사용해야하니 user.module.ts에서 exports에 UserRepository와 auth.module.ts의 providers에
UserRepository를 등록시키자

bcrypt를 이용해서 비밀번호를 비교하는 함수를 따로 빼서 관리하자
유저가 입력된 값과 암호된 값을 파라미터로 받아서 비교해서 맞으면 true 아니면 false를 리턴하는 함수를 구현해보자

// auth/util/bcrypt.ts
import * as bcrypt from 'bcrypt';
import { CreateUserInput } from '../../user/dto/create-user.input';
import { Builder } from 'builder-pattern';

export const encryptPassword = async (createUserInput: CreateUserInput) => {
  const encryptedPassword = await bcrypt.hash(createUserInput.password, 10);
  return Builder<CreateUserInput>()
    .email(createUserInput.email)
    .password(encryptedPassword)
    .build();
};

export const validatePassword = async (
  enteredPassword: string,
  encryptedpassword: string,
) => await bcrypt.compare(enteredPassword, encryptedpassword);

jwt에 signature부분을 만들어야한다. nestjs에서는 @nestjs/jwt에서 제공하는 jwtService를 통해서 이를 쉽게 할 수 있다. 물론 auth.module.ts의 providers에 등록을 해줘야한다. 또 userRepository도 사용해되니까 이것도 providers에 등록해주자
그리고 로직을 구현하면

import { JwtService } from '@nestjs/jwt';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { LoginUser } from 'src/user/dto/login-user.input';
import { UserRepository } from 'src/user/user.repository';
import { validatePassword } from './util/bcrypt';

@Injectable()
export class AuthService {
  constructor(
    private readonly userRepository: UserRepository,
    private readonly jwtService: JwtService,
  ) {}

  async login(loginUser: LoginUser) {
    const user = await this.userRepository.findByEmail(loginUser.email);
    if (!user) {
      throw new UnauthorizedException('이메일과 비밀번호를 확인해주세요.');
    }
    const isPasswordValidation = await validatePassword(
      loginUser.password,
      user.password,
    );
    if (!isPasswordValidation) {
      throw new UnauthorizedException('이메일과 비밀번호를 확인해주세요.');
    }
    return {
      accessToken: this.jwtService.sign({
        userEmail: loginUser.email,
        sub: user.id,
      }),
    };
  }
}

잘되는지 graphql playground에 확인을 해보면

잘되는걸 확인할 수 있다.

마지막으로 그럼 클라이언트가 우리가 보낸 엑세스 토큰을 해더에 담아서 보내면 우리는 어떻게 그것을 검증할까?

여기에서 nestjs의 가장 큰 강점이 나온다.

(guard 공식문서 :https://docs.nestjs.com/guards)

공식문서에

Guards have a single responsibility. They determine whether a given request will be handled by the route handler or not, depending on certain conditions (like permissions, roles, ACLs, etc.) present at run-time.

This is often referred to as authorization. Authorization (and its cousin, authentication, with which it usually collaborates) has typically been handled by middleware in traditional Express applications. Middleware is a fine choice for authentication, since things like token validation and attaching properties to the request object are not strongly connected with a particular route context (and its metadata).

가드는 런타임에 현제 확실한 조건에 따라서 라우터에 의해서 요청이 핸들링될껀지 말껀지 결정!
과거에 미들웨어에서 했던 인증을 라우터 context와 강하게 연결되지도 않는다.

공식문서에서도 인증할 때 쓰라고 나와있으니 guard를 통해 인증을 해보자
그러고 보니 @nestjs/passport에서 AuthGuard 또한 만들어져 있다.
인증 전략만 잘 구현하면 된다.

인증 전략이 어떻게 AuthGuard랑 연결되는지 알아야 한다.
모르면 마법같이 AuthGuard만 쓰면 자동으로 내가 구현한 클레스를 찾아서 인증을 구현하는 거 같아 보인다.
strategy에서 이름을 정하고 AuthGuard는 그 이름을 통해서 인증전략을 찾는다.

그리고 guard가 strategy에서 인증을 성공하면 validate함수가 return하는 값을 가지게 된다.

인증전략에서 인증을 구현하기 위해서는
passport를 통해 우리가 어떤 secret key를 썼는지 어떻게 jwt을 가져올껀지 알려줘야 하는데 이건 해당전략에 PassportStrategy를 상속받고 정해진대로 알려주면 된다!
또 jwt의 signature 부분에

{
  userEmail: loginUser.email,
  sub: user.id,
}

이렇게 넣어줬다는 걸 기억해야한다. 그러면 PassportStrategy를 상속받았을 때 필수로 구현해야하는 validate 함수에 signature로 넣어줬던 것이 passport에 의해서 파라미터로 들어온다. validate는 return 값을 headers에 user의 value 값으로 넣어준다.
그러면 파라미터로 들어온 정보 중에서 sub에 있는 user id를 통해서 어떤 유저인지 판단하고 AuthGard가 header의 user에 전달할 수 있도록 user를 return 하자!

먼저 userRepository에 id를 통해서 user를 찾는 로직을 만들자

import { Injectable } from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service';
import { CreateUserInput } from './dto/create-user.input';

@Injectable()
export class UserRepository {
  constructor(private readonly prismaService: PrismaService) {}

  async findByEmail(email: string) {
    return await this.prismaService.user.findFirst({
      where: {
        email,
      },
    });
  }

  async findById(id: number) {
    return await this.prismaService.user.findFirst({
      where: {
        id,
      },
    });
  }

  async create(createUserInput: CreateUserInput) {
    return await this.prismaService.user.create({
      data: createUserInput,
    });
  }
}

다음 JwtStrategy을 구현하자

// auth/jwt/jwt.stragy.ts
import { UserRepository } from 'src/user/user.repository';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';


@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
  constructor(private readonly userRepository: UserRepository) {
    super({
      jwtFromRequest: 
      // header에 Authorization에서 bearer token을 가져오고
      ExtractJwt.fromAuthHeaderAsBearerToken(),
      // serect key는 이걸 썼다
      secretOrKey: process.env.JWT_SECRET_KEY,
      ignoreExpiration: false,
    });
  }

  async validate(payload: { email: string; sub: number }) {
    const user = await this.userRepository.findById(payload.sub);
    if (!user) {
      throw new UnauthorizedException('접근오류');
    }
    return user;
  }
}

우리는 graphql을 쓰기 때문에 guard를 튜닝해줘야 한다.
공식문서에 나온데로 AuthGuard를 튜닝해주자

//auth/jwt/jwt.guard.ts
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  getRequest(context: ExecutionContext) {
    return GqlExecutionContext.create(context).getContext().req;
  }
}

또 공식문서에 따르면
To get the current authenticated user in your graphql resolver, you can define a @CurrentUser() decorator:

resolver에서 인증받은 user를 알고 싶으면 CurrentUser decorator를 정의하라고 나와있다.

데코레이터 자세한 내용은 여기에
(js decorator: https://medium.com/google-developers/exploring-es7-decorators-76ecb65fb841)
(nestjs 공식문서 : https://docs.nestjs.com/custom-decorators#param-decorators)

이중 Param decorators를 이용해 user정보를 resolver로 가져오자

// 공통적으로 쓰일 예정이기 때문에
// src/common/decorators/user.decorator.ts 경로에 만들었다
import { createParamDecorator, ExecutionContext } from '@nestjs/common';

export const CurrentUser = createParamDecorator(
  (data: unknown, context: ExecutionContext) => 
  			GqlExecutionContext.create(context).getContext().req.user,
);

자 이제 잘 작동하는지 확인해 보자
user.resolver.ts에서
me라는 함수를 만들어서 인증받은 user를 return 해보자

import { AuthService } from 'src/auth/auth.service';
import { Resolver, Query, Mutation, Args } from '@nestjs/graphql';
import { UserService } from './user.service';
import { UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from 'src/auth/jwt/jwt.guard';
import { CurrentUser } from 'src/common/decorators/user.decorator';
import { CreateUserInput, LoginInput, User } from 'src/graphql';

@Resolver('User')
export class UserResolver {
  constructor(
    private readonly userService: UserService,
    private readonly authService: AuthService,
  ) {}

  @Mutation('createUser')
  signUp(@Args('createUserInput') createUserInput: CreateUserInput) {
    return this.userService.signUp(createUserInput);
  }

  @Mutation('login')
  login(@Args('loginInput') loginUser: LoginInput) {
    return this.authService.login(loginUser);
  }

  @Query('user')
  //guard
  @UseGuards(JwtAuthGuard)
  //decorator
  me(@CurrentUser() user: User) {
    return user;
  }
}

잘되는 걸 확인해볼 수 있다.

로그인 로직은 완성 되었다.

다음시간에는 에러 핸들링과 테스트 코드를 써보자!!!

profile
오히려 좋아!

0개의 댓글