2019. 07. 21 수정

 

이번 글에서는 sequelize를 이용하여 회원 가입 기능을 구현해보고, crypto 모듈을 이용하여 비밀번호를 암호화하는 방법에 대해서 알아보도록 하겠습니다.

  • 개발환경
    • express-generator 4.16.1
    • MySQL 8.0.16
    • sequelize 5.10.1

예제를 진행하시려면, 셋팅은 여기를 참고하여 "1) 프로젝트 생성 및 npm 설치 ~ 3) sync 작성"를 진행해주시면 됩니다.

 

 

 

1. 회원 모델 정의하기

회원 가입을 하기 위해, 먼저 회원 모델을 정의해야 합니다.

sequelize-cli로 모델을 생성하고 이 때 생성된 migration 파일도 수정하겠습니다.

# sequelize model:create --name user --attributes "name:string, email:string, password:string, salt:string"

 

생성된 user 모델에서 제약조건들을 추가합니다.

/models/user.js

'use strict';
module.exports = (sequelize, DataTypes) => {
  var user = sequelize.define('user', {
    name: {
      type: DataTypes.STRING,
      allowNull: false
    },
    email: {
      type: DataTypes.STRING,
      allowNull: false,
      validate: {
        isEmail: true
      },
      primaryKey: true
    },
    password: {
      type: DataTypes.STRING,
      allowNull: false
    },
    salt:{
      type: DataTypes.STRING
    }
  });

  return user;
};
  • email 컬럼을 고유키로 두고, email 양식이 맞는지 확인하는 validate도 추가하였습니다.
    • 즉, 데이터가 " foo@example.com "과 같은 이메일 형식이 아니면, 유효성 검사에 실패하여 오류 메시지를 출력하고 DB에 저장하지 않습니다.
    • Sequelize validate 옵션에 대한 설명은 여기를 참고해주세요.
  • salt 컬럼은 암호화에 필요한 것인데, 뒤에서 살펴보도록 하겠습니다.

 

 

이어서 migration 파일을 수정합니다.

/migration/create-user.js

'use strict';
module.exports = {
    up: (queryInterface, Sequelize) => {
        return queryInterface.createTable('users', {
            name: {
                type: Sequelize.STRING,
                allowNull: false
            },
            email: {
                type: Sequelize.STRING,
                allowNull: false,
                validate: {
                    isEmail: true
                },
                primaryKey: true
            },
            password: {
                type: Sequelize.STRING,
                allowNull: false
            },
            salt:{
                type: Sequelize.STRING
            },
            createdAt: {
                allowNull: false,
                type: Sequelize.DATE
            },
            updatedAt: {
                allowNull: false,
                type: Sequelize.DATE
            }
        });
    },
    down: (queryInterface, Sequelize) => {
        return queryInterface.dropTable('users');
    }
};

migrations 파일까지 수정했으니, DB에 반영하기 위해 migrate 명령어를 실행하고, 서버를 실행합니다.

# sequelize db:migrate

# npm start

 

 

 

 

2. 회원 가입 form 만들기

다음으로 데이터를 받기 위해 회원 가입 뷰 페이지를 만들겠습니다.

 

/views/user/signup.ejs

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Document</title>
</head>
<body>
<h3>회원 가입</h3>
<form action="/user/sign_up" method="post">
    <table>
        <tr>
            <td>이름 : </td>
            <td><input type="text" name="userName"></td>
        </tr>
        <tr>
            <td>이메일 : </td>
            <td><input type="text" name="userEmail"></td>
        </tr>
        <tr>
            <td>비번 : </td>
            <td><input type="password" name="password"></td>
        </tr>
        <tr>
            <td><input type="submit" value="회원가입"></td>
        </tr>
    </table>
</form>
</body>
</html>

 

 

 

 

3. 라우터 등록하기

express-generator로 프로젝트를 생성하면 /routes 폴더에 user.js 파일이 있습니다.

/routes/user.js 파일에 회원과 관련된 라우터 미들웨어를 작성하면, 회원과 관련된 내용만 한 파일에 있으므로 관리하기가 쉬워집니다.

 

아래의 두 라우터 함수를 작성합니다.

  • 회원 가입 뷰 페이지를 응답하는 GET 방식
  • "회원가입"버튼을 클릭했을 때 처리하는 POST 방식

/routes/user.js

const express = require('express');
const router = express.Router();
const models = require("../models");


router.get('/sign_up', function(req, res, next) {
  res.render("user/signup");
});


router.post("/sign_up", function(req,res,next){
  let body = req.body;

  models.user.create({
    name: body.userName,
    email: body.userEmail,
    password: body.password
  })
  .then( result => {
    res.redirect("/users/sign_up");
  })
  .catch( err => {
    console.log(err)
  })
})

module.exports = router;

 

이제 회원 가입을 위한 모든 준비가 끝났습니다.

게시판을 만드는 것과 크게 다른 것이 없습니다.

브라우저에서 http://localhost:3000/users/sign_up 으로 요청하여, 회원 가입이 잘되는지 테스트를 해봅니다.

 

 

 

 

4. crypto 내장 모듈 소개

그런데 MySQL의 users 테이블을 확인해보면, 아래와 같이 password가 그대로 노출이 되었습니다.

 

회원의 개인정보인 비밀번호는 관리자를 포함하여 누구든 볼 수 있으면 안됩니다.

따라서 회원가입시, DB에 저장하는 순간 비밀번호를 암호화하여 누구도 확인할 수 없도록 해야 합니다.

 

Node.js에서는 비밀번호를 암호화 할 수 있도록 도와주는 내장 모듈인 crypto를 제공합니다. ( 참고문서 )

 

