비밀번호 암호화(Encrypt/Hash)

기존에는 plain text로 password를 DB에 저장하였다. 이런 경우에 해킹을 당하게 되면 그러한 정보들이 온전히 노출된다. 이러한 경우를 대비하여 비밀번호를 암호화하여 DB에 저장해보자.

비밀번호를 암호화하는 방식은 크게 두 가지가 있다.

양방향 암호화(Encrypt)와 단방향 암호화(Hash)

양방향 암호화(Encrypt)

양방향 암호화를 지향하는 사이트는 비밀번호를 찾을 때 온전한 비밀번호를 볼 수 있다. 그러나 이런 경우엔 보안이 조금 떨어진다.

단방향 암호화(Hash)

172936을 10으로 나눈 나머지로 암호화한다고 가정하자. 그런데 이렇게 생긴 암호는 복호화가 불가능하다. 왜냐하면 10으로 나눈 나머지가 7 9 6이 되는 수는 수없이 많기 때문이다. 그렇기에 이런 식으로 단방향 암호화를 하게 되면 보안이 더 철저해진다.

그렇다고 Hash도 완벽한 보안이 보장되는 것은 아니다.

수 없이 많은 경우의 수를 rainbowTable을 이용해서 비밀번호를 뚫어내는 경우도 존재한다.

rainbowTable(모든 경우의 수를 담은 테이블)

그럼 우리는 보안이 더 좋은 Hash를 사용해보자(bcrypt)

암호화는 bcrypt라는 라이브러리를 설치하면 간단하게 진행할 수 있다.

npm install bcrypt//npm 사용시
yarn add bcrypt//yarn 사용시

우리는 공식 문서에 나와있는 2번 방법을 사용할 것이다.

bcrypt.hash(myPlaintextPassword, saltRounds, function(err, hash) {
    // Store hash in your password DB.
})

함수의 첫번째 인자는 암호화 되지 않은 비밀번호를 넣고, 두 번째 인자는 알고리즘을 얼마나 더 복잡하게 할 지 횟수를 정해준다. Hash에 salt를 적용하여 알고리즘을 더욱 복잡하게 해보자.

그럼 적용하기 위해 기존에 만들어둔 User테이블을 활용해보자.

//user.resolver.ts
import { User } from 'src/apis/users/entities/user.entity';
import { Resolver, Mutation, Args } from '@nestjs/graphql';
import {UserService} from './user.service'
import * as bcrypt from 'bcrypt'
@Resolver()
export class UserResolver{
  constructor(
    private readonly userService: UserService,
  ){}
  @Mutation(()=> User)
  async createUser(
    @Args('email') email:string,
    @Args('password') password:string,
    @Args('name') name:string,
    @Args('age') age:number,
  ){
    const hashedPassword = await bcrypt.hash(password, 10)
    console.log(hashedPassword)
    return this.userService.create({email, hashedPassword, name, age})
  }
}
//yarn add --dev @types/bcrypt bcrypt타입스크립트 지원!
//user.service.ts
import { InjectRepository } from '@nestjs/typeorm';
import{ConflictException, Injectable}from '@nestjs/common'
import {User} from './entities/user.entity'
import { Repository } from 'typeorm';
@Injectable()
export class UserService{
  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
  ){}
  async create({email, hashedPassword: password, name, age}) {
    const user = await this.userRepository.findOne({email})
    if(user) throw new ConflictException("이미 등록된 이메일 입니다.")
    return await this.userRepository.save({email, password, name, age})
  }
}

playground에서 실행해보자.

결과가 잘 나온다.

Salt

bcrypt.hash(password, 10)를 사용할 때 salt 횟수의 따른 암호화 시간이다. 컴퓨터 성능에 맞춰 적용하자.


로그인 인증(JWT/Passport)

로그인을 하는 api를 만들어 볼 것이다. 이때 JWT를 accessToken용도로 활용할 예정이다.

