본문 바로가기

Spring Security

[오류 해결] 스프링 시큐리티 로그인 실패 시 무한 재로그인 문제 해결

 

스프링 시큐리티 프레임워크를 사용하는 방법은 프로젝트마다 천차만별일 것이라고 생각이 된다. 그래서 개인적으로 스프링 시큐리티에서 문제가 발생했을 때, 디버깅하는 것이 어렵다. 구글링을 했을 때 어떤 문제 상황이 일치하면서 시큐리티 설정 세팅도 유사한 사례를 찾기가 어렵기 때문이다.

 

이번에 스프링 시큐리티를 사용하는 인증용 모듈을 리팩토링했는데, 리팩토링 후 로그인 실패 로직에서 문제가 생겼다.

원래 로그인 실패 시 로그인 실패 사유를 응답하는 방식으로 작동했던 코드가, 로그인 실패 시 계속 로그인을 시도하며 무한 반복하여 결국 timeout이 되는 상황이 발생하였다.

 

디버그 모드를 통해 디버깅을 했고, 밝혀낸 원인은 ProviderManager에서 찾을 수 있었다.

public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {

(생략)

@Override
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
		Class<? extends Authentication> toTest = authentication.getClass();
		AuthenticationException lastException = null;
		AuthenticationException parentException = null;
		Authentication result = null;
		Authentication parentResult = null;
		int currentPosition = 0;
		int size = this.providers.size();
		for (AuthenticationProvider provider : getProviders()) {
			if (!provider.supports(toTest)) {
				continue;
			}
			if (logger.isTraceEnabled()) {
				logger.trace(LogMessage.format("Authenticating request with %s (%d/%d)",
						provider.getClass().getSimpleName(), ++currentPosition, size));
			}
			try {
				result = provider.authenticate(authentication);
				if (result != null) {
					copyDetails(authentication, result);
					break;
				}
			}
			catch (AccountStatusException | InternalAuthenticationServiceException ex) {
				prepareException(ex, authentication);
				// SEC-546: Avoid polling additional providers if auth failure is due to
				// invalid account status
				throw ex;
			}
			catch (AuthenticationException ex) {
				lastException = ex;
			}
		}
		if (result == null && this.parent != null) {
			// Allow the parent to try.
			try {
				parentResult = this.parent.authenticate(authentication);
				result = parentResult;
			}
			catch (ProviderNotFoundException ex) {
				// ignore as we will throw below if no other exception occurred prior to
				// calling parent and the parent
				// may throw ProviderNotFound even though a provider in the child already
				// handled the request
			}
			catch (AuthenticationException ex) {
				parentException = ex;
				lastException = ex;
			}
		}

 

위 코드에서 핵심적으로 살펴봐야 할 부분과 함께 설명을 하면 다음과 같다.

 

1. 첫 번째 try 문

			try {
				result = provider.authenticate(authentication);
				if (result != null) {
					copyDetails(authentication, result);
					break;
				}
			}

먼저 try 문에서 result = provider.authenticate(authentication)을 통해 인증을 진행한다.

 

로그인을 처리하는 Custom Provider에서는 인증 실패 시 AuthenticationException이 발생하도록 구현하였다. 현재 로그인 실패한 상황에서 디버깅하는 중이므로, try 문에서 시도한 인증 절차가 실패하여, AuthenticationExeption이 발생한다.

 

2. catch 문

			catch (AuthenticationException ex) {
				lastException = ex;
			}

 

이때 try 문을 빠져나와 위의 catch (AuthenticationException ex) 문을 타게 된다.

 

3. if 문

		if (result == null && this.parent != null) {
			// Allow the parent to try.
			try {
				parentResult = this.parent.authenticate(authentication);
				result = parentResult;
			}

 

그후 if (result == null && this.parent ≠ null) 조건문이 나온다. result는 메서드 내에서 null로 초기화된 이후에 첫 번째 try 문 내에서 값을 할당할 때 예외가 발생했으므로 계속 null이다. 따라서 첫 번째 조건(result == null)을 만족한다. 

 

그리고 디버거를 통해 확인한 결과 두 번째 조건(this.parent != null)도 참이 된다. 이로써 조건문은 참이 되어 if 문 내부가 실행된다.

try 문 내부에서 parentResult = this.parent.authenticate(authentication)을 실행할 때 다시 Custom Provider의 authenticate가 실행된다. (ProviderManager의 parent가 무엇인가 디버거를 통해 보면 ProviderManager라고 나온다. 즉 자기 자신이다. )

지금은 로그인에 실패한 상황이므로, 인증에 실패하여 AuthenticationException을 던진다. catch(AuthenticationException ex)을 탄다. 조건문이 나온다. 조건문은 참이 되어 parentResult = this.parent.authenticate(authentication)이 실행된다. 이러한 절차로 무한 반복 현상이 일어난다. 실패 → 시도 → 실패 → 시도 → 실패 ….

 

여기까지 파악을 하고, 어떻게 해결해야 할지 구글링을 하다가 stack overflow에서 검색을 했다.

 

검색어 : spring security provider manager infinite repeat authenticate

 

그렇게 찾은 아래 글을 보고 도움을 받아 문제를 해결했다.

https://stackoverflow.com/questions/78292899/what-is-spring-securitys-default-parent-providermanager

이분의 상황이 완벽하게 나랑 일치했고, AuthenticationManager를 설정하는 부분도 일치했기에, 나도 이 글에서 제시된 해결책을 그대로 적용하여 문제를 해결할 수 있었다.

 

@Bean
fun authenticationManager(
    http: HttpSecurity,
    loginAuthenticationProvider: LoginAuthenticationProvider,
    bearerTokenAuthenticationProvider: BearerTokenAuthenticationProvider
): AuthenticationManager {
    val authenticationManagerBuilder =
        http.getSharedObject(AuthenticationManagerBuilder::class.java)
    authenticationManagerBuilder.authenticationProvider(loginAuthenticationProvider).authenticationProvider(bearerTokenAuthenticationProvider)
    authenticationManagerBuilder.parentAuthenticationManager(null)
    return authenticationManagerBuilder.build()
}

 

해결책은 authenticationManagerBuilder를 통해 인증 매니저를 만들 때  authenticationManagerBuilder.parentAuthenticationManager(null) 한 줄을 추가하여, 부모 인증 매니저를 null로 설정하는 것이다.

 


이전(리팩토링 전)에는 문제가 발생하지 않았는데 왜 리팩토링 후 문제가 발생했는가?

리팩토링 전의 AuthenticationManager 설정 코드는 다음과 같다.

@Bean
    fun authenticationManager(
        loginAuthenticationProvider: LoginAuthenticationProvider,
        bearerTokenAuthenticationProvider: BearerTokenAuthenticationProvider,
    ): AuthenticationManager {
        val providers = listOf(
            loginAuthenticationProvider,
            bearerTokenAuthenticationProvider,
            )
        return ProviderManager(providers)
    }

 

리팩토링 전의 코드는 AuthenticationManager를 빈으로 등록하는 설정에서 아예 AuthenticationManager의 구현체인 ProviderManager의 객체를 생성하여 빈으로 등록을 하고 있다.

 

바뀐 코드에서는 AuthenticationManagerBuilder 를 통해 AuthenticationManager의 구현체를 빈으로 등록하고 있다. 이 차이에서 발생한 것이다.

ProviderManager의 생성자를 보면 다음과 같다. (필요한 부분만 발췌)

	/**
	 * Construct a {@link ProviderManager} using the given {@link AuthenticationProvider}s
	 * @param providers the {@link AuthenticationProvider}s to use
	 */
	public ProviderManager(List<AuthenticationProvider> providers) {
		this(providers, null);
	}
	
		public ProviderManager(List<AuthenticationProvider> providers, AuthenticationManager parent) {
		Assert.notNull(providers, "providers list cannot be null");
		this.providers = providers;
		this.parent = parent;
		checkState();
	}

리팩토링 전의 코드는 위 코드의 위쪽의 생성자를 사용하며(ProviderManager(providers)) , parent에 null이라는 값이 들어간다.

 

따라서 위의 무한 루프의 원인이었던 if (result == null && this.parent ≠ null) 조건문이 false가 되며 무한 루프도 일어나지 않는다.

 

 

그러면 AuthenticationManagerBuilder를 사용하여 만든 AuthenticationManager의 구현체는 어떨까?

내부 코드를 찾아봤다.

@Override
	protected ProviderManager performBuild() throws Exception {
		if (!isConfigured()) {
			this.logger.debug("No authenticationProviders and no parentAuthenticationManager defined. Returning null.");
			return null;
		}
		ProviderManager providerManager = new ProviderManager(this.authenticationProviders,
				this.parentAuthenticationManager);
		if (this.eraseCredentials != null) {
			providerManager.setEraseCredentialsAfterAuthentication(this.eraseCredentials);
		}
		if (this.eventPublisher != null) {
			providerManager.setAuthenticationEventPublisher(this.eventPublisher);
		}
		providerManager = postProcess(providerManager);
		return providerManager;
	}

 

결과적으로

ProviderManager providerManager = new ProviderManager(this.authenticationProviders, this.parentAuthenticationManager);

이 부분의 코드로 providerManager가 만들어진다.

 

이때 AuthenticationManagerBuilder 에서는 parentAuthenticationManager 의 초기화가 이루어 지지 않는다. 다음과 같이 parent를 초기화해주는 메서드만 존재할 뿐이다.

	public AuthenticationManagerBuilder parentAuthenticationManager(AuthenticationManager authenticationManager) {
		if (authenticationManager instanceof ProviderManager) {
			eraseCredentials(((ProviderManager) authenticationManager).isEraseCredentialsAfterAuthentication());
		}
		this.parentAuthenticationManager = authenticationManager;
		return this;
	}

 

즉 parent는 스프링 시큐리티의 자동 설정에 의해서 어딘가에서 초기화된다. 어디에서 초기화되는지 찾아봤다. HttpSecurityConfiguration 클래스 안에서 이루어졌다.

 

@Configuration(proxyBeanMethods = false)
class HttpSecurityConfiguration {

(생략)

	@Bean(HTTPSECURITY_BEAN_NAME)
	@Scope("prototype")
	HttpSecurity httpSecurity() throws Exception {
		LazyPasswordEncoder passwordEncoder = new LazyPasswordEncoder(this.context);
		AuthenticationManagerBuilder authenticationBuilder = new DefaultPasswordEncoderAuthenticationManagerBuilder(
				this.objectPostProcessor, passwordEncoder);
		authenticationBuilder.parentAuthenticationManager(authenticationManager());

 

그래서 parentAuthenticationManager는 뭘까? docs에 따르면 다음과 같다.

ProviderManager는 또한 인증을 수행할 수 있는 AuthenticationProvider가 없는 경우에 참조되는 선택적 parent AuthenticationManager를 구성할 수 있습니다. 부모는 모든 유형의 AuthenticationManager가 될 수 있지만 종종 ProviderManager의 인스턴스입니다. 사실, 여러 ProviderManager 인스턴스가 동일한 parent AuthenticationManager를 공유할 수 있습니다. 이는 공통된 인증(공유 parent AuthenticationManager)이 있지만 다른 인증 메커니즘(다른 ProviderManager 인스턴스)이 있는 여러 SecurityFilterChain 인스턴스가 있는 시나리오에서 다소 일반적입니다.

https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html