여러 프로젝트를 진행하면서 대부분 로그인과 회원가입 기능은 필요했습니다.
bcrypt
를 통해 비밀번호를 암호화하여 저장한 경험도 있었지만, 비밀번호를 있는 그대로 저장하는 경우도 있었습니다. 이는 테러리스트와 같은 개발자라고 합니다.
데이터베이스가 뚫리는 순간 해커는 사용자의 비밀번호를 가지고 우리의 웹 사이트뿐만 아니라 다른 곳에서도 사용할 것이고 2차 피해까지 이어지게 됩니다. 따라서 무조건 비밀번호는 암호화할 의무가 있습니다.
저 또한 이 부분들을 놓치며 테러리스트인 개발자가 아니였나 싶습니다. 앞으로는 주의하고 비밀번호를 암호화할 수 있도록 알아보고 작성한 글입니다.
Node.js 내장 모듈이며, 여러 해시 함수를 통한 암호화 기능을 제공
The crypto module provides cryptographic functionality that includes a set of wrappers for OpenSSL's hash, HMAC, cipher, decipher, sign, and verify functions.
Bcrypt는 암호를 해시하는 데 도움이 되는 라이브러리입니다. 해싱에 느리고 비용이 많이 드는 Blowfish 알고리즘으로 구현되었습니다. 따라서 강력한 보안이 필요로 할 때는 Bcrypt
를 사용하면 좋을 것 같습니다.
암호화 방식은 크게 단방향과 양방향으로 나뉘어진다. 단방향은 암호화를 할 수 있지만 복호화는 불가능하다. 반면 양방향은 암호화, 복호화 모두 가능하다.
그리고 단방향 암호화는 Hash 방식, 양방향 암호화는 대칭키(비공개키), 비대칭키(공개키) 방식을 사용한다.
암호화 | 복호화 | 암호화 방식 | |
---|---|---|---|
단방향 | O | X | Hash |
양방향 | O | O | 대칭키(비공개키), 비대칭키(공개키) |
사용자의 비밀번호는 본인만이 알 수 있어야하고, 만약 비밀번호를 잃어버린 경우 복호화하는 과정에서 노출되기 때문에 대부분 재설정을 할 수 있도록 한다. 따라서 단방향 암호화 방식을 사용한다.
다양한 종류의 해시 알고리즘이 있으며, 알고리즘마다 서로 다른 hash 길이를 가지기도 합니다.
그리고 해시 알고리즘은 공개되어 있기 때문에 해커에게도 공개됩니다. 따라서 이미 보안이 뚫린 해시 함수가 존재하며, 이는 MD5
, SHA-1
, HAS-180
로 사용해선 안된다고 합니다.
보다 안전한 SHA-256
, SHA-512
등을 사용하기를 권고하고 있습니다.
crypto
의 createHash()
메소드를 사용합니다.
각 메소드에 대한 인자는 다음과 같습니다.
createHash()
: 사용할 알고리즘update()
: 암호화할 비밀번호digest()
: 인코딩 방식import crypto from "crypto";
const createHashedPassword = (password) => {
return crypto.createHash("sha512").update(password).digest("base64");
};
console.log(createHashedPassword("1234"));
console.log(createHashedPassword("1234"));
console.log(createHashedPassword("1234"));
/*
1ARVn2Auq2/WAqx2gNrL+q3RNjAzXpUfCXrzkA6d4Xa22yhRLy4AC50E+6UTPoscbo31nbOoq51gvkuXzJ6B2w==
1ARVn2Auq2/WAqx2gNrL+q3RNjAzXpUfCXrzkA6d4Xa22yhRLy4AC50E+6UTPoscbo31nbOoq51gvkuXzJ6B2w==
1ARVn2Auq2/WAqx2gNrL+q3RNjAzXpUfCXrzkA6d4Xa22yhRLy4AC50E+6UTPoscbo31nbOoq51gvkuXzJ6B2w==
*/
위와 같이 비밀번호를 암호화하였지만 동일한 해시 알고리즘과 인코딩 방식을 사용할 때 사용자의 비밀번호가 동일한 경우 같은 해시 값을 반환합니다. 이를 레인보우 테이블이라고 합니다.
이를 통해 해커는 임의의 값을 입력하면서 유추하며 입력된 값을 알아낼 수도 있습니다.
이 점을 보완하기 위한 방법은
salt
라는 특정 값을 붙여 변형시킨다.2가지 방법을 합하여 입력 값에 salt 값을 붙여서 여러번 반복 해싱할 수도 있습니다.
입력은 길이 제한이 없지만, 출력인 해시 값은 항상 고정된 길이를 가지므로 한계가 있기 때문에 다른 입력이지만 같은 해시 값이 나오는 경우도 있다고 합니다.
salt
생성에서는 crypto
모듈의 randomBytes()
, 비밀번호 암호화 또는 검증에서는 pbkdf2()
메소드를 사용할 것 입니다.
앞으로 구현할 함수들을 정의할 때 new Promise()
로 감싸주려고 하였으나, Node.js의 내장 모듈인 util
의 promisify()
를 사용하면 좀 더 가독성 좋은 코드를 작성할 수 있습니다.
import util from "util";
import crypto from "crypto";
const randomBytesPromise = util.promisify(crypto.randomBytes);
const pbkdf2Promise = util.promisify(crypto.pbkdf2);
salt
값은 crypto
모듈의 randomBytes()
메소드를 통해 64바이트 길이로 생성합니다. buffer
형식을 가지고 있으므로 base64
문자열로 변경하면 랜덤 문자열이 됩니다.
salt
는 이후 검증을 위해 회원가입 시password
와 함께 DB에 저장이 필요합니다.
const createSalt = async () => {
const buf = await randomBytesPromise(64);
return buf.toString("base64");
};
단방향 암호화에서 많이 사용되는 crypto
모듈의 pbkdf2()
메소드를 사용합니다.
인자로는 총 5개로 해싱할 값
, salt
, 해시 함수 반복 횟수
, 해시 값 길이
, 해시 알고리즘
입니다.
해시 함수 반복 횟수
는 딱 떨어지는100000
보다는104906
와 같은 수를 넣는게 좋다고 합니다.
salt
생성을 위해 앞에서 정의한 createSalt()
함수를 사용합니다.
key
또한 buffer
형식을 가지고 있으므로 base64
문자열로 변경해줍니다.
export const createHashedPassword = async (password) => {
const salt = await createSalt();
const key = await pbkdf2Promise(password, salt, 104906, 64, "sha512");
const hashedPassword = key.toString("base64");
return { hashedPassword, salt };
};
password
: 로그인 인증할 때의 사용자가 입력한 비밀번호userSalt
: DB에 저장되어있는 사용자의 salt
userPassword
: DB에 저장되어있는 사용자의 암호화된 비밀번호(해시 값)단방향 암호화이므로 복호화는 진행할 수 없습니다. 따라서 비밀번호 암호화할 때의 동일한 방법으로 암호화를 진행하여 비교합니다. 이때 salt
는 기존에 생성된 값을 사용해야 합니다.
만약 일치한다면 true
, 일치하지 않는다면 false
를 반환하도록 합니다.
export const verifyPassword = async (password, userSalt, userPassword) => {
const key = await pbkdf2Promise(password, userSalt, 99999, 64, "sha512");
const hashedPassword = key.toString("base64");
if (hashedPassword === userPassword) return true;
return false;
};
verifyPassword()
함수의 사용 예시는 다음과 같습니다.
이는 passport
의 로그인 인증을 위한 콜백함수에서의 사용 예시입니다.
passport.use(
new LocalStrategy(
{
session: true, // 세션 저장 여부
usernameField: "id", // form > input name
passwordField: "password",
},
async (id, password, done) => {
try {
// 회원정보 조회
const user = await User.findOne({
where: {
email: id,
},
raw: true,
});
// 회원정보가 없는 경우
if (!user) {
done(null, false, {
message: "존재하지 않는 아이디입니다.",
});
}
const verified = await verifyPassword(
password,
user.salt,
user.password
);
// 비밀번호가 일치하지 않는 경우
if (!verified) {
done(null, false, {
message: "비밀번호가 일치하지 않습니다.",
});
}
done(null, user); // serializeUser로 user 전달
} catch {
done(null, false, {
message: "서버의 문제가 발생했습니다. 잠시 후 다시 시도해 주세요.",
});
}
}
)
);
감사합니다