근데 왜 토큰 기반 인증 시스템을 사용하는가? 참고

기존에 사용하던 서버 기반 인증 시스템의 단점(Stateful server)

  • 기존 서버 기반 인증 시스템은 서버측에서 유저들의 정보를 세션에 담아 기록하였는데 로그인 하는 유저가 많아지면 서버가 과부하된다.
  • 세션 사용시 시스템 분산과 서버 확장이 어렵다.
  • 이때 사용하는 쿠키는 여러 도메인에서 관리하는 것이 번거롭다.

토큰 기반 인증 시스템의 장점(Stateless server)

  • 쉽게 말하면 서버 기반 인증시스템의 모든 단점들을 보완했다.
  • 클라이언트가 서버에 요청을 보낼 때 쿠키를 사용하지 않아도 되므로 보안이 좋다.
  • 서버를 확장시키기도 하며 로그인 정보가 사용되는 분야를 확장할 수 있다. google, facebook, kakaotalk 계정을 통한 소셜 로그인도 stateless를 활용한 것이다.

토큰 기반 인증시스템의 단점

  • 상태정보를 매 요청시마다 전달해야하므로 네트워크 리소스 요구량이 많다. 그렇기에 다수의 클라이언트 관리가 어렵고, 대량 리소스로 인한 문제점이 발생할 수 있다.

우리는 게임처럼 실시간 연동이 많이 필요한 서버를 구현하는 것이 아니므로 stateless방식(토큰 기반 인증 시스템)을 사용해볼 것이다.

구현할 코드 작동원리

우리가 구현할 시스템의 작동원리는 아래와 같다.

  1. 유저가 아이디와 비밀번호로 로그인
  2. 서버측에서 해당 계정 정보를 검증
  3. 계정 정보가 정확하다면, 서버측에서 유저에게 signed 토큰을 발급
  4. 클라이언트 측에서 전달받은 토큰을 저장해두고, 서버에 요청을 할 때 마다 해당 토큰을 함께 서버에 전달
  5. 서버는 토큰을 검증하고, 요청에 응답

JWT(Json Web Token)

이때 우리가 사용하는 토큰은 json으로 이루어진 토큰인 JWT이다. JWT는 웹표준으로서 대부분의 주류 프로그래밍 언어에서 지원된다. 또한 모든 정보를 자체적으로 가지고 있어 자가 수용적이고, 두 개체 사이에서 쉽게 전달이 가능하다. 공식문서

JWT구조

JWT는 점(.)으로 구분된 세 부분으로 구성되어있다.

  • header: 토큰의 타입과 해싱 알고리즘을 담고 있다.
{
	"alg":"HS256",
    "typ":"JWT"
}
  • payload

    payload는 엔티티(일반적으로 사용자) 및 추가 데이터에 대한 설명이 담긴다. 클레임에는 registered, public, private의 세분류로 나뉘어져 있다.
    - registered claim에는 iss(발급자), exp(만료시간), sub(제목), sup(대상) 및 기타 등등의 정보가 담긴다.
    - public claim에는 JWT를 마음대로 정의할 수 있는데 충돌방지를 위해 네임스페이스를 포함하는 URL이나 IANA JSON Web Token Registry에서 정의하는 것이 좋다.
    - private claim에는 사용에 동의하고 등록된 클레임, 공개클레임이 아닌 비공개로 사용자들간의 정보를 공유하기 위해 생성된 맞춤 클레임이다.

