WEB BE Repo/Spring Security

스프링 시큐리티 stack overflow

조금씩 차근차근 2024. 9. 27. 23:18

느낀점

  • 진짜 단위테스트 통합테스트가 진짜 중요하구나
  • 버그 찾기 진짜 까다로워지네

문제 분석

java.lang.StackOverflowError: null
    at java.base/java.lang.Exception.<init>(Exception.java:103) ~[na:na]
    at java.base/java.lang.ReflectiveOperationException.<init>(ReflectiveOperationException.java:90) ~[na:na]
    at java.base/java.lang.reflect.InvocationTargetException.<init>(InvocationTargetException.java:68) ~[na:na]
    --------무한루프 시작--------
    at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:118) ~[na:na]
    at java.base/java.lang.reflect.Method.invoke(Method.java:580) ~[na:na]
    at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:355) ~[spring-aop-6.1.12.jar:6.1.12]
    at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:216) ~[spring-aop-6.1.12.jar:6.1.12]
    at jdk.proxy2/jdk.proxy2.$Proxy59.authenticate(Unknown Source) ~[na:na]
    -----------------------------------------------------------------------
    at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103) ~[na:na]
    at java.base/java.lang.reflect.Method.invoke(Method.java:580) ~[na:na]
    at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:355) ~[spring-aop-6.1.12.jar:6.1.12]
    at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:216) ~[spring-aop-6.1.12.jar:6.1.12]
    at jdk.proxy2/jdk.proxy2.$Proxy59.authenticate(Unknown Source) ~[na:na]
    -----------------------------------------------------------------------
  • 스프링 시큐리티 스택 오버플로우 문제 발생
  • AOP로 실행되는 메소드가 authenticate 를 재귀적으로 호출중

오류 발생 과정

  • 필터 호출
  • 필터가 authenticate() 메소드를 통해 AuthenticationManager 호출 ← 여기서 문제 발생
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String username = request.getHeader("username");
        String password = request.getHeader("password");
        String code = request.getHeader("code");

        if (code == null) {
            Authentication a = new UsernamePasswordAuthentication(username, password);
            manager.authenticate(a);
        }else{
            Authentication a = new OtpAuthentication(username, code);
            manager.authenticate(a);

            SecretKey key = Keys.hmacShaKeyFor(
                    signingKey.getBytes(StandardCharsets.UTF_8)
            );
            String jwt = Jwts.builder()
                    .setClaims(Map.of("username", username))
                    .signWith(key)
                    .compact();
            response.setHeader("Authorization", jwt);
        }
    }
  • AuthenticationManager 가 적절한 AuthenticationProvider 호출
    • support 메소드를 통해 들어온 authentication으로 적절한 Provider 찾음

의심가는 점 & 시도

의심 1

  • 스프링 시큐리티 6부터 authenticate 를 명시적으로 호출하지 않더라도, 호출해주는 기능이 추가되었나?
    • 근데 그러면 authenticate의 중복 호출이 되어야지, 재귀 호출이 되는건 뭔가 이상하다
  • 폐기

의심 2

  • 빈 초기 설정에 문제가 있나?
    • 의존성 주입이 잘못됐나?
    • 근데 그러면 컨텍스트가 올라갈때 무한루프가 발생하지 않았을까
  • 중요한건 authenticate() 메소드의 재귀호출
  • 폐기

의심 3

  • 기본 AuthenticateManager 기능에 authenticate 관련 동작이 있나?
  • 기본 설정 찾아보기
    • globalAuthConfigurers 에서 설정들을 받아온다
    • 여기에 아마 authenticate 관련 동작이 있는건 아닐까?
  • 모르겠음. TODO

의심 4

  • 컨텍스트 띄워질 때 의심스러운 문구 발견

