
Spring Security와 Attribute Converter (이곳을 클릭) 이슈에 대한 글을 적다가, 글의 내용이 너무 방대해져 포스팅을 나누어서 하려고 한다.
Spring Security 에 대한 전반적인 내용은 (이곳을 클릭)에서 다루고있다.
이번 포스팅은 Spring Security 에서 제공하는 PasswordEncoder Interface 에 대한 포스팅이다.
PasswordEncoder 구현체에 대해 다루며 총 2부에 걸쳐 내용을 다루고자 한다.
1부 : PasswordEncoder에 대한 전반적인 설명, 알고가면 좋은 지식
2부 : PasswordEncoder 구현체에 대한 세부 내용
암호를 확인하는데 걸리는 시간은 약 1초정도 소요되도록 설정하는 것을 권장합니다. 총 2가지의 이유가 있는데,
첫 번째는, 적응형 단방향 암호화는 리소스를 많이 사용합니다. (CPU, 메모리 등)
두 번째는, 공격자의 무차별적인 공격 (예를 들면 브루트포스 등)으로부터 비교적 안전합니다.
* 적응형 단방향 암호화 : 암호를 매번 다르게 생성하는 것 (Salt를 사용하여 가능)
사용하는 알고리즘 : BCrypt 알고리즘
속도 : 의도적으로 느리다. (암호 크래킹에 대한 저항력을 높이기 위해)
암호 강도 : 기본 10 (최소4, 최대 31 / BCryptPasswordEncoder의 Javadoc에 언급)
JAVA 코드 예시
// Create an encoder with strength 16 BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(16); // 파라미터를 넣지 않으면 기본 강도 10 String result = encoder.encode("myPassword"); assertTrue(encoder.matches("myPassword", result));
Kotlin 코드 예시
// Create an encoder with strength 16 val encoder = BCryptPasswordEncoder(16) // 파라미터를 넣지 않으면 기본 강도 10 val result: String = encoder.encode("myPassword") assertTrue(encoder.matches("myPassword", result))
사용하는 알고리즘 : Argon2 알고리즘
비고 :
Argon2는 Password Hashing Competition 에서 우승을 하기도 했다.
Argon2PasswordEncoder을 사용하기 위해서는 BouncyCastle이 필요하다.
(* BounceCastle : 확장된 기능을 가진 자바 암호화 라이브러리)
JAVA 코드 예시
// Create an encoder with all the defaults Argon2PasswordEncoder encoder = new Argon2PasswordEncoder(); String result = encoder.encode("myPassword"); assertTrue(encoder.matches("myPassword", result));
Kotlin 코드 예시
// Create an encoder with all the defaults val encoder = Argon2PasswordEncoder() val result: String = encoder.encode("myPassword") assertTrue(encoder.matches("myPassword", result))
사용하는 알고리즘 : PBKDF2 알고리즘
비고 : FIPS 인증이 필요할 때 사용하면 좋다.
(* FIPS : 미국의 연방 정보 처리 표준은 비군사적 인 미국 정부 기관 및 정부 계약자가 컴퓨터 시스템에 사용하기 위해 국립 표준 기술 연구소에서 개발 한 공개적으로 발표 된 표준)
JAVA 코드 예시
// Create an encoder with all the defaults Pbkdf2PasswordEncoder encoder = new Pbkdf2PasswordEncoder(); String result = encoder.encode("myPassword"); assertTrue(encoder.matches("myPassword", result));
Kotlin 코드 예시
// Create an encoder with all the defaults val encoder = Pbkdf2PasswordEncoder() val result: String = encoder.encode("myPassword") assertTrue(encoder.matches("myPassword", result))
사용하는 알고리즘 : SCrypt 알고리즘
JAVA 코드 예시
// Create an encoder with all the defaults SCryptPasswordEncoder encoder = new SCryptPasswordEncoder(); String result = encoder.encode("myPassword"); assertTrue(encoder.matches("myPassword", result));
Kotlin 코드 예시
// Create an encoder with all the defaults val encoder = SCryptPasswordEncoder() val result: String = encoder.encode("myPassword") assertTrue(encoder.matches("myPassword", result))
이전 포스팅 (PasswordEncoder에 대한 전반적인 설명, 알고가면 좋은 지식) 에서 소개했지만, PasswordEncoder 는 총 14개의 구현체 중, Deprecated 5개와 나머지 9개가 존재한다. 나머지 9개 중에서도 위에서 소개한 4개를 제외한 나머지 5개는 왜 소개를 하지 않았을까?
Spring Security 5.6.2 공식 문서에 따르면, 나머지 5개의 경우 '안전하지 않기 때문에 더 이상 사용되지 않지만, 기존 레거시 시스템의 마이그레이션을 고려하여 제거하지는 않는다.'라고 한다.
그러니 위 4개를 제외한 나머지 PasswordEncoder는 사용을 지양하는 것이 좋겠다.
그렇다면, 평문과 암호문의 일치 여부를 어떻게 판단할 수 있을까?
첫 번째로, PasswordEncoder 클래스를 상속받은 LazyPasswordEncoder 클래스의 matchs() 메소드를 통해 각 PasswordEncoder 구현체에 평문과 암호문을 전달한다.

