ProviderManager를 뜯으며 Spring Security 인증 이해하기

이창근·2024년 7월 10일
0

Spring공부

목록 보기
9/9

들어가며

  • Spring Security 인증 절차의 중추(내 생각), AuthenticationManager는 여러 AuthenticationProvider를 가지고 인증을 시도한다.
  • 이번에는 AuthenticationManager의 가장 자주쓰이는 구현체인 ProviderManager의 핵심 로직, authenticate() 메서드를 살펴보며 그 인증 절차를 이해해보려고 한다.
  • Spring Security 6.3.1을 기준으로 작성되었다.

ProviderManager.authenticate() 전체 코드

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();
        Iterator var9 = this.getProviders().iterator();

        while(var9.hasNext()) {
            AuthenticationProvider provider = (AuthenticationProvider)var9.next();
            if (provider.supports(toTest)) {
                if (logger.isTraceEnabled()) {
                    Log var10000 = logger;
                    String var10002 = provider.getClass().getSimpleName();
                    ++currentPosition;
                    var10000.trace(LogMessage.format("Authenticating request with %s (%d/%d)", var10002, currentPosition, size));
                }

                try {
                    result = provider.authenticate(authentication);
                    if (result != null) {
                        this.copyDetails(authentication, result);
                        break;
                    }
                } catch (InternalAuthenticationServiceException | AccountStatusException var14) {
                    this.prepareException(var14, authentication);
                    throw var14;
                } catch (AuthenticationException var15) {
                    AuthenticationException ex = var15;
                    lastException = ex;
                }
            }
        }

        if (result == null && this.parent != null) {
            try {
                parentResult = this.parent.authenticate(authentication);
                result = parentResult;
            } catch (ProviderNotFoundException var12) {
            } catch (AuthenticationException var13) {
                parentException = var13;
                lastException = var13;
            }
        }

        if (result != null) {
            if (this.eraseCredentialsAfterAuthentication && result instanceof CredentialsContainer) {
                ((CredentialsContainer)result).eraseCredentials();
            }

            if (parentResult == null) {
                this.eventPublisher.publishAuthenticationSuccess(result);
            }

            return result;
        } else {
            if (lastException == null) {
                lastException = new ProviderNotFoundException(this.messages.getMessage("ProviderManager.providerNotFound", new Object[]{toTest.getName()}, "No AuthenticationProvider found for {0}"));
            }

            if (parentException == null) {
                this.prepareException((AuthenticationException)lastException, authentication);
            }

            throw lastException;
        }
    }

그렇다. 굉장히 길다. 하지만 다 이해할 필요는 없다. 일단은 중요한 부분들만 살펴보며 기본 동작을 알아보자.

0. Authentication 객체는 인증요청 정보를 담고 있다.

public Authentication authenticate(Authentication authentication)
  • 인증절차는 사용자가 넘긴 Authentication 객체를 통해 진행되며, 이곳저곳에서 참조되고 수정될 예정이다.
  • Authentication = 아이디/패스워드 정도로 기억해두고 보자.

1. ProviderManager는 여러 AuthenticationProvider를 가진다.

Iterator var9 = this.getProviders().iterator();
while(var9.hasNext()) {
	...
}

(앞으로 AuthenticationProvider를 Provider와 혼용해서 적겠다.)

  • Provider들을 가져와서 각 provider들에 대해 반복문을 돌린다.
  • 한 ProviderManager는 여러 Provider들을 가질 수 있고, 이를 통해 JwtAuthenticationProvider, DaoAuthenticationProvider 등 원하는 Provider를 끼워넣을 수 있다. (난 스프링을 끼워 넣는 거라고 생각한다...)

2. 각 AuthenticationProvider는 실제로 인증을 수행한다. (중요)

try {
	result = provider.authenticate(authentication);
	if (result != null) {
		this.copyDetails(authentication, result);
		break;
	}
} catch (InternalAuthenticationServiceException | AccountStatusException var14) {
	this.prepareException(var14, authentication);
	throw var14;
} catch (AuthenticationException var15) {
	AuthenticationException ex = var15;
	lastException = ex;
}
  • 딱봐도 중요해보이는 provider.authenticate() 메서드가 등장했다.
  • provider는 Authentication 객체를 가지고 실제로 인증을 시도하며, Authentication 객체, result를 반환한다.
  • 해당 Provider가 Authentication을 핸들링할 수 있다면 인증을 시도한다.
    • 시도하여 인증에 성공했다면 result 객체를 세팅하여 반환한다.
    • 시도하여 인증에 실패했다면 AuthenticationException을 터트린다.
  • 해당 Provider가 Authentication을 핸들링할 수 없다면 다음 Provider에게 기회가 넘어간다.
  • AuthenticationException 이 발생했을 경우, 바로 터트리지는 않고, 다른 Provider에게 기회를 주기위해 일단 저장해둔다.
  • 결론적으로 시나리오는 다음 3개와 같다.
    • 핸들링 성공, 인증 성공 -> Provider 찾아보기 끝내기, Authentication 세팅
    • 핸들링 성공, 인증 실패 -> 일단 예외를 저장해두고 다음 Provider에게 기회주기
    • 핸들링 실패 -> 다음 Provider에게 기회주기

