본문 바로가기
🌱 Spring

🔐 비밀번호 암호화해보자 (Feat: Spring)

by iirin 2023. 10. 29.
해당 글에서 구현 언급한 코드는 practice-member-api 에서 확인 가능합니다.

 

시작하며

초반 연습용 프로젝트에서 회원가입을 구현하며 그냥 일반 텍스트로 회원 비밀번호를 관리해왔습니다.

하지만 보안상 비밀번호를 이렇게 평문으로 저장하는 일은 반드시 지양되어야 합니다.

 

마침 회원가입과 Jwt 토큰을 사용하는 인증/인가는 매우 자주 쓰는 로직이라 나만의 라이브러리를 만들고 싶었는데요. 원티드 프리온보딩 백엔드 미션이 딱 기본 회원가입 + jwt 토큰 방식의 인증인가 + 게시판 REST API라서 이참에 이 비밀번호 암호화를 구현해보기로 했습니다💪

Spring Framework 는 Spring Security 라는 하위 프레임워크에서 이러한 보안 인증인가 를 편리하게 제공해주고 있지만 해당 암호화 기술 대한 이해를 높이기 위해 Spring Security 없이 구현하였습니다. 그리고 이 암호화 하나만을 위해서 의존성을 주입하고, 잘 모르는 프레임 워크를 사용하고 싶지 않기도 했고요... 🥲

 

🤓 먼저 공부합시다, 비밀번호 암호화

암호화 Encryption 와 해싱 Hashing 의 차이점

보안 분야에서는 암호화와 해싱의 개념이 다르다는 것을 먼저 짚고 넘어가야겠습니다.

암호화는 데이터가 일반 텍스트로 전달되고 읽을 수 없는 암호화로 바꿀 수 있고, 이를 다시 해독하여 일반 텍스트로 읽을 수 있는 양방향 기능입니다. 암호화는 회원의 예민할 수 있는 정보를 저장합니다. (이름, 주소, 계좌번호 등) 데이터 노출을 최소화 시키기 위한 방법입니다.

반면 해싱은 단방향입니다. 한번 암호화하면 다시 이를 평문화 시킬 수 없습니다. 대신 같은 문자열을 같은 해시 함수를 거치면 결과값은 늘 같다는 것을 이용하여 값이 일치하는지 확인할 수 있습니다. 해싱은 비밀번호 등 평문화 할 일이 없는 중요한 정보를 저장하는 데 주로 사용합니다. 암호화의 목적과 다른 점은 원래 데이터와 비교해 데이터 변조가 없었는지 무결함을 증명하는 것이 목표 라는 것입니다.

 

그래서 우리는 비밀번호를 되찾을 수 없습니다.

서버에 있는 비밀번호는 암호화된 결과값(이하 다이제스트, 해시라는 용어로 표현하겠습니다.)일 뿐이지 원래 형태는 영영 되찾을 수 없는거죠. 그래서 비밀번호를 리셋해주거나 임시 비밀번호를 만드는 방법으로 비즈니스 로직을 해결합니다.

((( 만약 비밀번호를 되찾아주는 사이트가 있다면 당장 탈퇴하도록 하세요)))

 

비밀번호는 되찾을 수 없는거야 🪃

비밀번호를 해싱한다니! 해시테이블을 공부해본 분들이라면 다들 알겠지만 해시충돌(Hash Collision)이라는 것이 있습니다. 해시 테이블의 출력값이 유한할 경우 분명 다른 값을 넣었는데 같은 출력값이 나오는 경우이죠.

(이거 위키에서 다들 보셨죠?)

그래서 비밀번호 암호화 해싱을 하면 충돌이 나지 않을까 잠깐 걱정했지만 그럴 확률은 매우매우 적은 것 같습니다. 👉 참고링크 : Potential collision with hash password

 

여하튼 암호화와 해싱을 가볍게 정리하자면 다음의 표와 같습니다.

  암호화 해싱
복호화 가능성 가능 불가능
(결과값의) 길이 가변적 고정 길이
주요 사용 알고리즘 AES, RC4, DES, RSA, ECDSA… SHA-1, SHA-2, MD5, CRC32…

 

 

그럼 해싱이 완벽한 비밀번호 암호화 방법일까?

