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 추가가 안되나?
- 빌더? 플라이웨이트? 패턴으로 구현되어있으니, 수정이 안될거 같기도
- HttpSecurity 빈이 주입 될려면 AuthenticationManager가 있어야 하는거 아닌가?
- 예상하는 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() 에 기본 설정을 해주지 않았다
- InitialUsernamePasswordAuthenticationFilter 필터가 authenticationManager 의 주입을 필요로 한다.
- 빈으로써 AuthenticationManagerBuilder 에 의해 AuthenticationManager 가 생성된다
- 이때, AuthenticationManager 는 제대로 Provider를 공급받지 못한 채이기 때문에, 생성중인 상태로 스프링 빈에 등록된다
- 따라서 스프링 빈이 생성중(프록시 상태)인 스프링 빈을 참조하고 있기 때문에, 무한루프가 발생한다...?
- 스프링은 의존관계 설정을 다음과 같이 해결한다.
- 인터페이스의 구현체 -> JDK 동적 프록시
- 인터페이스가 아닌 빈 -> CGLIB 프록시
- 해당 빈이 InitialUsernamePasswordAuthenticationFilter 에 등록된다
- securityFilterChain 가 빈으로써 등록되기 위해, InitialUsernamePasswordAuthenticationFilter 가 주입된다.
그럼 HttpSecurity 에 있는 authenticationProvider() 메소드는 존재 가치가 뭐지?
- 분명 어떨땐 등록이 되고
- 어떨땐 등록이 안되는데
- 예상
- 전역 AuthenticationManager 가 아직 초기회되지 않았고 기본설정이 가능한 경우, 해당 전역 AuthenticationManager 를 사용하거나
- 이미 전역 AuthenticationManager 가 초기화되었다면, 필터체인 생성 시, AuthenticationManager 를 새로 생성하는 것 같다
- 좀 더 명시적으로 인증 로직이 눈에 보이도록
- 해당 필터 체인이 어느 로직을 사용하는지 눈에 보이도록
- Config 메소드로 관리하지 말고
- 스프링 빈으로 관리하는 방향으로 바꾸라는 뜻인듯
해결되지 않은 부분
- 왜 nullPointerException 아니면 BeanCreationException 이 아니고 stackOverFlow 인거지?
- 초기화되지 않은 프록시를 사용했는데 stackOverflow가 발생한 건 좀 이상하다-
- AOP 부분을 학습 해야 이해가 가능할 듯 - 다시 오기