//페이로드 예시
{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

payload된 토큰의 경우 변조는 불가능 하지만 누구나 읽을 수 있는 정보가 된다. 암호화 되지 않은 비밀번호나 중요한 정보를 담지 않는 것이 좋다.

  • signature
    JWT의 마지막 부분은 서명으로, Header의 인코딩값과 Payload의 인코딩값을 합친 후 주어진 비밀키로 해싱하여 생성한다.

JWT라이브러리 다운

npm install jsonwebtoken
yarn add jsonwebtoken

코드 작성

src/apis 경로에 로그인을 위한 auth폴더와 파일들을 만들어보자.

//폴더 구조
auth
├── auth.module.ts
├── auth.resolver.ts
└── auth.service.ts
//auth.model.ts
import { JwtModule } from '@nestjs/jwt';
import { AuthService } from './auth.service';
import { AuthResolver } from './auth.resolver';
import { Module } from '@nestjs/common';
import { UserService } from '../users/user.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '../users/entities/user.entity';
@Module({
  imports:[
    JwtModule.register({}),
    TypeOrmModule.forFeature([User]),
  ],
  providers:[
    AuthResolver, 
    AuthService,
    UserService,
  ]
})
export class AuthModule {}
//auth.resolver.ts
import { AuthService } from './auth.service';
import { UnprocessableEntityException } from '@nestjs/common';
import {Args, Mutation, Resolver } from '@nestjs/graphql';
import { UserService } from '../users/user.service';
import * as bcrypt from 'bcrypt'

@Resolver()
export class AuthResolver {
  constructor(
    private readonly userService: UserService,
    private readonly authService: AuthService
  ){}
  @Mutation(()=> String)
  async login(
    @Args('email') email: string,
    @Args('password') password: string,
    ){
    // 1. 로그인(이메일과 비밀번호가 일치하는 유저 찾기)
    const user = await this.userService.findOne({ email })

    // 2. 일치하는 유저가 없으면? 에러 던지기!
    if(!user) throw new UnprocessableEntityException('이메일이 존재하지 않습니다.')
    
    // 3. 일차하는 유저가 있지만, 암호가 틀렸다면 에러던지기!!
    const isAuth = await bcrypt.compare(password, user.password)
    if(!isAuth) throw new UnprocessableEntityException('암호가 틀렸습니다.')
    
    // 4. 일치하는 유저가 있으면? accessToken(JWT)을 만들어서 프론트엔드에 주기
    return this.authService.getAccessToken({user})
  }
}
//auth.service.ts
import { Injectable } from "@nestjs/common";
import { JwtService } from '@nestjs/jwt'
@Injectable()
export class AuthService {
  constructor(
    private readonly jwtService:JwtService
  ){}
   getAccessToken({user}){
     return this.jwtService.sign(
       { email: user.email, sub: user.id},
       { secret: "myAccessKye", expiresIn: '1h'}
     )

   }
   //토큰의 유효기간을 만들어야 보안이 좋음 
}

위와같이 코드를 작성하고, 기존에 만들어 둔 createUser를 이용해 유저를 생성하자. 그 후 loginAPI를 사용해보자.

이때 auth.service.ts의 jwtService.sign을 유심히 보자.

jwtService에서 sign 메서드를 사용하면 토큰을 발급받을 수 있다.

jsondata: 입력받은 데이터를 담은 payload를 의미
secretKey: 해싱 알고리즘(default: HS256알고리즘)
option: 토큰의 유효기간 및 발행자 지정 가능

createUser로 유저를 먼저 만들고

login을 시도 하면

이런식으로 결과를 return받을 수 있다.


인가(Authorization)

로그인한 유저의 accessToken을 복호화하여 실제로 사용하는 유저가 맞는지 확인하는 과정으로 JWT Strategy를 통해 구현할 수 있다.

이때 사용자가 맞는 지 확인(login)하는 과정에서 accessToken(JWT)를 HTTP-Header에 실어 보낸다. Bearer는 아무 의미 없는 관례의 불과하다.

//HTTP-Header
{
	"authorization":"Bearer accessToken"
}

이렇게 암호화된 accessToken을 passport라이브러리를 통해 복호화 할 수 있다.

Passport module

nodejs의 인증 라이브러리로서 자격증명(JWT,사용자이름/암호)을 확인하여 사용자를 인증하고, 인증상태를 관리하고, 인증된 사용자에 대한 정보를 Route Handler에서 사용할 수 있도록 Request 객체에 첨부해준다.

npm install @nestjs/passport
yarn add @nestjs/passport

그럼 로그인(토큰이 존재하지 않으면)을 하지 않았으면 에러를 출력하는 코드를 작성할 예정인데 유저를 조회하는 API를 만들어 로그인한 경우에만 사용할 수 있도록 할 것이다. 이를 위해선 GUARD가 필요하다. 구현해보자

JWT를 우선 Header로 부터 받아오고, 암호화된 토큰을 복호화하여 전송할 jwt-access.strategy.ts를 만들어보자.

import { Injectable } from '@nestjs/common';
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";

@Injectable()
export class JwtAccessStrategy extends PassportStrategy(Strategy, 'access'){
  //1. 검증하는 부분(secretOrKey가 복호회되어 비교!)
  constructor(){
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), //http-header부분에 담긴 값을 가져오면 됨! jwt
      secretOrKey: 'myAccessKey' // password
    })
  }

  //중간에 검증이 실패하면 프론트로 에러 출력
  // 2. 검증 완료되면 실행
  validate(payload){
    console.log(payload)
    return {
      ...payload
    }
    //return하는 값들은 PassportStrategy로 인해 context안에 request안에 user객체로 들어간다.
  }
}
  1. JwtAccessStrategyPassportStrtegy을 상속하고 super를 사용하여 부모클래스의 생성자함수를 호출하여 JWT를 전송

  2. jwtFromRequest를 통해 Header의 Token으로부터 JWT를 추출

  3. secretOrKey는 이전에 토큰을 발행했던 secretKey와 동일하게 적어주어야 토큰의 payload의 정보 추출

    4. validatepayload를 열어서 사용자의 정보를 반환