물론 모든 암호는 뚫릴 수 있겠지만, 해싱이 가지는 문제점이 두 가지 있습니다. 👉 참고링크 D2

 

첫번째는 느리다는 것입니다.

해싱은 해싱 알고리즘마다 속도가 다른데요. 일반적으로 강력할 수록 더 느립니다. 예를 들어 자주 쓰였던 SHA-1 알고리즘과 최근 많이 사용되는 PBKDF2를 비교해본다면 후자 쪽이 브루트 포스 공격에 더 강력하지만 속도는 매우 느립니다.

의도적으로 해싱 횟수를 추측할 수 없게 하기 위해서 느리게 하기도 합니다.

하지만! 보안을 위해서 몇 초의 기다림은 사용자에게 그닥 문제가 되지 않을 것 같습니다.

 

두번째는 더 심각한 문제인 인식 가능성입니다.

그러니까 자주 쓰는 비밀번호의 경우에는 그냥 다이제스트(해시 함수를 거쳤을 때 결과 값)를 미리 표로 정리해둘 수 있습니다.

동일한 메시지에 대해서 늘 동일한 다이제스트를 갖기 때문에, 해커가 다이제스트 결과를 가능한 많이 확보한 다음 이를 탈취한 데이터 베이스 데이터와 비교하여 원본 메시지를 찾아낼 수 있게 되는거죠. 어떤 알고리즘을 사용했는지 안다면요.

이러한 다이제스트를 모아서 레인보우 테이블이라고 하는데요. 하나의 패스워드에서 시작해 변이된 형태의 여러 패스워드를 생성하여 그 패스워드의 해시를 고리처럼 연결해 일정 수의 패스워드와 해시로 이루어진 테이블입니다. 👉 참고 링크

이미지 출처 : https://www.thesecurityblogger.com/understanding-rainbow-tables/

이를 이용하면 브루트 포스 공격이 더 쉬워지겠죠.

아래는 SHA-1을 사용했을 때 레인보우 테이블의 예시입니다.

86f7e437faa5a7fce15d1ddcb9eaeaea377667b8 a
e9d71f5ee7c92d6dc9e92ffdad17b8bd49418f98 b
84a516841ba77a5b4648de2cd0dfcb30ea46dbb4 c
...
948291f2d6da8e32b007d5270a0a5d094a455a02 ZZZZZX
151bfc7ba4995bfa22c723ebe7921b6ddc6961bc ZZZZZY
18f30f1ba4c62e2b460e693306b39a0de27d747c ZZZZZZ

5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8 password
e38ad214943daad1d64c102faec29de4afe9da3d password1
b7a875fc1ea228b9061041b7cec4bd3c52ab3ce3 letmein
5cec175b165e3d5e62c9e13ce848ef6feac81bff qwerty123

 

 

Salt, Pepper, Key Stretch 😵

위에서 언급한 인식 가능성에 대한 단점을 보완하기 위해 솔트Salt, 페퍼pepper, 스트레치Key Stretch 방법을 사용하는데요. 이것에 대해서도 간단히 다루어 보겠습니다.

이 세가지가 적영된 해시를 간단하게 수식으로 표현하자면 이렇게 될 것 같습니다.

