본문 바로가기

프로젝트

Spring Security 없이 PasswordEncoder 구현하기

사이드 프로젝트를 진행하며 회원가입 시 패스워드 암호화에 대해 구현했습니다. 이 포스트에서는 암호화에 대한 간략한 내용과 저의 암호화 구현기에 대한 내용을 적어보도록 하겠습니다.


일반적인 서비스를 이용할 때 사용자는 이메일(혹은 ID)과 비밀번호를 입력해 회원가입을 합니다.
서비스 내에는 사용자의 주소나 휴대전화 번호 등 중요한 개인 정보가 저장됩니다.
이러한 개인정보를 지키기 위해 요즘 회원가입 시에는 비밀번호에 대문자, 특수문자 등을 강제로 넣게 하고, 오랜만에 방문한 사이트에서는 비밀번호 좀 바꾸라고 닦달을 합니다.

 

90일 후 변경은 국룰

그렇다면 이러한 정도로 우리의 개인정보는 안전하게 지켜졌을까요? 놉! 아닙니다.
사용자가 비밀번호를 어렵게 만들려고 노력하는 만큼, 서비스도 사용자의 비밀번호를 어렵게 만들려 노력합니다. 그게 암호화입니다!
이제부터 간략한 암호화의 내용과 제가 회원가입 시에 비밀번호를 어떻게 DB상에 저장했는지에 대해 다뤄보려고 합니다.

사용자의 암호를 어렵게 만든다??

사용자의 암호를 어렵게 만든다는 것은 비밀번호를 평문 그대로 저장하는 것이 아니라 암호 알고리즘을 사용한 후 저장한다는 뜻입니다.

암호 알고리즘에는 크게 두 가지 개념이 있습니다.

암호화(Encryption) & 해시(Hash)

 

둘의 차이는 간단합니다. 암호화는 양방향이고 해시는 단방향입니다. 양방향이라는 뜻은 복호화가 가능하다는 것인데.. 감이 오지 않나요? 당연히 비밀번호는 해시 함수를 이용해야 합니다.

해시 함수는 다음과 같은 특징이 있습니다.

  1. 동일한 메시지가 동일한 해시 함수를 통해 처리되면 항상 같은 해시 값을 생성한다.
  2. 해시 값을 통해 원래 메시지를 복원하는 것이 불가능하다.
  3. 메시지의 작은 변경에도 해시 값이 크게 달라집니다.
  4. 다른 메시지가 동일한 해시 값을 생성하지 않도록 해야 합니다.

해시 알고리즘에 대한 내용은 이제 끝인가요??

마지막으로 또 중요한 개념이 남아있습니다. 바로 솔트입니다.
솔트란 해싱되기 전 메시지에 추가적으로 붙게 되는 문자열을 뜻합니다.

단어에 소금을 치듯 임의의 문자열을 추가하는 작업을 Adding Salt라고 합니다.

이러한 작업을 하는 이유는 무엇일까요?

해커가 무차별 대입을 통해 하나의 해시 값에 대한 원 메시지를 찾았다는 가정을 해보겠습니다.

test1234 = cQdza/EoWPLa3dsXRowpRA==

그렇다면 해커는 해당 해시 값을 가진 모든 사용자의 비밀번호를 알게 되는 것이겠지요.
이러한 상황을 방지하기 위해 해싱을 하기 전 메시지에 Adding Salt 작업을 하는 것입니다.
당연히 모든 사용자에게 동일한 솔트 값을 추가하는 것은 효용이 없고 각 사용자마다 다른 솔트값을 대입해야 합니다.

PasswordEncoder를 구현해 보자!

앞선 개념을 바탕으로 바로 PasswordEncoder를 구현해 보겠습니다.