우리가 만약 restapi를 사용했다면 여기서 코드 작성을 마무리해도 좋다. 그러나 graphql을 이용하한 단계를 더 거쳐주어야한다. 왜냐하면 graphql이 restapi보다 처리 과정에서 위에 있는데 context를 graphql용으로 변경을 해주어야 guard를 통과할 수 있으므로 처리해주자.

//common/auth/gql-auth-guard
import { ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { AuthGuard } from '@nestjs/passport';

//AuthGuard(restapi용)을 graphql용으로 변환!
export class GqlAuthAccessGuard extends AuthGuard('access'){
  getRequest(context: ExecutionContext){ //contextheader를 따라 들어오는 데이터들
   const ctx = GqlExecutionContext.create(context)
   return ctx.getContext().req//graphql용 context
  }
}
}
//user.resolver.ts
import { User } from 'src/apis/users/entities/user.entity';
import { Resolver, Mutation, Args } from '@nestjs/graphql';
import {UserService} from './user.service'
import * as bcrypt from 'bcrypt'
@Resolver()
export class UserResolver{
  constructor(
    private readonly userService: UserService,
  ){}
  @Mutation(()=> User)
  async createUser(
    @Args('email') email:string,
    @Args('password') password:string,
    @Args('name') name:string,
    @Args('age') age:number,
  ){
    const hashedPassword = await bcrypt.hash(password, 10)
    console.log(hashedPassword)
    return this.userService.create({email, hashedPassword, name, age})
  }
  
  @UseGuards(GqlAuthAccessGuard )//passport라이브러리를 통해 guard형성!
  @Query(()=> User)
  fetchUser(){
    console.log(currentUser)
    console.log('fetchUser실행 완료')
    return await this.userService.findOneEmail({email:currentUser.email})
   }
}

마치며

로그인을 위한 api를 만들면서 jwt를 사용해보았는데 다른 부분에도 응용할 수 있을 것 같아 공부할 예정이다.

profile
백엔드 주니어 개발자

0개의 댓글