Spring Security - PasswordEncoder (2 / 2)

조갱·2022년 5월 1일
3

Spring Security

목록 보기
3/3
post-thumbnail

Spring Security와 Attribute Converter (이곳을 클릭) 이슈에 대한 글을 적다가, 글의 내용이 너무 방대해져 포스팅을 나누어서 하려고 한다.

Spring Security 에 대한 전반적인 내용은 (이곳을 클릭)에서 다루고있다.

이번 포스팅은 Spring Security 에서 제공하는 PasswordEncoder Interface 에 대한 포스팅이다.

PasswordEncoder 구현체에 대해 다루며 총 2부에 걸쳐 내용을 다루고자 한다.
1부 : PasswordEncoder에 대한 전반적인 설명, 알고가면 좋은 지식
2부 : PasswordEncoder 구현체에 대한 세부 내용

0. 시작하기 전에

암호를 확인하는데 걸리는 시간은 약 1초정도 소요되도록 설정하는 것을 권장합니다. 총 2가지의 이유가 있는데,
첫 번째는, 적응형 단방향 암호화는 리소스를 많이 사용합니다. (CPU, 메모리 등)
두 번째는, 공격자의 무차별적인 공격 (예를 들면 브루트포스 등)으로부터 비교적 안전합니다.
* 적응형 단방향 암호화 : 암호를 매번 다르게 생성하는 것 (Salt를 사용하여 가능)

1. BCryptPasswordEncoder

사용하는 알고리즘 : 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))

2. Argon2PasswordEncoder

사용하는 알고리즘 : 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))

3. Pbkdf2PasswordEncoder

사용하는 알고리즘 : 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))

4. SCryptPasswordEncoder

사용하는 알고리즘 : 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))

5. 기타 PasswordEncoder

이전 포스팅 (PasswordEncoder에 대한 전반적인 설명, 알고가면 좋은 지식) 에서 소개했지만, PasswordEncoder 는 총 14개의 구현체 중, Deprecated 5개와 나머지 9개가 존재한다. 나머지 9개 중에서도 위에서 소개한 4개를 제외한 나머지 5개는 왜 소개를 하지 않았을까?

Spring Security 5.6.2 공식 문서에 따르면, 나머지 5개의 경우 '안전하지 않기 때문에 더 이상 사용되지 않지만, 기존 레거시 시스템의 마이그레이션을 고려하여 제거하지는 않는다.'라고 한다.

그러니 위 4개를 제외한 나머지 PasswordEncoder는 사용을 지양하는 것이 좋겠다.

6. 인증 방법

그렇다면, 평문암호문의 일치 여부를 어떻게 판단할 수 있을까?

6-1. PasswordEncoder().matches(CharSequence, String)

첫 번째로, PasswordEncoder 클래스를 상속받은 LazyPasswordEncoder 클래스의 matchs() 메소드를 통해 각 PasswordEncoder 구현체평문암호문을 전달한다.

6-2. {구현체}.matches(CharSequence, String)

각 구현체라고 하면, 위에서 설명한 BCryptPasswordEncoder, Argon2PasswordEncoder, Pbkdf2PasswordEncoder 등이 있다.

  • 사용자가 입력한 평문 패스워드가 null인지 검사
  • 기존 인코딩된 패스워드가 null or empty인지 검사
  • 기존 인코딩된 패스워드가 {각 구현체}에 맞는 형식인지 '정규식'으로 검사
    // BCrypt 정규식
    private Pattern BCRYPT_PATTERN = Pattern.compile("\\A\\$2(a|y|b)?\\$(\\d\\d)\\$[./0-9A-Za-z]{53}");

6-3. {구현체}.checkpw(String, String)

여기에서 좀 이상한 점이 있다.

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

6-3-1. {구현체}.hashpw(String, String)


6-3에서 hashpw(plaintext, hashed)에 전달한 두번째 인자 hashed는 이곳에서 salt 로서 사용되고 있다.

6-3-2. {구현체}.hashpw(byte[], String)

내용이 다소 길어 코드 본문을 첨부하며, 기존 암호문에서 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-4. {구현체}.checkpw(String, String)

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));
	}

6-5. 정리

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

profile
A fast learner.

1개의 댓글

comment-user-thumbnail
2022년 5월 10일

아하 평문이 있어야만 salt를 추출할 수 있는거였군요~
저도 어떻게 동일여부를 판단하는지 궁금했는데 해결이 됐습니다

답글 달기