구현 주제 : Spring Security를 이용한 username password 인증 로직 구현
일주일 간의 username&password 인증 로직 구현 스프린트가 마무리되었다. 스프링 시큐리티 설정에서 상당히 많은 시간동안 삽질을 했는데, 삽질 내역은 다음과 같다.
CustomProvider 구현체 등록, 해야할까 안해도될까?
이건 준영님께서 의문점을 삼고 질문을 주셨던 내용이다. 내가 해당 인증 로직을 구현하면서 아주 많은 삽질을 했는데, 그로 인해서 가려졌던(?) 문제였다.
스프링 시큐리티에서는 UserDetailsService
나 PasswordEncoder
를 빈으로만 등록하면 이를 자동으로 구현체로써 취급한다. 그렇기 때문에 커스텀 AuthenticationProvider
를 따로 구현하지 않아도 된다. 하지만 나는 이걸 모르고 따로 구현했다. 즉, 쓸데없는 클래스를 만든 것이다.
그럼 대체 어떤 Provider를 사용하길래 따로 구현하지 않아도, 내가 빈으로 등록한 커스텀 UserDetailsService
나 PasswordEncoder
를 인식하고 정상적으로 동작을 하는걸까?
결론부터 말하자면, 구현체는 스프링 시큐리티가 제공하는 DaoAuthenticationProvider
를 사용한다. (준영님이 힌트 주심..! 아주 빠른 문제해결에 도움을 주셨다.) 해당 Provider
는 내가 등록한 UserDetailsService
와 PasswordEncoder
를 사용하여 접속한 유저의 비밀번호를 저장소에 저장된 유저의 비밀번호와 매칭해서 인증을 수행해준다.
그렇다면 어떻게 이런 동작이 가능했을까? 이를 알아보..기전에 너무 내용이 길기 때문에 요약을 먼저 올려두었다. 이를 확인한 후에 자세한 내용이 궁금하다면 아래의 내용을 훑어보면 좋을 것 같다. (프레임워크 자체가 너무 복잡하게 구성되어 있어서 잘 설명도 안되었을 것 같긴 하지만..ㅠㅠ)
요약
- HttpSecurity가 PasswordEncoder의 구현체를
AuthenticationManagerBuilder
에 넣어준다. HttpSecurity.authenticationManager()
→AuthenticationConfiguration.getAuthenticationManager()
호출AuthenticationManagerBuilder
를 빈에서 꺼내옴 →DefaultPasswordEncoderAuthenticationManagerBuilder
구현체 획득DefaultPasswordEncoderAuthenticationManagerBuilder
의 부모 추상 클래스인AbstractConfiguredSecurityBuilder
에GlobalConfigurerAdpater
를 모두 등록build
메서드 호출 →doBuild
메서드 호출 →AbstractConfiguredSecurityBuilder
의doBuild()
메서드가 수행 됨- 설정 정보인
Configurer
초기화doBuild()
내부의init()
- 설정 정보를 기반으로 설정 등록
doBuild()
내부의configure()
usernamePassword
인증 기반으로, 이 때InitializeUserDetailsManagerConfiguerer
에서AuthenticationProvider
구현체가 생성된다.UserDetailsService
구현체를 빈으로 등록한 경우DaoAuthenticationProvider
가 구현체로 생성되고, 구현한UserDetailsService
와 빈으로 등록한PasswordEncoder
가 Provider에 등록된다.
DefaultPasswordEncoderAuthenticationManagerBuilder
의 설정이 완료된 후,doBuild()
메서드 내부의performBuild()
에서 Build를 수행하고,ProviderManager
에configure()
에서 등록한AuthenticationProvider
를 등록하고, 해당ProviderManager
를 반환한다. 이를 Manager 구현체로 사용한다.
- 더더더 요약
- 빈으로 등록된
PasswordEncoder
나UserDetailsService
를 특정AuthenticationProvider
에 등록한 후 (해당 경우에는DaoAuthenticationProvider
)DefaultPasswordEncoderAuthenticationManagerBuilder
에 등록한다. - 설정이 완료된
DefaultPasswordEncoderAuthenticationManagerBuilder
의 정보를 이용하여ProviderManager
에 설정해주고, 이를 반환하고HttpSecurity
에 등록한다.
- 빈으로 등록된
더 자세한 내용은 아래에서 확인할 수 있다.
- 참고 : 이를 확인하기 위해서,
ProviderManager
의 생성자를 사용하는 클래스들을 모두 찾아 디버그 포인트를 찍었다.ProviderManager
의 생성자를 사용하는 클래스에서 위 구현체들을 등록해줄 것이라고 추측했다. Manager는 Provider를 관리하는 역할을 하기 때문이다.
HttpSecurityConfiguration
PasswordEncoder 및 아래에 설명할 클래스들의 시초가 되는 부분인 것으로 추측된다. 더 앞까지 파보다간 해당 글이 끝나지 못할 것 같아 여기부터 차근차근 알아보았다. 아래는 해당 클래스에서 가장 중요한 빈 설정 메서드이다.
@Bean(HTTPSECURITY_BEAN_NAME)
@Scope("prototype")
HttpSecurity httpSecurity() throws Exception {
LazyPasswordEncoder passwordEncoder = new LazyPasswordEncoder(this.context);
AuthenticationManagerBuilder authenticationBuilder = new DefaultPasswordEncoderAuthenticationManagerBuilder(
this.objectPostProcessor, passwordEncoder);
// 부모 AuthenticationManager를 빌더에 등록
authenticationBuilder.parentAuthenticationManager(authenticationManager());
// EventPublisher 관련 AuthenticationManager를 빌더에 등록
authenticationBuilder.authenticationEventPublisher(getAuthenticationEventPublisher());
//... 긴 코드
}
LazyPasswordEncoder
getPasswordEncoder
메서드를 통해**PasswordEncoder
를 사용할 때**ApplicationContext
에 빈으로 등록된PasswordEncoder
를 찾아서 반환한다. (나는Bcrypt
를 사용했으므로 해당 구현체가 할당되어 있다.)
DefaultPasswordEncoderAuthenticationManagerBuilder
Bcrypt
인코더를 가진AuthenticationManagerBuilder
구현체가 생성된다.
parentAuthenticationManager
의authenticationManager()
가 호출된다- 이는
AuthenticationConfiguration
의getAuthenticationManager()
를 호출한다
getAuthenticationManager()
@Bean
public static InitializeAuthenticationProviderBeanManagerConfigurer initializeAuthenticationProviderBeanManagerConfigurer(
ApplicationContext context) {
return new InitializeAuthenticationProviderBeanManagerConfigurer(context);
}
public AuthenticationManager getAuthenticationManager() throws Exception {
if (this.authenticationManagerInitialized) {
return this.authenticationManager;
}
AuthenticationManagerBuilder authBuilder = this.applicationContext.getBean(AuthenticationManagerBuilder.class);
if (this.buildingAuthenticationManager.getAndSet(true)) {
return new AuthenticationManagerDelegator(authBuilder);
}
for (GlobalAuthenticationConfigurerAdapter config : this.globalAuthConfigurers) {
authBuilder.apply(config);
}
this.authenticationManager = authBuilder.build();
if (this.authenticationManager == null) {
this.authenticationManager = getAuthenticationManagerBean();
}
this.authenticationManagerInitialized = true;
return this.authenticationManager;
}
- 해당 메서드 내부에서, 빈으로 등록된
AuthenticationManagerBuilder
를 찾는다.DefaultPasswordEncoderAuthenticationManagerBuilder
를 조회해온다.
- 그리고 미리 빈으로 등록해둔
GlobalAuthenticationConfigurerAdapter
를 순회하며AbstractConfiguredSecurityBuilder
에 모두 등록한다.AbstractConfiguredSecurityBuilder
는AuthenticationManagerBuilder
의 구현체인DefaultPasswordEncoderAuthenticationManagerBuilder
의 부모 추상클래스다. 해당 추상 클래스에 등록된다.
- 그 후,
AuthenticationManagerBuilder
를AuthenticationManager
로 만들기 위해AbstractSecurityBuilder
의build()
메서드를 호출한다. 해당 메서드는 내부에서doBuild
를 호출한다. (이 때 구현체는AbstractConfiguredSecurityBuilder
이므로 구현체의 메서드가 호출된다.)
- 구현체의 관계는 아래와 같다. 너무 복잡함.. 앞으로는 DefaultPasswordEncoderAuthenticationManagerBuilder라고 이해하면 될 것 같다. 여기에 재정의되지 않은 메서드는 부모 추상클래스의 메서드를 사용한다.
doBuild() & AbstractConfiguredSecurityBuilder
public abstract class AbstractConfiguredSecurityBuilder<O, B extends SecurityBuilder<O>>
extends AbstractSecurityBuilder<O> {
@Override
protected final O doBuild() throws Exception {
synchronized (this.configurers) {
this.buildState = BuildState.INITIALIZING;
beforeInit();
init();
this.buildState = BuildState.CONFIGURING;
beforeConfigure();
configure();
this.buildState = BuildState.BUILDING;
O result = performBuild();
this.buildState = BuildState.BUILT;
return result;
}
}
}
내가 찾은 두번째 클래스다. 위 클래스는 SecurityConfigurer를 적용할 수 있는 기본적인 SecurityBuilder라고 한다.
- DelegatingFilterProxy를 빌드하는 클래스라고 설명되어 있다.
- 해당 클래스의 구현체로써
DefaultPasswordEncoderAuthenticationManagerBuilder
가 사용된다.- 위에서 잠깐 나왔지만, 이는
HttpSecurity
에서 설정해준AuthenticationManagerBuilder
의 구현체다.
- 위에서 잠깐 나왔지만, 이는
해당 클래스는 doBuild
메서드를 이용해 시큐리티 설정을 빌드해준다. SecurityConfigurer는 기본적으로 세개가 파라미터로 전달되고, 다음과 같은 순서로 실행이 된다.
- 이는
getAuthenticationManager()
메서드에서 등록해준 configurer들이다. beforeInit()
,beforeConfigure()
를 추상 메서드 형태로 남겨 둠으로써, 해당 메서드를 재정의 할 수 있도록 구성 해두었다. 재정의를 하지 않으면 아무것도 실행되지 않는 듯 하다. (protected로 정의되어 있는 걸로 봐서 해당 클래스를 상속 후 재정의하면 되는 것 같다.)
1. init()
메서드에서 초기화를 수행한다.
private void init() throws Exception {
Collection<SecurityConfigurer<O, B>> configurers = getConfigurers();
for (SecurityConfigurer<O, B> configurer : configurers) {
configurer.init((B) this);
}
for (SecurityConfigurer<O, B> configurer : this.configurersAddedInInitializing) {
configurer.init((B) this);
}
}
- 세 개의 Configurer를 순회하면서 모두 초기화 시키는데, configurer의 초기화는 다음과 같이 수행된다.
EnableGlobalAuthenticationAutowiredConfigurer
초기화
@Override
public void init(AuthenticationManagerBuilder auth) {
Map<String, Object> beansWithAnnotation = this.context
.getBeansWithAnnotation(EnableGlobalAuthentication.class);
if (logger.isTraceEnabled()) {
logger.trace(LogMessage.format("Eagerly initializing %s", beansWithAnnotation));
}
}
- 파라미터로
DefaultPasswordEncoderAuthenticationManaberBuilder
가 넘어온다. - 필드에
ApplicationContext
를 갖고 있다. - 이를 이용해서
EnableGlobalAuthentication
애노테이션이 달린 빈을 가져와 Map에 담아둔다. - 즉, 이는
@EnableGlobalAuthentication
이라는 애노테이션이 달려있는 클래스에 대한 정보를 가진 Configurer이다.
InitializeAuthenticationProviderBeanManagerConfigurer
초기화
- 파라미터로
DefaultPasswordEncoderAuthenticationManaberBuilder
가 넘어온다.(자기 자신) InitializeAuthenticationProviderManagerConfigurer
를 configurer에 등록해준다.
InitializeUserDetailsBeanManagerConfigurer
초기화
- 파라미터로
DefaultPasswordEncoderAuthenticationManaberBuilder
가 넘어온다.(자기자신) InitializeUserDetailsManagerConfigurer
를 configurer에 등록해준다.
등록된 나머지 configurer들에 대한 초기화 수행
추가로 두 개의 configurer들을 더 등록했었다. 이에 대한 초기화를 수행한다.
2. configure()
메서드에서 설정을 수행한다.
5개의 configurer에 대한 configure
가 수행되고, 해당 메서드를 수행하게 되면 각 configurer가 가진 설정 정보들을 SecurityBuilder
에 등록하게 된다.
EnableGlobalAuthenticationAutowiredConfigurer
, InitializeAuthenticationProviderBeanManagerConfigurer
,
InitializeUserDetailsBeanManagerConfigurer
- 이 클래스들은
GlobalAuthenticationConfigurerAdapter
의 메서드를 재정의 하지 않았다. 따로 설정이 수행되지 않는다.
InitializeAuthenticationProviderManagerConfigurer
- 이미 설정이 되어있다면 다시 설정하지 않는다
- 설정이 안되어 있는 경우
AuthenticationProvider
빈을 찾는다.- null이면 설정하지 않고 return
- null이 아니면
AuthenticationManagerBuilder
에 찾은AuthenticationProvider
를 등록하고 종료한다.
- 하지만 나는
AuthenticationProvider
로 등록된 빈이 없기 때문에, 아무 것도 등록되지 않고 리턴된다.
InitializeUserDetailsBeanManagerConfigurer
- 이미 설정이 되어있다면 다시 설정하지 않는다.
UserDetailsService
빈을 찾는다- null이면 설정하지 않고 return
- null이 아니면 다음 과정을 수행한다. 나는
UserDetailsService
구현체를 등록 하였으므로 해당 메서드가 수행된다.
PasswordEncoder
빈과UserDetailsPasswordService
빈을 찾는다PasswordEncoder
→Bcrypt
UserDetailsPasswordService
→ null
DaoAuthenticationProvider
를 생성한다- 해당 provider에 아까 조회한
UserDetailsService
구현체를 등록한다. PasswordEncoder
가 null이 아닐 경우 provider에 등록한다.UserDetailsPasswordService
가 null이 아닐 경우 provider에 등록한다.
- 해당 provider에 아까 조회한
- 마지막으로,
AuthenticationManaberBuilder
에DaoAuthenticationProvider
를 등록한다.
3. performBuild()
메서드에서 Build를 수행한다.
AuthenticationManagerBuilder.class
@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;
}
- 해당 메서드는
AuthenticationManagerBuilder
내부에 있는 메서드다. - 여태까지 저장된 정보들을 기반으로
ProviderManager
를 생성한다.- 2번 과정에서,
DaoAuthenticationProvider
를AuthenticationManagerBuilder
에 등록했기 때문에, 해당 Provider를 가진 manager가 생성된다. 지금은 한 개지만, 여러 개일 경우도 있다. (List로 관리 됨)
- 2번 과정에서,
- 참고로, 해당 메서드에서
eraseCredential
을 검사하는데, 이는 비밀번호를 메모리에 유지할 것인지에 대한 정보이다. 따로 설정을 하지 않았다면 기본값은true
가 적용되어 메모리에 비밀번호가 유지되지 않는다고 한다. 따로 정책을 정해줬을 경우에는 ProviderManager에 세팅을 해준다. - 모든 것을 설정하고 나면 아래의 상태를 가진
ProviderManager
가 반환된다.
doBuild
메서드가 완료되고 나면, AuthenticationConfiguration.getAuthenticationManager()
나머지 메서드가 수행된다.
public AuthenticationManager getAuthenticationManager() throws Exception {
// 위에는 생략됨.
this.authenticationManager = authBuilder.build();
if (this.authenticationManager == null) {
this.authenticationManager = getAuthenticationManagerBean();
}
this.authenticationManagerInitialized = true;
return this.authenticationManager;
}
- authenticationManager가 null이 아니므로, 따로 추가적으로 설정하지 않고
authenticationManagerInitialized
를true
로 설정해 매니저가 초기화 되었음을 알린다. - 빌드한 AuthenticationManager를 반환한다.
다시 HttpSecurity
로 돌아와서, 나머지 메서드를 수행한다.
@Bean(HTTPSECURITY_BEAN_NAME)
@Scope("prototype")
HttpSecurity httpSecurity() throws Exception {
// 생략된 메서드
authenticationBuilder.parentAuthenticationManager(authenticationManager());
authenticationBuilder.authenticationEventPublisher(getAuthenticationEventPublisher());
HttpSecurity http = new HttpSecurity(this.objectPostProcessor, authenticationBuilder, createSharedObjects());
WebAsyncManagerIntegrationFilter webAsyncManagerIntegrationFilter = new WebAsyncManagerIntegrationFilter();
webAsyncManagerIntegrationFilter.setSecurityContextHolderStrategy(this.securityContextHolderStrategy);
// @formatter:off
http
.csrf(withDefaults())
.addFilter(webAsyncManagerIntegrationFilter)
.exceptionHandling(withDefaults())
.headers(withDefaults())
.sessionManagement(withDefaults())
.securityContext(withDefaults())
.requestCache(withDefaults())
.anonymous(withDefaults())
.servletApi(withDefaults())
.apply(new DefaultLoginPageConfigurer<>());
http.logout(withDefaults());
// @formatter:on
applyDefaultConfigurers(http);
retu
public HttpSecurity(ObjectPostProcessor<Object> objectPostProcessor,
AuthenticationManagerBuilder authenticationBuilder, Map<Class<?>, Object> sharedObjects) {
super(objectPostProcessor);
Assert.notNull(authenticationBuilder, "authenticationBuilder cannot be null");
setSharedObject(AuthenticationManagerBuilder.class, authenticationBuilder);
for (Map.Entry<Class<?>, Object> entry : sharedObjects.entrySet()) {
setSharedObject((Class<Object>) entry.getKey(), entry.getValue());
}
ApplicationContext context = (ApplicationContext) sharedObjects.get(ApplicationContext.class);
this.requestMatcherConfigurer = new RequestMatcherConfigurer(context);
}
- 생성한
AuthenticationManagerBuilder
를 기반으로HttpSecurity
가 생성된다.- 생성 시, 여러
SecurityConfigurer
가 공유할 수 있는 객체를 설정한다- ContentsNegotiation, ApplicationContext가 대표적이다.
- 생성 시,
RequestMatcherConfigurer
를 만든다. 이는 요청 매핑에 사용된다. (엔드포인트 매핑)
- 생성 시, 여러
생성된 HttpSecurity를 빈으로 등록한 후에는 여러 configurer들에 대한 초기화 및 configuration을 수행하는 것을 확인할 수 있었다. 이 것 까지 알아보기에는 너무 지쳤기에… 오늘의 목표만 달성하고 끝내려고 한다.
- 여러가지 configure들이 있으니, 초기화 방법이나 설정 방법이 궁금하다면
init()
,configure()
메서드를 찾아보면 될 것 같다.
마무리
디버깅 + 정리에 총 4시간이 소요되었지만, SpringSecurity에 대해서 조금은 더 알게된 날이 아닐까 싶다. 이정도까지 알아야 할까는 의문이긴 하지만… 알아서 나쁠건 없을 것 같다. 또한, 빈으로 등록하기만 한게 어떻게 시큐리티 필터에 등록될 수 있을까 궁금했는데, 전체적인 내용을 파악할 수 있어서 좋았던 것 같다.
설정 정보를 모조리 Builder라는 클래스에 집어넣고, 해당 설정 정보를 기반으로 ProviderManager 구현체를 만드는 것이 인상깊었다. 무언가 복잡한 설정을 많이 한 후에 구현체를 반환해야 할 때 참고삼아 볼 수는 있을 것 같다. 구현은 내 머리가 따라줄까..? 모르겠다.
'스터디 > 리얼 월드 스터디 회고' 카테고리의 다른 글
리얼 월드 스터디 회고 - 7 (2) | 2023.01.08 |
---|---|
리얼 월드 스터디 회고 - 6 ) Spring Security + JWT (0) | 2023.01.05 |
리얼 월드 스터디 회고 - 4 (2) | 2022.12.23 |
리얼 월드 스터디 회고 - 3 (0) | 2022.12.21 |
리얼 월드 스터디 회고 - 2 (0) | 2022.12.18 |
댓글