[Spring] 패스워드 마이그레이션: Django to Spring

sig03
11 min readMay 30, 2024

--

1.

Django로 구성한 BackEnd 서버를 Spring Boot로 마이그레이션 테스트 중이다. 다 만들어 놓은 BackEnd를 굳이 마이그레이션 하려는 이유는 레퍼런스 문제. Django가 많이 쓰이는 듯 한데 레퍼런스 찾기가 힘들다. 진짜 많이 쓰이는 게 맞아? java쪽은 좋아하지 않았는데 내 기호와 관련없이 레퍼런스가 차고 넘치는 Spring이 더 좋은 대안이 아닐까? 무엇보다 개발자 채용이 쉬울 것 같다.

2.

Spring으로 마이그레이션 하자니 Django에서 적용했던 룰을 Spring에 동일하게 적용해야 하는 문제가 있다. 이미 Django로 DB가 완성되었기 때문에. 제일 처음에 걸렸던 게 패스워드 로직이다.

3.

Django의 default 패스워드 생성 로직은 다음과 같다.

SHA256을 이용한 PBKDF2 알고리즘을 사용하고 아래와 같은 형식으로 저장된다.

# 형식
<algorithm>$<iterations>$<salt>$<hash>

DB에는 아래와 같이 저장된다.

# DB 데이터
# password: abcd
# salt: 1234

pbkdf2_sha256$390000$1234$gJlF8ib+ZQX2/19WC3UXToZeLWtRm5P4IJxJ5QUKrkA=

$ 를 기준으로 패스워드 로직과 hash 값이 저장된다.

pbkdf2_sha256: 알고리즘

390000: iterations 로 hash를 몇번 돌리는지 정한다. Django 버전 따라 값이 다르다.

1234: 랜덤함 salt 값

gJlF8ib+ZQX2/19WC3UXToZeLWtRm5P4IJxJ5QUKrkA=: hash 값

4.

Spring에서 새롭게 만들거나 수정 시 Spring의 패스워드 로직을 따르고 기존 패스워드는 Django의 패스워드 로직으로 읽어들여 비교한다. gpt에게 만들어 달라고 했더니 아래와 같이 만들어 줬다.

package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

import java.util.Base64;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;

@SpringBootApplication
public class DemoApplication {
public static boolean verifyPassword(String password, String storedHash) throws Exception {
String[] parts = storedHash.split("\\$");
String algorithm = parts[0];
int iterations = Integer.parseInt(parts[1]);
String salt = parts[2];
String hash = parts[3];

if (!algorithm.equals("pbkdf2_sha256")) {
throw new IllegalArgumentException("Unsupported algorithm: " + algorithm);
}

byte[] saltBytes = Base64.getDecoder().decode(salt);
byte[] hashBytes = Base64.getDecoder().decode(hash);

// Here the key length should be 256 bits (32 bytes)
PBEKeySpec spec = new PBEKeySpec(password.toCharArray(), saltBytes, iterations, 256);

SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
byte[] testHash = skf.generateSecret(spec).getEncoded();

System.out.println("Expected hash: " + Base64.getEncoder().encodeToString(hashBytes));
System.out.println("Generated hash: " + Base64.getEncoder().encodeToString(testHash));

// Compare the hashes
return slowEquals(hashBytes, testHash);
}

private static boolean slowEquals(byte[] a, byte[] b) {
int diff = a.length ^ b.length;
for (int i = 0; i < a.length && i < b.length; i++) {
diff |= a[i] ^ b[i];
}
return diff == 0;
}

public static void main(String[] args) throws Exception{

String password = "abcd";
String storedHash = "pbkdf2_sha256$390000$1234$gJlF8ib+ZQX2/19WC3UXToZeLWtRm5P4IJxJ5QUKrkA=";

boolean isMatch = verifyPassword(password, storedHash);
System.out.println("Password match: " + isMatch);

SpringApplication.run(DemoApplication.class, args);
}
}

Django의 로직으로 만들어진 패스워드를 분해해 hash 값을 추출하고, 새롭게 만들 패스워드를 Django의 로직으로 만들어 hash 값끼리 비교하는 로직이다. 공식 hash 알고리즘을 사용하는 것이라 제대로 만들어 진다면 똑같은 hash 값이 나와야 한다. 그런데 다른 값이 나온다.

5.