Global Authentication Manager will not be configured with AuthenticationProviders.
Consider publishing a single AuthenticationProvider bean, or wiring your Providers directly using the DSL.

  • 기본 Authentication Manager 가 설정이 안되어있다고?
  • 기존 코드
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(
            HttpSecurity http,
            InitialAuthenticationFilter initialAuthenticationFilter,
            JwtAuthenticationFilter jwtAuthenticationFilter,
            OtpAuthenticationProvider otpAuthenticationProvider,
            UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProvider
    ) throws Exception {
        http
                .authenticationProvider(otpAuthenticationProvider)
                .authenticationProvider(usernamePasswordAuthenticationProvider)
                .csrf(AbstractHttpConfigurer::disable)
                .addFilterAt(
                        initialAuthenticationFilter,
                        BasicAuthenticationFilter.class
                )
                .addFilterAfter(
                        jwtAuthenticationFilter,
                        BasicAuthenticationFilter.class
                )
                .authorizeHttpRequests(a->a.anyRequest().authenticated());
        return http.build();
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }
}
  • filterChain 빈이 AuthenticationManager 하고 따로 노는건가
    • HttpSecurity 빈이 주입 될려면 AuthenticationManager가 있어야 하는거 아닌가?
      • 정확히는 HttpSecurity 가 build 될 때, 기본 설정된 AuthenticationManagerBuilder 를 호출함.
      •  
    • AuthenticationManager 가 생성되고 나면, 더이상 Provider 추가가 안되나?
      • 빌더? 플라이웨이트? 패턴으로 구현되어있으니, 수정이 안될거 같기도
  • 예상하는 AuthenticationManager 의 구현 형식
@Component
public class CustomAuthenticationManager implements AuthenticationManager {

    private final List<AuthenticationProvider> providers;

    public CustomAuthenticationManager(List<AuthenticationProvider> providers) {
        this.providers = providers;
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        for (AuthenticationProvider provider : providers) {
            if (provider.supports(authentication.getClass())) {
                return provider.authenticate(authentication);
            }
        }
        throw new AuthenticationException("No suitable AuthenticationProvider found") {};
    }
}

시도

-   변경한 코드

package security.practice21.config;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import security.practice21.filter.InitialAuthenticationFilter;
import security.practice21.filter.JwtAuthenticationFilter;
import security.practice21.security.OtpAuthenticationProvider;
import security.practice21.security.UsernamePasswordAuthenticationProvider;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(
            HttpSecurity http,
            InitialAuthenticationFilter initialAuthenticationFilter,
            JwtAuthenticationFilter jwtAuthenticationFilter
    ) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable)
                .addFilterAt(
                        initialAuthenticationFilter,
                        BasicAuthenticationFilter.class
                )
                .addFilterAfter(
                        jwtAuthenticationFilter,
                        BasicAuthenticationFilter.class
                )
                .authorizeHttpRequests(a->a.anyRequest().authenticated());
        return http.build();
    }

    @Bean
    public AuthenticationManager authenticationManager(OtpAuthenticationProvider otpAuthenticationProvider,
                                                       UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProvider)  {
        return new ProviderManager(usernamePasswordAuthenticationProvider, otpAuthenticationProvider);
    }
}


-   여전히 해당 메시지는 뜬다.
-   Global Authentication Manager will not be configured with AuthenticationProviders. Consider publishing a single AuthenticationProvider bean, or wiring your Providers directly using the DSL.
-   근데 된다
    -   왜됨????????
    -   저 메시지는 왜 뜨는거지?
        -   말 그대로 그냥 기본 AuthenticationProviders 를 안썼다는 의미인가

해결

기존 코드가 안됐던 이유

  • 기존 코드가 안됐던 이유
    • 필터가 authenticationManager 의존중
    • 필터체인 등록 시 필터 참조
    • authenticationManager는 Provider 참조중
    • 따라서 Manager → Filter → FilterChain 순서대로 빈 생성
    • Provider 의 초기화 시점이 애매함
    • 일단 FilterChain 전인건 확정
    • 근데 FilterChain 생성 시 Manager 객체 수정?
      • Manager 가 수정될까?
      • 새로 생성되는건 아닐까?

