본문 바로가기
스터디/리얼 월드 스터디 회고

리얼 월드 스터디 회고 - 5

by 딱구킴 2022. 12. 30.

구현 주제 : Spring Security를 이용한 username password 인증 로직 구현

일주일 간의 username&password 인증 로직 구현 스프린트가 마무리되었다. 스프링 시큐리티 설정에서 상당히 많은 시간동안 삽질을 했는데, 삽질 내역은 다음과 같다.

CustomProvider 구현체 등록, 해야할까 안해도될까?

이건 준영님께서 의문점을 삼고 질문을 주셨던 내용이다. 내가 해당 인증 로직을 구현하면서 아주 많은 삽질을 했는데, 그로 인해서 가려졌던(?) 문제였다.

 

스프링 시큐리티에서는 UserDetailsServicePasswordEncoder를 빈으로만 등록하면 이를 자동으로 구현체로써 취급한다. 그렇기 때문에 커스텀 AuthenticationProvider를 따로 구현하지 않아도 된다. 하지만 나는 이걸 모르고 따로 구현했다. 즉, 쓸데없는 클래스를 만든 것이다.

 

그럼 대체 어떤 Provider를 사용하길래 따로 구현하지 않아도, 내가 빈으로 등록한 커스텀 UserDetailsServicePasswordEncoder를 인식하고 정상적으로 동작을 하는걸까?

 

결론부터 말하자면, 구현체는 스프링 시큐리티가 제공하는 DaoAuthenticationProvider를 사용한다. (준영님이 힌트 주심..! 아주 빠른 문제해결에 도움을 주셨다.) 해당 Provider는 내가 등록한 UserDetailsServicePasswordEncoder를 사용하여 접속한 유저의 비밀번호를 저장소에 저장된 유저의 비밀번호와 매칭해서 인증을 수행해준다.

 

그렇다면 어떻게 이런 동작이 가능했을까? 이를 알아보..기전에 너무 내용이 길기 때문에 요약을 먼저 올려두었다. 이를 확인한 후에 자세한 내용이 궁금하다면 아래의 내용을 훑어보면 좋을 것 같다. (프레임워크 자체가 너무 복잡하게 구성되어 있어서 잘 설명도 안되었을 것 같긴 하지만..ㅠㅠ)

요약

  • HttpSecurity가 PasswordEncoder의 구현체를 AuthenticationManagerBuilder에 넣어준다.
  • HttpSecurity.authenticationManager()AuthenticationConfiguration.getAuthenticationManager() 호출
  • AuthenticationManagerBuilder를 빈에서 꺼내옴 → DefaultPasswordEncoderAuthenticationManagerBuilder 구현체 획득
  • DefaultPasswordEncoderAuthenticationManagerBuilder의 부모 추상 클래스인 AbstractConfiguredSecurityBuilderGlobalConfigurerAdpater를 모두 등록
  • build메서드 호출 → doBuild메서드 호출 → AbstractConfiguredSecurityBuilderdoBuild()메서드가 수행 됨
  • 설정 정보인 Configurer 초기화
    • doBuild()내부의 init()
  • 설정 정보를 기반으로 설정 등록
    • doBuild() 내부의 configure()
    • usernamePassword인증 기반으로, 이 때 InitializeUserDetailsManagerConfiguerer에서 AuthenticationProvider구현체가 생성된다.
    • UserDetailsService구현체를 빈으로 등록한 경우 DaoAuthenticationProvider가 구현체로 생성되고, 구현한 UserDetailsService와 빈으로 등록한 PasswordEncoder가 Provider에 등록된다.
  • DefaultPasswordEncoderAuthenticationManagerBuilder의 설정이 완료된 후, doBuild()메서드 내부의 performBuild()에서 Build를 수행하고, ProviderManagerconfigure()에서 등록한 AuthenticationProvider를 등록하고, 해당 ProviderManager를 반환한다. 이를 Manager 구현체로 사용한다.

 

  • 더더더 요약
    • 빈으로 등록된 PasswordEncoderUserDetailsService를 특정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 구현체가 생성된다.
  • parentAuthenticationManagerauthenticationManager()가 호출된다
  • 이는 AuthenticationConfigurationgetAuthenticationManager()를 호출한다

 

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에 모두 등록한다.
    • AbstractConfiguredSecurityBuilderAuthenticationManagerBuilder의 구현체인 DefaultPasswordEncoderAuthenticationManagerBuilder의 부모 추상클래스다. 해당 추상 클래스에 등록된다.

 

  • 그 후, AuthenticationManagerBuilderAuthenticationManager로 만들기 위해 AbstractSecurityBuilderbuild() 메서드를 호출한다. 해당 메서드는 내부에서 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 빈을 찾는다
    • PasswordEncoderBcrypt
    • UserDetailsPasswordService → null
  • DaoAuthenticationProvider를 생성한다
    • 해당 provider에 아까 조회한 UserDetailsService 구현체를 등록한다.
    • PasswordEncoder가 null이 아닐 경우 provider에 등록한다.
    • UserDetailsPasswordService가 null이 아닐 경우 provider에 등록한다.
  • 마지막으로, AuthenticationManaberBuilderDaoAuthenticationProvider를 등록한다.

 

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번 과정에서, DaoAuthenticationProviderAuthenticationManagerBuilder에 등록했기 때문에, 해당 Provider를 가진 manager가 생성된다. 지금은 한 개지만, 여러 개일 경우도 있다. (List로 관리 됨)

  • 참고로, 해당 메서드에서 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이 아니므로, 따로 추가적으로 설정하지 않고 authenticationManagerInitializedtrue로 설정해 매니저가 초기화 되었음을 알린다.
  • 빌드한 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 구현체를 만드는 것이 인상깊었다. 무언가 복잡한 설정을 많이 한 후에 구현체를 반환해야 할 때 참고삼아 볼 수는 있을 것 같다. 구현은 내 머리가 따라줄까..? 모르겠다.

댓글