@Component
public class PasswordEncoder {
    public String encrypt(String password) {
        try {
            SecureRandom random = new SecureRandom();
            byte[] salt = new byte[16];
            random.nextBytes(salt);

            KeySpec spec = new PBEKeySpec(password.toCharArray(), salt, 85319, 128);
            SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");

            byte[] hash = factory.generateSecret(spec).getEncoded();
            return Base64.getEncoder().encodeToString(hash);
        } catch (NoSuchAlgorithmException | UnsupportedEncodingException |
                 InvalidKeySpecException e) {
            throw new RuntimeException(e);
        }
    }
}
  • 제가 최초에 구현했던 PasswordEncoder 클래스입니다.
  • 비밀번호를 해싱해 주는 클래스이니 여러 인스턴스가 생성될 필요 없이 빈으로 등록(@Component)해 싱글톤으로 관리되게 합니다.
  • 고정된 솔트 값을 가지면 안 되니 SecureRandom 클래스를 이용해 매번 다른 솔트 값을 생성합니다.
  • javax.crypto 라이브러리를 이용해 해시 값을 생성해 줍니다.
  • PBEKeySpec 생성자의 세 번째 인자값은 이 알고리즘을 얼마나 반복할 것인지에 대한 강도 함수이고, 네 번째 인자값은 길이에 대한 값입니다.

해당 코드는 당연히 한 번에 원하는 방향으로 작동하지 않았습니다. 이유가 무엇인지 보이시나요?

결론적으로 말씀드리자면 솔트값을 랜덤으로 생성한 것이 문제였습니다..

PasswordEncoder는 회원가입 시에만 쓰이는 것이 아니고 로그인 시에도 사용됩니다. 사용자가 로그인을 할 때 분명 회원가입 시와 같은 비밀번호를 입력했는데 달라진 해시 값으로 인해 로그인에 실패해 버립니다.

회원가입 시 해싱된 비밀번호와 로그인 시 해싱된 비밀번호의 값이 다르다..

그래서 저는 salt 값을 구하는 부분을 다음과 같이 수정하였습니다.

KeySpec spec = new PBEKeySpec(password.toCharArray(), getSalt(password), 85319, 128);

...

private byte[] getSalt(String password)
    throws NoSuchAlgorithmException, UnsupportedEncodingException {

    MessageDigest digest = MessageDigest.getInstance("SHA-512");
    byte[] keyBytes = password.getBytes("UTF-8");

    return digest.digest(keyBytes);
}

짜잔~ 이렇게 고치니 로그인에서 문제가 일어나던 것은 해결이 되었습니다.

 

그렇다면 이것으로 오늘의 구현기가 끝일까요? 아닙니다. 솔트를 설명하는 부분에서 예시로 들었던 내용에 대한 문제가 남아있습니다. 패스워드를 통해 솔트를 구현했기 때문에 결국 같은 비밀번호를 쓰는 사용자들은 서로 같은 해시 값을 같게 된다는 문제입니다.

다른 사용자여도 비밀번호가 같다면 해시값 또한 같다.

이 문제를 해결하기 위해 encrypt 함수에 email 값을 추가로 받아오는 것으로 결정했습니다. 제가 개발하고 있는 서비스의 정책은 email 중복이 불가능하기 때문에 email을 이용해 솔트값을 생성하면 사용자별로 다른 솔트값을 기대할 수 있습니다.

그렇게 완성된 PasswordEncoder입니다.

@Component
public class PasswordEncoder {

    public String encrypt(String email, String password) {
        try {
            KeySpec spec = new PBEKeySpec(password.toCharArray(), getSalt(email), 85319, 128);
            SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");

            byte[] hash = factory.generateSecret(spec).getEncoded();
            return Base64.getEncoder().encodeToString(hash);
        } catch (NoSuchAlgorithmException | UnsupportedEncodingException |
                 InvalidKeySpecException e) {
            throw new RuntimeException(e);
        }
    }

    private byte[] getSalt(String email)
        throws NoSuchAlgorithmException, UnsupportedEncodingException {

        MessageDigest digest = MessageDigest.getInstance("SHA-512");
        byte[] keyBytes = email.getBytes("UTF-8");

        return digest.digest(keyBytes);
    }
}

테스트 성공~!

테스트가 성공하고 원하던 결과가 나온 것을 확인할 수 있다.

물론 Spring Security를 쓰면 더 간편하게 작업이 가능하겠지만, 때로는 이렇게 직접 구현하며 작동 원리를 이해해 나가는 것도 중요하다고 본다.

 

출처: 

https://www.baeldung.com/java-password-hashing

https://crackstation.net/hashing-security.htm#salt