답답할 땐 정의

  • filterChain
    • 특정 URI 에 대하여 다른 필터 체인을 적용하고 싶을 때
    • 책임: 제어 흐름 가로채기 & 필터들의 집합 관리
  • filter
    • 필터 체인에 들어갈 필터들의 모듈화
    • 책임: 인증 객체 생성 & 필터의 순서 제어
    • 필터체인 : 필터 = 1 : 1…N
  • AuthenticationManager
    • 적절한 공급자로 연결
    • 중재자
    • 인증과 관련없거나 특별한 필터의 경우
      • 자신만의 AuthenticationManager 를 가지거나,
      • AuthenticationManager 를 갖지 않아버림
      • 예시
        • cors
        • csrf
        • ExceptionTranslation
    • 책임: 공급자 집합 생성 & 적절한 공급자 선택
    • 필터 : 매니저 = 1…N : 1 (대략)
  • AuthenticationProvider
    • 인증 객체에 대하여 적절한 인증 수행
    • 책임: 실제 인증을 수행 → userDetailsService & PasswordEncoder 를 통해
    • 매니저 : 프로바이더 = 1 : 1...N (대략)
  • 애초에 필터체인에서 Provider를 등록한다는게 뭔가 이상하다
    • 필터체인은 필터들의 집합을 관리하는 객체
    • 그중의 한 필터에 Provider 를 등록한다고? 뭔가 이상하다
      • 바로 filterChain 직속 authenticationManager 에 등록한다고?
    • 왜 필터당 하나의 authenticationManager 를 갖는게 아닌, 필터체인당 하나의 authenticationManager를 갖게 하는거지?
      • 중재자 역할로
      • 필터의 순서 제어와
      • 인증 로직 수행을 분리하기 위해서
  • 스프링 부트 3 의 스프링 시큐리티 auto configuration 은 어떠한 상황을 기본으로 상정하고 설정을 수행할까
    • 단일 인증 로직을 상정하고 설정을 수행
      • 하나의 UserDetailsService 와
      • 하나의 PasswordEncoder 가 있을 때
      • DaoAuthenticationProvider 가 자동으로 생성되어서
      • 해당 Provider 가 기본 설정 AuthenticationManager 에 등록되도록
    • 만약 여러개의 UserDetailsService, PasswordEncoder 가 있으면?
      • @Primary 가 붙은 빈을 우선 사용하거나
      • 예외를 발생시킴
  • 한마디로 authenticationConfiguration.getAuthenticationManager() 이녀석은
    • 스프링 부트의 기본 설정을 가져오거나
    • 개발자가 따로 지정한 config 을 받아오고 사용하는 용으로 쓰는 메소드
    • 개발자가 따로 기본 설정 지정하는 방법 → AuthenticationManagerBuilder
    •     @Autowired
          public void configureGlobal(AuthenticationManagerBuilder auth,
                                      OtpAuthenticationProvider otpAuthenticationProvider,
                                      UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProvider){
              auth
                      .authenticationProvider(otpAuthenticationProvider)
                      .authenticationProvider(usernamePasswordAuthenticationProvider);
          }
  • 수정된 돌아가는 코드 다른버전
  • @Configuration
    @EnableWebSecurity
    public class SecurityConfig {
    
        @Autowired
        public void configureGlobal(AuthenticationManagerBuilder auth,
                                    OtpAuthenticationProvider otpAuthenticationProvider,
                                    UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProvider){
            auth
                    .authenticationProvider(otpAuthenticationProvider)
                    .authenticationProvider(usernamePasswordAuthenticationProvider);
        }
    
        @Bean
        public SecurityFilterChain filterChain(
                HttpSecurity http,
                InitialAuthenticationFilter initialAuthenticationFilter,
                JwtAuthenticationFilter jwtAuthenticationFilter
        ) throws Exception {
            http
                    .csrf(AbstractHttpConfigurer::disable)
                    .addFilterAt(
                            initialAuthenticationFilter,
                            BasicAuthenticationFilter.class
                    )
                    .addFilterAfter(
                            jwtAuthenticationFilter,
                            BasicAuthenticationFilter.class
                    )
                    .authorizeHttpRequests(a->a.anyRequest().authenticated());
            return http.build();
        }
    
        @Bean
        public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
            return authenticationConfiguration.getAuthenticationManager();
        }
    }

 

