새로운 강의는 이제 https://memi.dev 에서 진행합니다.
memi가 Vue & Firebase로 직접 만든 새로운 사이트를 소개합니다.

바로가기


모던웹(NEMV) 혼자 제작 하기 3기 - 59 비밀번호 암호화하기

7 분 소요

비밀번호를 평문이 아닌 암호화된 문자로 저장해보겠습니다.

개요

지금까지 회원가입시 비밀번호를 그대로 디비에 저장했습니다.

20세기에도 있을까 말까한 일인데 21세기에 절대 그래서는 안되죠.(운영시 법적으로도 문제가 있는 것은 당연합니다.)

그래서 노드의 크립토(crypto) 모듈을 통해 암호화 시키는 방법에 대해 알아보겠습니다.

크립토(crypto)란?

노드에서 사용하는 암호화 모듈입니다.

참고: https://www.npmjs.com/package/crypto

해당 모듈을 npm에서 뒤져보면 더이상 사용되지 않는다고 되어 있습니다.(deprecated)

이제부터 노드에서 포함(설치 필요 없음)되어 공식 제공하는 암호화 모듈이기 때문이죠.

공식 노드 제공: https://nodejs.org/dist/latest-v11.x/docs/api/crypto.html

크립토를 이용해 다양한 것들을 할 수 있지만 비밀번호 암호화 하는 방법만 알아보겠습니다.

암호화 방법

암호화 시키는 방법은 크게 두가지입니다.

  • 양방향: 암호화 시킨 후 풀 수 있음
  • 단방향: 암호화 시킨 후 풀 수 없음

현재까지는 왠지 양방향을 해야 할 것 같은 느낌이죠?

풀어야 패스워드가 일치하는지 확인할 수 있다고 생각이 되니까요~

양방향 암호화

프로그램을 시작한지 얼마 안되었을 때 이런 아이디어를 낸 적이 있습니다.

const char* ptable = ['m', 'e', 'm', 'i', ...];

String s = "1234"; // 암호화 전
String h = ""; // 암호화 할 문자

// 암호화 하기
for (int i = 0; i < s.Length(); i++ ) {
    h += (s[i] + ptable[i % sizeof(ptable)]).toHex();
}

// 풀기
s = "";
for (int i = 0; i < h.Length() / 2; i++) {
    s += (h.subString(i * 2, 2) - ptable[i % sizeof(ptable)]);
}

결국 “1234” 는 바이트로 0x31, 0x32, 0x33, 0x34 이기 때문에

암호화는 각 글자(바이트)들에 미리 만들어둔 테이블 값들을 더해서 헥스값으로 만든 것이고

암호화 해제는 반대로 빼면 원래값이 돌아옵니다.

우습게도 이런 저질스러운 방법으로 실무에서 사용했었고. 뚫린 적은 없었죠..(뚫려는 사람도 없었고..)

풀어서 원래 값을 알 수 있는.. 이것이 양방향입니다.

양방향의 근본적인 문제는 관리자는 알 수 있다는 것이죠.

const Encode (pwd) => {} // 암호화
const Decode (epwd) => {} // 풀기

// 회원가입시
User.create({ id: req.body.id, pwd: Encode(req.body.pwd) })

// 로그인시
User.findOne({ id: '어쩌고' })
    .then(r => {
      if (req.body.pwd === Decode(r.pwd)) // 이런식으로 비교
      console.log(Decode(r.pwd)) // 본래 비밀번호를 관리자는 언제나 알 수 있음..
    })

위와 같은 방식으로 사용하는 것이 크립토의 싸이퍼(Cipher) 와 디싸이퍼(Decipher) 입니다.

그런데 꼭 풀어서 비교해야 할까요?

단방향 암호화

단방향 암호화는 일반적으로 해시값을 쓰는 경우를 여럿 봤는데요..

해시값은 단순 산술적인것에 불과하고 pbkdf2 같은 것을 쓰셔야합니다.(노드6버전에 나옴)