돌려보면 아래와 같은 결과값이 나온다. Expected hash 는 Django 에서 만든 hash 이고 Generated hash 는 Spring에서 만든 hash 이다. 알고리즘만 같으면 똑같은 값이 나와야 하는데 안 되는 이유를 모르겠다. 이제 막 Spring을 스터디 중이라 지식이 짧으니 뭐가 잘못됐는지 감을 잡을 수 없었다. gpt 한테 물어봐도 똑같은 대답 뿐이다.

Expected hash: gJlF8ib+ZQX2/19WC3UXToZeLWtRm5P4IJxJ5QUKrkA=
Generated hash: Q2Jc9+5IjUIjC0TTfe5USkszlrKVNj1HYTA+d9hSx7o=

6.

몇일 해 보고 모르겠다 포기하려 했다. 그러나 안 되는 게 이상하다 생각되어 좀 더 찾아보았다. 그러다 소스를 하나 봤는데, Java에서 PBKDF2 알고리즘으로 hash 값을 만들 때 salt 가져오는 부분이 다름을 확인했다.

# 기존: gpt 소스
byte[] saltBytes = Base64.getDecoder().decode(salt);


# 수정: 검색한 소스
byte[] saltBytes = salt.getBytes();

gpt가 알려준 소스는 패스워드의 salt 값이 base64 인코딩 되어 있는 걸 가정해서 salt를 디코딩 하고 있다. 그러나 Django에서 만든 salt 값은 랜덤 plaintext이지 base64 인코딩한 값이 아니다. 그래서 base64 디코딩 없이 그대로 가져와야 한다. hash 값을 다르게 만드는 salt 값이 다르니 알고리즘이 같아도 다른 값이 나왔던 것이다.

7.

최종 소스

package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

import java.util.Base64;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;

@SpringBootApplication
public class DemoApplication {
public static boolean verifyPassword(String password, String storedHash) throws Exception {
String[] parts = storedHash.split("\\$");
String algorithm = parts[0];
int iterations = Integer.parseInt(parts[1]);
String salt = parts[2];
String hash = parts[3];

if (!algorithm.equals("pbkdf2_sha256")) {
throw new IllegalArgumentException("Unsupported algorithm: " + algorithm);
}

// 수정된 부분
byte[] saltBytes = salt.getBytes();
byte[] hashBytes = Base64.getDecoder().decode(hash);

// Here the key length should be 256 bits (32 bytes)
PBEKeySpec spec = new PBEKeySpec(password.toCharArray(), saltBytes, iterations, 256);

SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
byte[] testHash = skf.generateSecret(spec).getEncoded();

System.out.println("Expected hash: " + Base64.getEncoder().encodeToString(hashBytes));
System.out.println("Generated hash: " + Base64.getEncoder().encodeToString(testHash));

// Compare the hashes
return slowEquals(hashBytes, testHash);
}

private static boolean slowEquals(byte[] a, byte[] b) {
int diff = a.length ^ b.length;
for (int i = 0; i < a.length && i < b.length; i++) {
diff |= a[i] ^ b[i];
}
return diff == 0;
}

public static void main(String[] args) throws Exception{

String password = "abcd";
String storedHash = "pbkdf2_sha256$390000$1234$gJlF8ib+ZQX2/19WC3UXToZeLWtRm5P4IJxJ5QUKrkA=";

boolean isMatch = verifyPassword(password, storedHash);
System.out.println("Password match: " + isMatch);

SpringApplication.run(DemoApplication.class, args);
}
}

8.

gpt가 알려준 소스에서 salt 값 부분을 제대로 이해했다면 금방 찾았을 내용인데 Spring에 대한 이해도가 낮으니 원인을 쉽게 발견하지 못하고 시간이 오래 걸렸다.

그러고 보면 gpt가 만능이(아직까지는…) 아니다. 좋은 질문을 했다면 좋은 결과가 나왔겠지만, 좋은 질문도 해당 분야의 지식이 있어야 할 수 있다. 아무것도 모르는 사람이 좋은 질문을 할 수 없다.

gpt의 등장으로 다른 업종 종사자가 새로운 분야에 뛰어들어 성공할 수 있을 것처럼 얘기한다. 하지만 gpt를 쓰다 보면 느껴진다. 기존에 하고 있던 사람이 더 편하게 잘하게 만들어 줄 수 있는거지, 새로운 분야에 gpt만 믿고 뛰어 들어 성공한다는 건 말이 안 된다. 아직까지는…

--

--

sig03
sig03

No responses yet