3. 결과 처리

if (result != null) {
	if (this.eraseCredentialsAfterAuthentication && result instanceof CredentialsContainer) {
        ((CredentialsContainer)result).eraseCredentials();
	}
	if (parentResult == null) {
    	this.eventPublisher.publishAuthenticationSuccess(result);
	}
	return result;
} else {
	if (lastException == null) {
		lastException = new ProviderNotFoundException(this.messages.getMessage("ProviderManager.providerNotFound", new Object[]{toTest.getName()}, "No AuthenticationProvider found for {0}"));
	}
	if (parentException == null) {
        this.prepareException((AuthenticationException)lastException, authentication);
	}
	throw lastException;
}
  • 모든 Provider를 다 시도해본 후 나오는 코드이다.
  • result가 null이 아닐 경우, 즉 인증에 성공했을 경우
    • Authentication 객체의 Credential을 지운다. -> HttpSession이 길게 유지되는 것을 방지하기 위해(보안을 위해) 작동된다.
    • 인증 성공을 알린다. (SecurityContext에 담기는 과정이다. 다음에 설명이 나온다.)
  • result가 null일 경우, 즉 인증에 실패했을 경우
    • 대충, 마지막 AuthenticatoinException을 드디어 던지게 된다.

그 후는 어떻게 진행되는가?

이 다음의 인증 절차는 위 코드에서는 나타나지 않지만, 간단하게 요약하면 이렇다.

  • 인증이 완료된 Authentication을 SecurityContext에 담는다.
  • SecurityContext는 SecurityContextHolder에 담긴다.
  • 결론적으로 SecurityContextHolder를 통해 인증된 정보에 접근할 수 있게 된다.
  • 이 정보는 후에 인가를 위해 사용된다.

설명을 뛰어넘은 코드 1

if (provider.supports(toTest)) {
	if (logger.isTraceEnabled()) {
		Log var10000 = logger;
		String var10002 = provider.getClass().getSimpleName();
		++currentPosition;
        var10000.trace(LogMessage.format("Authenticating request with %s (%d/%d)", var10002, currentPosition, size));
	}
}
  • 로깅을 지원하기 위한 코드다.
  • 설정파일에 logging.level.org.springframework.security=TRACE 설정을 통해 로그를 볼 수 있다.

설명을 뛰어넘은 코드 2 (중요)

if (result == null && this.parent != null) {
	try {
		parentResult = this.parent.authenticate(authentication);
		result = parentResult;
	} catch (ProviderNotFoundException var12) {
	} catch (AuthenticationException var13) {
		parentException = var13;
		lastException = var13;
	}
}
  • ProviderManager는 다른 AuthenticationManager를 부모로 가질 수 있다.
  • ProviderManager에 Authentication을 핸들링할 수 있는 Provider가 없었을 경우, 이 Authentication을 가지고 부모를 참조하여 인증을 시도한다.
  • 또한 여러 ProviderManager가 한 부모를 가지기도 한다.
    • 이를 통해 ProviderManager간 중복되는 Provider는 부모에게 주고, 자신은 특화된 Provider만을 가질 수 있게 된다. (이 사실을 알게되고 난 온몸에 소름이 돋았다. 미친 설계)

마치며

어떤가... Spring Security, 그렇게 어렵진 않은 것 같기도 하다.
Spring Security는 어떻게 사용해야 하는지에 대한 정보는 많은데, 실제로 어떻게 동작하는지에 대한 정보가 상대적으로 부족한 것 같다. 그만큼 변화무쌍한 영역이고 어려운 영역이라는 생각이 들었다.
이럴 때는 역시 공식문서인 것 같다. ChatGPT와 Claude의 도움을 받아가며 공식문서를 열심히 읽다보면, 프레임워크 개발자는 천재라는 생각이 계속해서 들고, 좋은 설계에 대한 전반적인 시각도 생기는 것 같다.
다음에는 인가의 중요한 코드를 한번 뜯어보는 것도 나쁘지 않을 것 같다.

(모든 내용은 공식문서에서 따온 것이다.)

profile
나중에 또 모를 것들 모음

1개의 댓글

comment-user-thumbnail
2024년 10월 10일

이 부분 이해가 잘 안갔는데 좋은 글 감사합니다.

답글 달기