그것과 비슷한 기능을하면서 보안이 강화된 scrypt(노드 10.5에서 나옴)을 사용해 보도록 하겠습니다.

pbkdf2, scrypt 둘 다 정확한 원리는 모르지만 단방향으로 알수 없는 문자를 만드는 녀석들입니다.

노드의 모듈들은 대부분 func 와 funcSync로 구성되어 있는데요

뒤에 Sync가 없는 함수는 콜백형, 있는 함수는 블러킹형입니다.

공식홈 예제중 crypto.scryptSync를 테스트해봅시다.

be/app.js

const crypto = require('crypto');
// Using the factory defaults.
const key1 = crypto.scryptSync('secret', 'salt', 64);
console.log(key1.toString('hex'));  // '3745e48...08d59ae'
// Using a custom N parameter. Must be a power of two.
const key2 = crypto.scryptSync('secret', 'salt', 64, { N: 1024 });
console.log(key2.toString('hex'));  // '3745e48...aa39b34'

노드 공식홈에 있는 예제를 가져와서 백엔드 아무데나 넣고 실행해봤습니다.

값에 toString(‘hex’)을 써주는 이유는 값 자체가 node의 버퍼로 구성되어 있습니다.

나중에 파일, 이미 처리등에서 버퍼 시스템이 필요할 수 있으니 한번 둘러보시는 것도 좋습니다.(프론트에는 없지만 너무 훌륭한 버퍼 시스템입니다.) 노드 버퍼 참고: https://nodejs.org/dist/latest-v11.x/docs/api/buffer.html

예제에 주석 ‘3745…’ 와는 다른 문자가 나오네요..

05ffaebcca41770af425d4ba9b4e7bcdff532237dca931c192a36d94db7307d4c2df95e6065
14b4113ccb3ad3c19f7ca648e373a112a6b8290f3a69818aa9b7e
eba9bb7eb94c6ebd8d2c4636469b51c6cea1aadafc321bada4716add1a4e7f29d233df29538
86310c02a9bd60c6975e0ecc22d397f154550ca43189b3773673f

단방향 암호화는 이렇게 크립토함수만 이용하면 끝입니다.

원래 문자가 뭔지 알 수 없는 (꼬아 놓은 값)이 만들어 지는 것이죠

안에 들어가 있는 인자가 이렇게 봐서는 모르겠죠?

공식홈에 따르면 4가지 입니다.

  • password: 입력 문자(비밀번호)
  • salt: 풀기 어렵게 만들기
  • keyLen: 바이트수
  • Option: 암호화 방법등 여러가지

소금(salt)은 대체 뭘 의미하는 지 모르겠죠? 아래서 다시 확인해보겠습니다.

그럼 비교는 어떻게 하냐고요?

비교

const crypto = require('crypto');
const registPwd = '1234' // 회원가입시 1234로 등록
const dbPwd = crypto.scryptSync(registPwd, 'salt', 64, { N: 1024 }).toString('hex'); // 암호화된 문자를 디비에 저장
console.log(dbPwd)
// 9415e...

const inputPwd = '12345' // 로그인시 12345로 들어옴
const userPwd = crypto.scryptSync(inputPwd, 'salt', 64, { N: 1024 }).toString('hex'); // 암호화된 문자로 변경

if (userPwd === dbPwd) console.log('비번 같음')
else console.log('비번 틀림')

디비에 저장한 똥과 비교 할 똥을 비교하면 되는 것이죠..

그 똥들의 원래 문자를 알 수는 없지만 비교는 되는 것이죠..

문제점

꼬아 놓은 값들을 정말 알 수 없을까요?

시간이 아주 오래 걸리지만 풀 수 있습니다.

그 방법은 원래값과 똥들을 우선 저장해 놓고 테이블로 만들어 놓는 거죠.

그런 테이블을 레인보우 테이블이라고 한답니다.

무지게 테이블 예