crypto는 해시를 생성하고 각종 암호화를 지원하는 유틸리티를 제공합니다.

해시는 복호화( 본래의 값을 복원할 수 없는 )가 불가능한 단방향 암호화 방식입니다.

 

그런데 로그인을 할 때, 비밀번호를 확인하려면 "DB에 저장된 password를 복호화해서 사용자가 입력한 평문의 값과 비교해야 하지 않을까?"하고 생각할 수 있습니다. 

그러나 그렇지 않습니다.

로그인 시, 사용자가 입력한 평문의 값을 똑같이 암호화 해서 DB에 저장된 암호화된 데이터와 비교해서 일치하면, 인증이 되는 방식으로 처리할 수 있기 때문에 암호화에 단방향 방식을 사용할 수 있습니다.

 

그런데 해시만 사용하면 해커가 해시 정보를 갖고 있는 레인보우 테이블을 이용해 원래의 값을 알아낼 수도 있습니다.

( 해시는 특정 포맷을 가진 문자열로 변환해주는 것이지, 엄밀히 말해서 암호화가 아닙니다. )

때문에 salt( 소금 )을 추가로 적용하여 보안성을 더 높여야 암호화가 잘 되었다고 할 수 있습니다.

 

salt는 랜덤 값으로써, salt 값을 추가하여 해시를 수 만번 반복하여 사용자의 비밀번호를 변형시킵니다.

따라서 salt를 추가하는 것만으로도 보안을 강화할 수 있습니다.

 

 

 

 

5. crpyto를 이용한 비밀번호 암호화

이제 회원가입 시, 라우터 함수에서 DB에 저장할 때 crypt 모듈을 사용하여 비밀번호를 암호화 할 수 있도록 수정하겠습니다.

crypto 모듈은 내장모듈이기 때문에 npm 설치는 필요없고, 상단에 require만 해주면 됩니다.

/routes/users.js

const crypto = require('crypto');

...

router.post("/sign_up", async function(req,res,next){
    let body = req.body;

    let inputPassword = body.password;
    let salt = Math.round((new Date().valueOf() * Math.random())) + "";
    let hashPassword = crypto.createHash("sha512").update(inputPassword + salt).digest("hex");

    let result = models.user.create({
        name: body.userName,
        email: body.userEmail,
        password: hashPassword,
        salt: salt
    })

    res.redirect("/user/sign_up");
})

salt 값은 현재 시간에 랜덤 값을 곱해서 생성된 문자열로 생성했습니다.

그리고 이 값은 users 테이블의 salt 컬럼에 저장됩니다.

 

crypt 모듈을 사용하여 해시된 비밀번호 hashPassword가 생성되는 과정은 다음과 같습니다.

  • crypto 모듈 객체의 createhash() 메서드의 인자로 해시 알고리즘을 넘겨줍니다.
    • 사용할 수 있는 알고리즘으로는 sha256, sha512 등이 있습니다. ( 참고 )
    • 다른 sha 방식에 비해, sha512가 더 길지만 안전하므로 sha512 사용을 추천한다고 합니다.
  • update() 메서드의 인자로는 salt를 적용할 것이므로 평문 비밀번호에 salt를 더한 값을 넘겨줍니다. ( 참고 )
  • digest() 메서드의 인자로는 인코딩 방식을 넘겨줍니다. ( 참고 )
    • base64, hex 등의 방식이 있습니다.

 

다시 회원가입을 한 후에, 비밀번호가 어떤 모습으로 바뀌었을지 한 번 확인해보세요.

 

 

 

같은 알고리즘 및 인코딩 방식을 사용하면, 같은 입력 값에 대해서 같은 결과를 얻을 수 있습니다.

그렇기 때문에 로그인을 할 때 DB에 저장된 비밀번호를 복호화 할 필요가 없는 것입니다.

단, salt는 랜덤 값이기 때문에 암호화를 했을 때 사용했던 salt를 알고 있어야 합니다.

그렇기 때문에 users 테이블의 필드로 salt를 추가한 것입니다.

( 이 부분에 대해서는 다음 글에서 로그인에서 다시 언급하겠습니다. )

 

 

 

 

6. pbkd2 암호화 방식

hash이외에도 공식 문서에는 여러 암호화 알고리즘을 소개하고 있습니다.

 

위의 방법 중 pbkd2를 이용한 방법만 간단하게 살펴보도록 하고 마무리 짓도록 하겠습니다. ( 참고 )

 

/routes/users.js

router.post("/sign_up", function(req,res,next){
    let body = req.body;

    crypto.randomBytes(64, function(err, buf) {
        crypto.pbkdf2(body.password, buf.toString('base64'), 100000, 64, 'sha512', async function(err, key){
            result = await models.user.create({
                name: body.userName,
                email: body.userEmail,
                password: key,
                salt: buf
            })

            res.redirect("/user/sign_up");
        });
    });
})
  • crypto.randomBytes()를 호출함으로써 64byte의 salt를 생성합니다.
  • crypto.pbkdf2() 메서드
    • 첫 번째 인자는 사용자의 비밀번호,
    • 두 번째 인자는 salt,
    • 세 번째 인자는 반복 횟수,
      • 반복 횟수는 많으면 많을 수록 보안이 좋아집니다. ( 레인보우 테이블을 만들기 어려워집니다. )
    • 네 번째 인자는 비밀번호 길이,
    • 다섯 번째 인자는 인코딩 방식을 의미합니다.

또한 이 방식은 비동기로 동작합니다.

 

 

 

 

이상으로 회원가입 할 때 비밀번호를 암호화 하는 방법에 대해 알아보았습니다.

 

[ 참고 ]

https://www.zerocho.com/category/NodeJS/post/593a487c2ed1da0018cff95d