pepper = {came from somewhere}
salt = {generated random}
hash = H(H(H(H(H(H(H(H(H(H(H(H(H(H(H(...H(password + salt + pepper) + salt + pepper) + salt + pepper) ... )

 

Salt

솔트 Salt는 단방향 해싱시 다이제스트를 생성할 때 추가되는 바이트 단위의 임의 문자열입니다. 보통 128비트(16byte) 이상으로 랜덤 문자열을 생성하여 사용하고 이 생성되는 문자열은 회원마다 각각 다르게 적용한다면 더욱 강력하게 작용합니다.

솔트의 목적은 해커가 데이터베이스를 탈취하기 전에 레인보우 테이블과 같은 예측가능한 테이블을 생성하지 못하는 데 있습니다. 그래서 솔트는 데이터베이스에 저장하고 이를 얻을 수 있는 유일한 방법을 데이터베이스로 합니다.

 

Pepper

페퍼 Pepper는 모든 비밀번호에 일정하게 적용되지만 ✌️데이터베이스에는 저장하지 않는✌️ 또다른 소금입니다.

모두에게 동일하게 적용되기 때문에 이 방법이 유효하지 않다는 의견도 있습니다만, 데이터베이스외에 다른 설정 파일을 통해서 넣어주는 페퍼로 비밀번호에 대한 안전성을 더 높일 수 있을 것입니다.

 

Key Stretch

salt와 pepper와 본래 문자열을 여러번 해시 함수를 거쳐 다이제스트를 중접하여 생성하게 합니다. 이렇게 생성된 다이제스트는 브루트 포스 공격시 시간이 더 걸리도록 할 수 있습니다.

이미지 출처 : https://d2.naver.com/helloworld/318732

 

그리고 더 어렵게!

다이제스트를 생성할 때 솔트와 패스워드 외에도 또 다른 값을 추가하여 다이제스트를 추측하기 더 어렵게 강력하게 만들 수 있습니다.

대표적인 예로 KDF(Key Derivation Function)가 있는데요. 비밀번호나 솔트 등에서 키를 파생시켜 암호화 알고리즘에 사용합니다. 👉 참고링크

hash = KDF(password, salt, workFactor)

가장 널리 사용되는 두 가지 KDF는 PBKDF2 와 bcrypt 입니다. PBKDF2는 키가 있는 HMAC (블록 암호를 사용할 수 있음) 의 반복을 수행하여 작동하며 bcrypt는 Blowfish 블록 암호에서 많은 수의 암호문 블록을 계산하고 결합하여 작동합니다.

 

Spring 에서 구현해보자

테이블 구조

비밀번호를 DB에 직접적으로 저장하지 않고, 해시와 솔트를 저장하여 비밀번호 검증을 하는 형태로 기획해보았는데요. 특히 회원가입과 로그인 외에는 회원 도메인과 별도로 움직일 수 있다고 생각되어 별도 테이블로 ERD를 짜보았습니다.

 

  • member_password 비밀번호를 저장하는 테이블입니다.
    • member 와 식별관계로 설계해도 괜찮았겠지만 member_auth 와 통일감있게 설계하고 싶어 비식별 관계로 설계하였습니다.
    • member 와는 1:1 관계입니다. (ERD가 조금 잘못되었습니다 🥲)

 

Entity 구현

ORM은 Spring Data JPA를 사용했습니다. 특이사항은 없고 테이블 레코드와 매핑이 될 수 있도록 했습니다.

salt 길이와 저장방법에 대해서도 고민을 했는데요. 최소 권장 길이인 128비트로 설정하고 컬럼 타입도 지정해주었습니다. 더 길고 길이가 랜덤이면 효과적일 것 같습니다.

@Entity
@Table(name = "member")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@SecondaryTable(name = "member_password", pkJoinColumns = @PrimaryKeyJoinColumn(name = "member_id"))
public class Member extends CreatedEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Email
    private String email;
    @Embedded
    private Password password = new Password();
    private String username;
}
@Getter
@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Password {

    @Column(table = "member_password")
    private String hash;
    @Column(columnDefinition = "BINARY(16)", table = "member_password")
    private byte[] salt;

    public static Password of (String hash, byte[] salt) {
        return new Password(hash, salt);
    }
}

 

PasswordEncoder

인터페이스로 먼저 역할을 규정해준 뒤, 알고리즘마다 하위 구현 클래스를 구현할 수 있도록 하였습니다.

단방향 암호화시 많이 쓰이는 PBKDF2 를 이용하는 인코더를 구현했습니다. 

알고리즘 이름인 "PBKDF2WithHmacSHA1", 반복 회수와 키 길이는 외부 변수로 주입받았습니다. 인스턴스 생성자의 파라미터에 따라 변수를 정했고 페퍼는 따로 사용하지 않았습니다.

@Component
public class Pbkdf2Encoder implements PasswordEncoder {
    private final String encodeAlgorithm; // 알고리즘 이름
    private final int iterations; // 반복 횟수
    private final int keyLength; // 키 길이

    public Pbkdf2Encoder(
            @Value("${password.encode.algorithm}") String encodeAlgorithm,
            @Value("${password.encode.iterations}") int iterations,
            @Value("${password.encode.keyLength}") int keyLength) {
        this.encodeAlgorithm = encodeAlgorithm;
        this.iterations = iterations;
        this.keyLength = keyLength;
    }

    @Override
    public Password encrypt(String password) {
        byte[] saltBytes = generateSalt();
        return encrypt(password, saltBytes);
    }

    public Password encrypt(String password, byte[] saltBytes) {
        byte[] hashBytes;
        KeySpec spec = new PBEKeySpec(password.toCharArray(), saltBytes, iterations, keyLength);
        try {
            SecretKeyFactory factory = SecretKeyFactory.getInstance(encodeAlgorithm);
            hashBytes = factory.generateSecret(spec).getEncoded();
        } catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
            throw new RuntimeException("Password encryption failed.", e);
        }
        String hash = new String(Base64.getEncoder().encode(hashBytes));
        return Password.of(hash, saltBytes);
    }
		
		// 랜덤 salt를 반환합니다.
    private byte[] getSalt() {
        SecureRandom random = new SecureRandom();
        byte[] salt = new byte[16];
        random.nextBytes(salt);
        return salt;
    }

}
  • 테스트 코드
    @Test
    @DisplayName("비밀번호를 암호화 할 수 있다.")
    void testPasswordEncoder() throws Exception{
        //given
        String password = "password1234";
        
        //when
        Password encrypt = passwordEncoder.encrypt(password);
        log.info(encrypt.toString());
    }

    @Test
    @DisplayName("같은 비밀번호를 같은 salt로 암호화하면 해시가 같아야 한다.")
    void testPassword() throws Exception{
        //given
        String inputPassword1 = "password1234";
        String inputPassword2 = "password1234";

        //when
        Password password1 = passwordEncoder.encrypt(inputPassword1);
        byte[] salt = password1.getSalt();
        Password password2 = passwordEncoder.encrypt(inputPassword2, salt);

        assertThat(password1.getHash()).isEqualTo(password2.getHash());
        assertThat(password1).isEqualTo(password2);
    }​

 

PasswordValidator

암호를 체크하는 역할의 컴포넌트를 따로 구현하였습니다. 역할이 작기 때문에 PasswordEncoder 에 같이 체크 메서드를 구현해도 무방할 것 같습니다.

@Component
@RequiredArgsConstructor
public class PasswordValidator {

    private final PasswordEncoder passwordEncoder;

    public boolean check(Member member, String inputPassword) {
        return passwordEncoder.encrypt(inputPassword, member.getSalt())
                .equals(member.getPassword());
    }
}
  • 테스트 코드
    @BeforeEach
    void init() {
        Password password = passwordEncoder.encrypt("myPassword");

        testMember = Member.builder()
                .id(1L)
                .email("test@gmail.com")
                .username("회원")
                .password(password)
                .build();
    }

    @Test
    @DisplayName("회원 비밀번호가 맞는지 확인할 수 있다.")
    void isCorrectPassword() {
        byte[] salt = testMember.getSalt();
        Password otherPassword = passwordEncoder.encrypt("myPassword", salt);

        assertThat(testMember.isCorrectPassword(otherPassword)).isTrue();
    }

    @Test
    @DisplayName("회원 비밀번호가 틀린지 확인할 수 있다.")
    void isIncorrectPassword() {
        byte[] salt = testMember.getSalt();
        Password otherPassword = passwordEncoder.encrypt("wrongPassword", salt);

        assertThat(testMember.isCorrectPassword(otherPassword)).isFalse();
    }

 

하면서 Spring Security는 어떻게 해시와 솔트를 저장하고 있는지 궁금했는데요.

자주 쓰이는 BCryptPasswordEncoder의 경우, 내부적으로 임의 솔트를 생성하고 해시 내에 저장하고 있다고 합니다. 👉 참고링크

$2a$10$ZLhnHxdpHETcxmtEStgpI./Ri1mksgJ9iDP36FmfMdYyVg9g0b2dq

 

이상 여기까지 비밀번호 암호화에 대해서 학습하고 구현한 내용이었습니다. 혹시 사실과 다른 부분이 있다면 댓글로 알려주시면 감사하겠습니다 😀

 


Refs.