본문 바로가기
Spring/Spring Security

PasswordEncoder: There is no PasswordEncoder mapped for the id "null" 오류 원인 분석

by Soono991 2023. 10. 3.

Spring Security를 사용하여 인증을 시도할 경우 로그인 실패와 함께 아래와 같은 오류가 발생한 적이 있을 것입니다.

 

java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"
	at org.springframework.security.crypto.password.DelegatingPasswordEncoder$UnmappedIdPasswordEncoder.matches(DelegatingPasswordEncoder.java:289) ~[spring-security-crypto-6.1.3.jar:6.1.3]
	at org.springframework.security.crypto.password.DelegatingPasswordEncoder.matches(DelegatingPasswordEncoder.java:237) ~[spring-security-crypto-6.1.3.jar:6.1.3]
    ...

 

 

로그 내용을 확인해보면 null 인 id에 매핑할 PasswordEncoder가 없다는 뜻입니다.

사실 이 부분에 관련해서는 해결 방법이 많이 공유가 되어 있어서 오류 자체는 쉽게 해결하실 수 있을 것입니다.

해결 방법은 저장되어 있는 유저의 비밀번호에 접두사로 암호화 방식을 추가해 주면 됩니다.

 

// 해결 방법
"1234" -> "{noop}1234"
"1234" -> "{bcrypt}1234"

 

보통은 에러를 해결하는 것 까지만 알아보는 것 같은데, 저는 여기서 조금 더 나아가 왜 이런 오류가 발생했는지 확인해보려고 합니다.

 

 

DelegatingPasswordEncoder

원인은 DelegatingPasswordEncoder라는 객체에게 있습니다.

아까 알아봤던 에러도 이 객체에서 발생하는 것이었기 때문에 우리는 이 객체에 대해 자세하게 알 필요가 있습니다.

 

생성자 및 필드

 

DelegatingPasswordEncoder의 각 필드들에 대해서 알아보겠습니다.

 

DEFAULT_ID_PREFIX 접두사 기본 값 = {
DEFAULT_ID_SUFFIX 접미사 기본 값 = }
idPrefix 기본 접두사
idSuffix 기본 접미사
idForEncode 기본 암호화 방식
passwordEncoderForEncode 기본 PasswordEncoder
idToPasswordEncoder 등록된 PasswordEncoder 목록
defaultPasswordEncoderForMatches PasswordEncoder 기본 값

 

 

DelegatingPasswordEncoder에는 2개의 생성자가 있는데 각 필드와 함께 살펴보면 쉽게 이해할 수 있습니다.

 

기본 암호화 방식(idForEncode)와 기본 PasswordEncoder 목록(idToPasswordEncoder)을 전달하면 접두사, 접미사를 기본값 {, }로 설정합니다.

그리고 전달된 PasswordEncoder 목록에서 설정한 기본 암호화 방식에 해당하는 PasswordEncoder를 기본 PasswordEncoder로 설정합니다.

 

encode

 

 

DelegatingPasswordEncoder로 비밀번호 암호화를 할 경우 기본 접두사, 접미사, 암호화 방식, PasswordEncoder를 사용하여 비밀번호 암호화를 진행합니다.

 

matches & extractId

 

matches에서는 암호화된 비밀번호를 파싱하여 PasswordEncoder를 찾은 후 전달된 평문 비밀번호를 암호화하여 비교합니다.

이때 암호화된 비밀번호를 파싱해야 하는데 extractId()를 보면 기본 접두사, 접미사를 사용하여 암호화 방식을 추출하는 것을 확인할 수 있습니다.

추출한 암호화 방식으로 PasswordEncoder 목록에서 PasswordEncoder를 찾아내는데 만약 전달된 암호화 방식에 해당하는 PasswordEncoder가 없을 경우 defaultPasswordEncoderForMatches에 할당된 PasswordEncoder의 matches를 호출합니다.

 

위에서 확인했듯이 defaultPasswordEncoderForMatches에는 UnmappedIdPasswordEncoder 객체가 할당되어 있는 이는 DelegatingPasswordEncoder의 내부 클래스입니다.

 

UnmappedIdPasswordEncoder는 예외를 던지도록 되어 있는데, 이 예외가 바로 앞서 우리가 만났던 예외입니다.

 

평문 비밀번호인 "1234"의 경우 암호화 방식을 추출하면 null이 반환되기 때문에 예외 메시지가 아래와 같이 null인 id에 해당하는 PasswordEncoder가 없다고 출력되는 것입니다.

There is no PasswordEncoder mapped for the id "null"

 

그렇다면 이제 Security 설정과 함께 DelegatingPasswordEncoder에서 암호화 방식들을 설정하는 부분을 확인해 보겠습니다.

 

 

Security Config

 

    @Bean
    PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

 

Security 설정은 다음과 같이 PasswordEncdoer를 빈으로 등록해 주 변 되는데, 이때 PasswordEncoderFactories.createDelegatingPasswordEncoder() 메서드를 사용하여 PasswordEncoder를 등록합니다.

 

PasswordEncoderFactories.createDelegatingPasswordEncoder()

 