각 구현체라고 하면, 위에서 설명한 BCryptPasswordEncoder, Argon2PasswordEncoder, Pbkdf2PasswordEncoder 등이 있다.
// BCrypt 정규식
private Pattern BCRYPT_PATTERN = Pattern.compile("\\A\\$2(a|y|b)?\\$(\\d\\d)\\$[./0-9A-Za-z]{53}");
여기에서 좀 이상한 점이 있다.

기존 암호화된 패스워드 (hashed) 와 일치한지 비교하기 위해 plainText를 인코딩하는 것까지는 알겠는데,
왜 plainText를 암호화하기 위해 hashed가 필요한걸까?

6-3에서 hashpw(plaintext, hashed)에 전달한 두번째 인자 hashed는 이곳에서 salt 로서 사용되고 있다.
내용이 다소 길어 코드 본문을 첨부하며, 기존 암호문에서 salt를 추출하는 부분과
추출한 salt로 암호문을 만드는 곳에는 주석을 달았다.
public static String hashpw(byte passwordb[], String salt) {
BCrypt B;
String real_salt;
byte saltb[], hashed[];
char minor = (char) 0;
int rounds, off;
StringBuilder rs = new StringBuilder();
if (salt == null) {
throw new IllegalArgumentException("salt cannot be null");
}
int saltLength = salt.length();
if (saltLength < 28) {
throw new IllegalArgumentException("Invalid salt");
}
if (salt.charAt(0) != '$' || salt.charAt(1) != '2') {
throw new IllegalArgumentException("Invalid salt version");
}
if (salt.charAt(2) == '$') {
off = 3;
}
else {
minor = salt.charAt(2);
if ((minor != 'a' && minor != 'x' && minor != 'y' && minor != 'b') || salt.charAt(3) != '$') {
throw new IllegalArgumentException("Invalid salt revision");
}
off = 4;
}
if (salt.charAt(off + 2) > '$') {
throw new IllegalArgumentException("Missing salt rounds");
}
if (off == 4 && saltLength < 29) {
throw new IllegalArgumentException("Invalid salt");
}
rounds = Integer.parseInt(salt.substring(off, off + 2));
// 여기서 기존 암호문을 사용하여 salt를 추출합니다.
real_salt = salt.substring(off + 3, off + 25);
saltb = decode_base64(real_salt, BCRYPT_SALT_LEN);
if (minor >= 'a') {
passwordb = Arrays.copyOf(passwordb, passwordb.length + 1);
}
B = new BCrypt();
hashed = B.crypt_raw(passwordb, saltb, rounds, minor == 'x', minor == 'a' ? 0x10000 : 0);
rs.append("$2");
if (minor >= 'a') {
rs.append(minor);
}
rs.append("$");
if (rounds < 10) {
rs.append("0");
}
rs.append(rounds);
rs.append("$");
// 여기서 기존 암호문을 통해 추출된 salt와 평문을 조합하여, 새로운 암호문을 만듭니다.
// 기존 암호문의 salt를 이용했기 때문에, 평문이 동일하다면 암호문도 기존과 동일하게 만들어집니다.
encode_base64(saltb, saltb.length, rs);
encode_base64(hashed, bf_crypt_ciphertext.length * 4 - 1, rs);
return rs.toString();
}
6-3을 통해 만들어진 암호문은, 6-2의 {구현체}.checkpw(String, String) 에서 기존 암호문과 동일 여부를 판별하게 된다.
public static boolean checkpw(String plaintext, String hashed) {
// hashpw(plaintext, hashed)는 6-3을 통해 만들어진다.
return equalsNoEarlyReturn(hashed, hashpw(plaintext, hashed));
}
PasswordEncoder 는 적응형 단방향 암호화 라고 이전 포스팅 (Spring Security - PasswordEncoder (1 / 2))에서 소개했다.
간략하게 다시 소개하자면 동일한 평문일지라도 salt에 의해 매번 다른 암호문이 생성되는 것이다.
그래서 '매번 다르게 생성된다고 하는데, 이전에 만들어진 암호문과 어떻게 동일 여부를 판단하는걸까?'에 대한 의문이 들었다.
패스워드 생성 시 : salt는 매번 새로운 값이기 때문에, 매번 새로운 암호문이 만들어짐.
비밀번호 동일 비교 시 : 기존 암호문의 salt를 추출하기 때문에, (평문이 동일하다면) 평문 + 추출된 salt -> 기존 암호문이 되어 비교가 가능해진다.
Reference :
Spring Security : https://spring.io/projects/spring-security
Spring Security : https://mangkyu.tistory.com/76
Password Encoder : https://docs.spring.io/spring-security/reference/features/authentication/password-storage.html
salt : https://ko.wikipedia.org/wiki/%EC%86%94%ED%8A%B8_(%EC%95%94%ED%98%B8%ED%95%99)
Bounce Castle : https://m.blog.naver.com/PostView.naver?isHttpsRedirect=true&blogId=milkoon_yes&logNo=220625906633
FIPS 의미 : https://en.wikipedia.org/wiki/Federal_Information_Processing_Standards