1234 e0560..
abcd 0c5fce..
qwer 15e05..
zxcv ce4fa2..
~ ~

저런게 몽고디비에 저장되어 있다면~

Rainbow.find({ 똥: ‘0c5fce..’}) 을 돌리면 ‘abcd’가 나오게 되는 것이죠..

물론 무지게 테이블에 ‘Hell5forXX.#com’ 같은 것은 없겠죠? 그래서 패스워드 어렵게 하라고 그리도 강조하는 것입니다.

소금 뿌리기

그래서 소금을 첨가해야합니다.

const crypto = require('crypto');
const dbPwd = crypto.scryptSync('1234', '소금1', 64, { N: 1024 }).toString('hex'); // 암호화된 문자를 디비에 저장
const dbPwd2 = crypto.scryptSync('1234', '소금2', 64, { N: 1024 }).toString('hex'); // 암호화된 문자를 디비에 저장
console.log(dbPwd, dbPwd2)
// 7675b..., 0934a...

소금에 따라 다른 똥들이 나오는 것을 알 수 있습니다.

무지게 테이블도 소금 뿌린 똥은 찾을래야 찾을 수가 없죠..

그래서 사용자 계정 정보에 똥과 소금을 같이 저장해야합니다.

계정이 털리면?

그러면 계정이 털리면 어떻게 될까요?

소금과 똥을 이제 해커가 알고 있습니다.

그럼 원래 암호를 알 수 있을까요?

예 언젠가는 알게 됩니다. for를 여러번 돌려서 무지게 테이블을 겹겹히 만들어서 백년 후에나 풀 수 있겠죠..

세상에 못 푸는 암호는 없습니다.

못 풀게 막는 것 보다는 최대한 지연시키는 방법을 생각해야 하는 것입니다.

소금 제작

회원가입 할때 하면 되겠죠?

보통 소금을 만들 때 임의값을(crypto.randomFillSync)을 사용해서 만듭니다.

const crypto = require('crypto');
const bf = Buffer.alloc(64)
const s = crypto.randomFillSync(bf)
console.log(s.toString('hex'))
// 42c95a42f6fa5271010...

이런식으로 버퍼 64바이트 짜리를 만들어서 랜덤값을 채우는 것이죠..

몽고디비를 사용하는 우리는 이미 자연스러운 랜덤값 _id 라는 것이 있습니다.

어떤 값이든 _id가 생성되는데요.. 날짜와 시스템관련 정보로 긴 임의 값이 중복되지 않게 자동으로 만들어져있습니다.

모델에 salt 추가하는 것도 귀찮아서..

회원가입에 적용하기

be/routes/api/register/index.js

const crypto = require('crypto')
const User = require('../../../models/users')

router.post('/', (req, res) => {
  const u = req.body
  if (!u.id) return res.send({ success: false, msg: '아이디가 없습니다.'})
  if (!u.pwd) return res.send({ success: false, msg: '비밀번호가 없습니다.'})
  if (!u.name) return res.send({ success: false, msg: '이름이 없습니다.'})

  User.findOne({ id: u.id })
    .then((r) => {
      if (r) throw new Error('이미 등록되어 있는 아이디입니다.')
      return User.create(u)
    })
    .then((r) => {
      const pwd = crypto.scryptSync(r.pwd, r._id.toString(), 64, { N: 1024 }).toString('hex')
      return User.updateOne({ _id: r._id }, { $set: { pwd } })
    })
    .then((r) => {
      res.send({ success: true })
    })
    .catch((e) => {
      res.send({ success: false, msg: e.message })
    })
})
  • 회원을 만들고 일반 비밀번호로 저장한 후
  • 저장된 회원정보의 _id(소금)를 이용해 암호화 된 비밀번호를 다시 저장하는 것입니다.

이때 _id는 몽고디비의 오브젝트키 라는 형이기 때문에 toString()으로 꼭 문자열로 변경해야합니다.

이제 회원가입을 하면 암호화된 비밀번호로 저장됩니다.(id: cccc pwd: cccccc 로 해봄)

