스프링 시큐리티 프레임워크를 사용하는 방법은 프로젝트마다 천차만별일 것이라고 생각이 된다. 그래서 개인적으로 스프링 시큐리티에서 문제가 발생했을 때, 디버깅하는 것이 어렵다. 구글링을 했을 때 어떤 문제 상황이 일치하면서 시큐리티 설정 세팅도 유사한 사례를 찾기가 어렵기 때문이다.
이번에 스프링 시큐리티를 사용하는 인증용 모듈을 리팩토링했는데, 리팩토링 후 로그인 실패 로직에서 문제가 생겼다.
원래 로그인 실패 시 로그인 실패 사유를 응답하는 방식으로 작동했던 코드가, 로그인 실패 시 계속 로그인을 시도하며 무한 반복하여 결국 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
'Spring Security' 카테고리의 다른 글
스프링 시큐리티 의존성만 추가했을 때 동작하는 것들 (0) | 2024.04.24 |
---|