요약

  • AuthenticationManager 는 필터체인의 필터 순서 로직과 인증 로직을 분리시켜주는 중재자 역할을 수행한다.
  • HttpSecurity 객체는 AuthenticationManager 생성 시 authenticationConfiguration.getAuthenticationManager() 를 기본적으로 호출한다
  • authenticationConfiguration.getAuthenticationManager() 는 기본적으로 스프링부트의 기본 설정을 따른다
    • 정확히 말하자면 AuthenticationManagerBuilder 에 설정된 값을 따라 AuthenticationManager 를 생성한다.
  • 스프링부트의 기본 설정은 단일 인증 로직을 위한 설정이 되어있다
    • 따라서 하나의 UserDetailsService, 하나의 PasswordEncoder 가 아니면 정상적으로 동작하지 않는다
  • 여러 개의 필터 체인과 인증 로직을 사용한다면, 명시적으로 새로운 Provider 와 Manager 객체를 생성해서 지정해주는게 가독성에 좋다
    • Provider 빈 등록
    • new ProviderManager(tempProvider1, tempProvider2) 빈 등록

그럼 왜 stackOverflow 무한 재귀 호출이 발생했던거지?

  • 기존 코드는 authenticationConfiguration.getAuthenticationManager() 에 기본 설정을 해주지 않았다
  1. InitialUsernamePasswordAuthenticationFilter 필터가 authenticationManager 의 주입을 필요로 한다.
  2. 빈으로써 AuthenticationManagerBuilder 에 의해 AuthenticationManager 가 생성된다
    1. 이때, AuthenticationManager 는 제대로 Provider를 공급받지 못한 채이기 때문에, 생성중인 상태로 스프링 빈에 등록된다
    2. 따라서 스프링 빈이 생성중(프록시 상태)인 스프링 빈을 참조하고 있기 때문에, 무한루프가 발생한다...?
    • 스프링은 의존관계 설정을 다음과 같이 해결한다.
      • 인터페이스의 구현체 -> JDK 동적 프록시
      • 인터페이스가 아닌 빈 -> CGLIB 프록시
  3.  
  4. 해당 빈이 InitialUsernamePasswordAuthenticationFilter 에 등록된다
  5. securityFilterChain 가 빈으로써 등록되기 위해, InitialUsernamePasswordAuthenticationFilter 가 주입된다.

그럼 HttpSecurity 에 있는 authenticationProvider() 메소드는 존재 가치가 뭐지?

  • 분명 어떨땐 등록이 되고
  • 어떨땐 등록이 안되는데
  • 예상
    • 전역 AuthenticationManager 가 아직 초기회되지 않았고 기본설정이 가능한 경우, 해당 전역 AuthenticationManager 를 사용하거나
    • 이미 전역 AuthenticationManager 가 초기화되었다면, 필터체인 생성 시, AuthenticationManager 를 새로 생성하는 것 같다
  • 좀 더 명시적으로 인증 로직이 눈에 보이도록
    • 해당 필터 체인이 어느 로직을 사용하는지 눈에 보이도록
    • Config 메소드로 관리하지 말고
    • 스프링 빈으로 관리하는 방향으로 바꾸라는 뜻인듯

해결되지 않은 부분

  • 왜 nullPointerException 아니면 BeanCreationException 이 아니고 stackOverFlow 인거지?
    • 초기화되지 않은 프록시를 사용했는데 stackOverflow가 발생한 건 좀 이상하다-
  • AOP 부분을 학습 해야 이해가 가능할 듯 - 다시 오기

출처