잘 적용되었는 지 확인

프론트에 패스워드를 표시해보면 됩니다.

fe/src/views/user.vue

  <div>로그인 횟수: {{user.inCnt}}</div>
  <div>소금(_id): {{user._id}}</div>
  <div>비밀번호: {{user.pwd}}</div>

로그인 횟수 아래에 소금과 비밀번호를 표시해보면 됩니다.

alt pwd fr

백엔드 비밀번호 노출 제거

아무리 암호화 되어 있어도 프론트에 비밀번호가 노출 되는 것은 말이 안되죠..

백엔드에서 전체 컬럼을 보내지 않고 지정 컬럼만 보내야 하는거죠. 그것을 프로젝션이라고 합니다.(rdbms 의 select 뒷부분)

be/api/manage/user/index.js

// User.find({}, { name: 1, age: 1, lv: 1, inCnt: 1 })
User.find().select('-pwd')

몽구스에서는 쿼리빌더(?) 기능으로 쉽게 지정 컬럼을 선택할 수 있습니다.

주석 쳐놓은 원래 방법보다 훨씬 좋죠?

관리자 적용

관리자는 현재 평문 비밀번호를 가지고 있습니다.

관리자로 로그인해서 사용자들을 싹다 지우고 다시 관리자부터 만들어봅니다.

be/models/users.js

User.findOne({ id: cfg.admin.id })
  .then((r) => {
    // console.log(r)
    if (!r) return User.create({ id: cfg.admin.id, pwd: cfg.admin.pwd, name: cfg.admin.name, lv: 0 })
    // if (r.lv === undefined) return User.updateOne({ _id: r._id }, { $set: { lv: 0, inCnt: 0 } }) // 임시.. 관리자 계정 레벨 0으로..
    return Promise.resolve(r)
  })
  .then((r) => {
    if (r.pwd !== cfg.admin.pwd) return Promise.resolve(null)
    console.log(`admin:${r.id} created!`)
    const pwd = crypto.scryptSync(r.pwd, r._id.toString(), 64, { N: 1024 }).toString('hex')
    return User.updateOne({ _id: r._id }, { $set: { pwd } })
  })
  .then(r => {
    if (r) console.log('pwd changed!')
  })
  .catch((e) => {
    console.error(e.message)
  })

관리자 생성후 비밀번호가 설정값과 같으면 아직 암호화 안된 것이 암호화하게 되는 것입니다.

로그인 적용

be/routes/api/sign/index.js

router.post('/in', (req, res) => {
  const { id, pwd } = req.body
  if (!id) return res.send({ success: false, msg: '아이디가 없습니다.'})
  if (!pwd) return res.send({ success: false, msg: '비밀번호가 없습니다.'})

  User.findOne({ id })
    .then((r) => {
      if (!r) throw new Error('존재하지 않는 아이디입니다.')
      // if (r.pwd !== pwd) throw new Error('비밀번호가 틀립니다.')
      const p = crypto.scryptSync(pwd, r._id.toString(), 64, { N: 1024 }).toString('hex')
      if (r.pwd !== p) throw new Error('비밀번호가 틀립니다.')
      return signToken(r.id, r.lv, r.name)
    })
    .then((r) => {
      res.send({ success: true, token: r })
    })
    .catch((e) => {
      res.send({ success: false, msg: e.message })
    })
})

이제 관리자로 로그인 해보면 정상 작동 됨을 확인 할 수 있습니다.

마치며

여기까지 뭔가 장황해 보이지만 단순하게 생각해보면..

양방향 암호화는 이치에 맞지 않으니 단방향 암호화를 해서 비교한다가 끝입니다.

몇년 더 암호를 못풀게 하려면 임의소금을 쳐주는 것이 좋습니다.

소스

소스 확인

소스가 필요하신 분들은 커밋된 부분을 눌러서 강좌 번호로 코드 변경 확인하시면 됩니다.

영상

댓글남기기