createDelegatingPasswordEncoder()에서 각 PasswordEncoder 구현체를 생성하고 암호화 방식을 설정하여 DelegatingPasswordEncoder 객체에게 전달하고 있습니다.

이때 여기서 BCryptPasswordEncoder가 기본 PasswordEncoder로 설정되는 것을 확인할 수 있습니다.

 

Custom PasswordEncoder

여기까지 확인하고 끝낼 수도 있지만 저는 여기서 한 가지 더 생각해 보았습니다.

그렇다면 평문 비밀번호의 암호화 방식이 null이니까 이 부분도 직접 등록해 주면 되지 않을까?

 

그래서 PasswordEncoderFactories.createDelegatingPasswordEncoder()를 직접 사용하지 않고 내부 구현을 응용하여 직접 PasswordEncoder를 등록해 보기로 했습니다.

 

 

다른 암호화 방식은 지금은 사용하지 않기 때문에 제거하고 확인하고 싶은 암호화 방식들만 적용해 보았습니다.

 

 

의도한 대로 암호화 방식이 null인 경우 NoOpPasswordEncoder가 동작하는 것을 확인할 수 있습니다.

 

굳이 DelegatingPasswordEncoder가 필요한가???

이제 만족했다 싶어 끝내려던 찰나, DelegatingPasswordEncoder에 있던 수많은 암호화 방식들을 우리가 전부 사용하지는 않는데 이렇게 전부 등록해 주는 것이 과연 효율적인가? 하는 의문이 들었습니다.

 

사실 비밀번호에 암호화 방식을 지정하여 저장한 것은 DelegatingPasswordEncoder에 여러 암호화 방식이 있기 때문에 그에 맞는 PasswordEncoder를 가져와 암호화를 하려고 하는 것인데, 하나의 프로젝트에서 이렇게 여러 암호화 방식을 사용할 일이 있을까요?

 

그래서 DelegatingPasswordEncoder를 사용하기보다는 사용할 PasswordEncoder의 구현체를 직접 등록하면 되지 않을까?라는 생각을 했습니다.

 

// NoOpPasswordEncoder는 암호화를 하지 않기 때문에 프로덕션에서는 사용하면 안됨.
@Bean
PasswordEncoder passwordEncoder() {
    return NoOpPasswordEncoder.getInstance();
}

// 암호화
// "password" -> "password"

@Bean
PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

// 암호화
// "password" -> "$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG"

 

이렇게 할 경우 장점은 어떤 PasswordEncoder를 사용하는지 명확해진다는 것이고, 더 이상 비밀번호를 저장할 때 접두사, 접미사, 암호화 방식을 작성하지 않아도 된다는 것입니다.

 

이와 관련해서는 스택오버플로우에서도 비슷한 내용이 있어 같이 참고하면 좋을 것 같습니다.

 

 

How override the default BCryptPasswordEncoder created through PasswordEncoderFactories?

I know that in Spring Security would arise the following: There was an unexpected error (type=Internal Server Error, status=500). There is no PasswordEncoder mapped for the id "null" java...

stackoverflow.com

 

PasswordEncoderFactories.createDelegatingPasswordEncoder() Deprecation?????

후.. 이제 진짜 끝내려고 했는데, PasswordEncoderFactories.createDelegatingPasswordEncoder() 메서드를 보니 Deprecation 되어 있어 왜 갑자기?라는 생각에 더 찾아보게 되었습니다.

 

처음에는 제가 생각했던 이유처럼 하나의 애플리케이션에서 여러 암호화 방식을 지원할 경우가 많지 않기 때문에 그런가? 싶었는데 그 이유는 아니고 보안상의 이유가 있었습니다.

 

인 메모리 유저를 생성할 때 아래와 같이 생성하게 되는데 이때 평문 비밀번호가 노출된다는 것이 보안 취약점의 이유가 되어 deprecation 된 것입니다.

 

UserDetails user = User.withDefaultPasswordEncoder()
     .username("user")
     .password("password")
     .roles("USER")
     .build();
 // outputs {bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
 System.out.println(user.getPassword());
 
PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
 // outputs {bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
 // remember the password that is printed out and use in the next step
 System.out.println(encoder.encode("password"));

 

유저를 생성하고 나서 비밀번호를 출력하고 나면 암호화된 비밀번호가 잘 출력이 되지만 소스 코드 상에서는 여전히 평문 비밀번호가 노출되는 것이 이유입니다.

 

PasswordEncoderFactories.createDelegatingPasswordEncoder() 또한 마찬가지입니다. 암호화할 비밀번호를 전달할 때 평문 비밀번호가 코드 상에 그대로 노출됩니다.

 

바로 이런 이유 때문에 deprecation 되었고 프로덕션용으로는 적합하지 않지만 개발용으로는 열어두기 위해 제거하지는 않는다고 합니다.

 

 

관련해서 공식 문서 링크를 같이 참고하면 좋을 것 같습니다.

 

 

User (spring-security-docs-manual 5.3.0.RELEASE API)

Returns true if the supplied object is a User instance with the same username value. In other words, the objects are equal if they have the same username, representing the same principal.

docs.spring.io

 

댓글