Spring

[스프링부트] 멀티인증 AuthenticationManager + Stackoverflow feat. UserDetailsService 무한재귀오류 해결

곽코딩루카 2024. 8. 7. 10:31
반응형

나는 REST-API용 로그인을 구축해놓은 상태에서 어드민 페이지를 추가로 만들어야 하는 상황이였다.

보통 admin과 rest-api용 서버는 따로 나누지만 나같은경우 서버비용 절약 및 규모가 작은 프로젝트였기에 한곳에서 진행하려 하였다.

문제는 스프링부트에서 jwt+security로 rest-api용 (회원로그인) 기능을 구현해 놓은 상태에서 추가로 form-login 어드민을 시큐리티로 기능을 같이 넣어야 되는 상황이 온것이다.

 

그렇기에 userDetailsService를 2개를 구축해야 하는 상황이였다. ( userDetailsService를 구체화하여 로그인을 커스텀함)

 

어드민로그인같은경우 admin테이블에서 조회를 해야했으며 일반 로그인은 user테이블에서 조회를했어야했다.

 

에초에 작용해야하는 mapper가 달랐기 때문에 각각 다른 userDetails를 구체화해서 각각의 객체를 사용했어야 했다.

 

그렇기에 나는 admin용은 AdminDetailsService를 만들고 일반유저 로그인용은 CustomUserDetailsService를 만들었다. 만든 예시는 아래와 같다.

 

아래 소스는 일반 유저의 로그인용 detailsService이다.

@Service
@RequiredArgsConstructor
//Spring Security에서 사용자 인증을 처리하는 데 필요한 사용자 정보를 로드하는 역할을 함
public class CustomUserDetailsService implements UserDetailsService { //UserDetailsService 인터페이스는 Spring Security에서 사용자 인증 정보를 로드하기 위해 사용하는 인터페이스

    private final UserMapper userMapper;

    //loadUserByUsername 구체화
    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        //이메일로 사용자 정보 조회
        User user = userMapper.findByEmail(email);
        //조회된 이메일이 없으면 인증 실패 처리
        if (user == null) {
            throw new UsernameNotFoundException("User not found with email: " + email);
        }


        /*  org.springframework.security.core.userdetails.User객체
        * Spring Security에서 사용자의 인증 정보를 담고 있다.
        * 이메일, 비밀번호, 권한 목록을 포함
        * */
        return new CustomUserDetails(user.getId(),user.getEmail(), user.getPw(), new ArrayList<>());
    }
}

 

 

아래는 어드민용 detailsService이다.

@Service
@RequiredArgsConstructor
public class CustomAdminDetailsService implements UserDetailsService {
    private final AdminUserMapper adminUserMapper;
    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        AdminUser user = adminUserMapper.findByEmail(email);
        if (user == null) {
            throw new UsernameNotFoundException("Admin user not found with email: " + email);
        }
        return new CustomUserDetails(user.getId(), user.getEmail(), user.getPw(), new ArrayList<>());
    }
}

 

 

 

그렇다면 filterChain을 2개를 사용해야 하는 상황이였다. 그렇기에 .securityMatcher메소드로 각 경로에 대해서 체인이 작동하도록 설정을 하여 /admin으로 들어올경우 adminDetailsService /api용으로 들어오면 customDetailsService를 타야 하는 상황이였기에 아래와 같이 시큐리티 설정을 해놓았다.

 

 

public class SecurityConfig {

    private final JwtRequestFilter jwtRequestFilter;
    private final CustomUserDetailsService userDetailsService; //user테이블에서 조회
    private final CustomAdminDetailsService adminDetailsService;   //admin_user테이블에서 조회


    //API filterChain
    @Bean
    @Order(1)
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .securityMatcher("/api/**") // /api/** 경로에 대해서만 이 필터 체인이 작동하도록 설정
                .csrf(csrf -> csrf.disable())   // CSRF 보호 기능 비활성화 (REST-API이므로)
                .sessionManagement(sessionManagement ->
                        sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))   // 세션기반 인증 사용 안함
                .authorizeHttpRequests(authz -> authz
                        // 기타 경로 권한 설정
                        .requestMatchers("/api/auth/**","/error","/swagger-ui/**","/api-docs/**","/api/test/**","/sweeety-api-docs","/api/files/**","/api/payment/**","/api/payment/prepare/**","/admin/**","/img/**","/vendor/**","/register","/css/**","/js/**","/health").permitAll()
                        .anyRequest().authenticated()) //나머지 경로는 인증 필요
                .addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class) //jwtRequestFilter가 먼저 실행되도록
                .formLogin(formLogin -> formLogin.disable())    // 폼 기반 로그인 비활성화
                .userDetailsService(userDetailsService); // 특정 UserDetailsService 지정
        return http.build();
    }

    // Admin filterChain
    @Bean
    @Order(2)
    public SecurityFilterChain adminFilterChain(HttpSecurity http) throws Exception {
        http
                .securityMatcher("/admin/**") // /admin/** 경로에 대해서만 이 필터 체인이 작동하도록 설정
                .csrf(csrf -> csrf.disable())
                .authorizeHttpRequests(authz -> authz
                        .requestMatchers( "/admin/login", "/css/**", "/js/**","/img/**","/vendor/**").permitAll()
                        .anyRequest().authenticated())
                .formLogin(formLogin -> formLogin
                        .loginPage("/admin/login")
                        .defaultSuccessUrl("/admin", true)
                        .permitAll())
                .logout(logout -> logout
                        .logoutUrl("/admin/logout")
                        .logoutSuccessUrl("/admin/login?logout")
                        .permitAll())
                .userDetailsService(adminDetailsService); // 특정 UserDetailsService 지정
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

 

 

여기서 문제가 생긴다!!! 바로 아래와 같은 오류가 나왔던 것이다.... StackOverflowError가 발생한다

 

 

java.lang.StackOverflowError: null
at java.base/java.lang.String.equals(String.java:1832) ~[na:na]
at org.springframework.util.ReflectionUtils.isEqualsMethod(ReflectionUtils.java:510) ~[spring-core-6.1.6.jar:6.1.6]
at org.springframework.aop.support.AopUtils.isEqualsMethod(AopUtils.java:162) ~[spring-aop-6.1.6.jar:6.1.6]
at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:175) ~[spring-aop-6.1.6.jar:6.1.6] ...무한반복

 

 

  1. 빈 초기화 순환 참조: 첫 번째 방법에서 authenticationManager 빈이 AuthenticationConfiguration에 의해 생성될 때, 스프링 컨테이너는 어떤 UserDetailsService를 사용할지 명확하게 알지 못합니다. UserDetailsService가 두 개 이상 등록되어 있을 때, 스프링은 어떤 빈을 선택해야 할지 혼란스러워하며 순환 참조가 발생할 수 있습니다. 이로 인해 무한 루프가 발생하게 됩니다.
  2. Lazy Initialization 문제: 첫 번째 방법에서는 AuthenticationConfiguration이 AuthenticationManager를 가져오려고 시도할 때 UserDetailsService 빈이 아직 초기화되지 않은 상태일 수 있습니다. 이로 인해 빈 초기화 과정에서 순환 참조가 발생하게 됩니다.

반면, 두 번째 방법에서는 AuthenticationManager를 직접 구성하고, 두 개의 DaoAuthenticationProvider를 명시적으로 설정합니다. 이렇게 하면 스프링이 어떤 UserDetailsService를 사용할지 명확하게 알고, 필요한 모든 빈을 명시적으로 초기화하게 됩니다. 이로 인해 순환 참조 문제가 해결됩니다.

첫 번째 방법: 순환 참조 문제

@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
    return authenticationConfiguration.getAuthenticationManager();
}

이 방법에서는 AuthenticationConfiguration이 스프링 컨테이너에서 AuthenticationManager를 가져오려고 할 때 두 개의 UserDetailsService 빈 중 어느 것을 사용할지 명확하지 않아서 순환 참조가 발생할 수 있습니다.

두 번째 방법: 명시적 설정

@Bean
public AuthenticationManager authenticationManager() {
    DaoAuthenticationProvider userAuthProvider = new DaoAuthenticationProvider();
    userAuthProvider.setUserDetailsService(userDetailsService);
    userAuthProvider.setPasswordEncoder(passwordEncoder());

    DaoAuthenticationProvider adminAuthProvider = new DaoAuthenticationProvider();
    adminAuthProvider.setUserDetailsService(adminDetailsService);
    adminAuthProvider.setPasswordEncoder(passwordEncoder());

    return new ProviderManager(Arrays.asList(userAuthProvider, adminAuthProvider));
}

이 방법에서는 DaoAuthenticationProvider를 명시적으로 구성하여 어떤 UserDetailsService를 사용할지 명확히 지정합니다. 이를 통해 스프링 컨테이너가 어떤 빈을 사용해야 할지 정확하게 알 수 있게 되어 순환 참조 문제를 피할 수 있습니다.

결론

두 번째 방법에서는 AuthenticationManager를 구성하는 동안 두 개의 UserDetailsService를 명시적으로 설정하므로, 스프링이 어떤 빈을 사용해야 할지 명확하게 이해하게 됩니다. 이로 인해 순환 참조 문제와 무한 루프 오류가 발생하지 않습니다.

 

 

 

 

이번에도 팀 시큐리티를 맡게 되었다

 

이번엔 기필코 깔끔하게 하겠다는 생각으로 임하게 되었는데..

각 파트 다 짜고 이제 로그인해보자! 하고 들어가는 순간...!!!!

 

아.... 제일 보기 싫은 에러가 떴다 

 

Filter는 타고 들어가는 걸 보니 일단 authentication으로 들어가는 시늉은 하는듯한데..

아마도 authentication에서 뭔가 문제가 생겼나 해서 authenticationmanager를 이리저리 건드렸고..

 

답이 안 나오던 나는 구글에 검색해보았다 

 

https://github.com/spring-projects/spring-framework/issues/29215#issuecomment-1263775300

 

AopTestUtils.getUltimateTargetObject results in stack overflow for proxy backed by LazyInitTargetSource · Issue #29215 · sprin

Security configuration class @Configuration @EnableGlobalMethodSecurity(prePostEnabled = true) public class SecurityConf { @Bean public AuthenticationManager authenticationManagerBean(Authenticatio...

github.com

 

아..

 

유난히 이번에 이런 리포트가 많이 올라오는 이유는

 

다들 아마 WebSecurityConfigurerAdapter가 삭제되어

authenticationManager를 Bean으로 등록하여 쓰려하다 생기는 이유지 않을까

 

다들 아마 이렇게 등록하여 쓰고 있을 것이다

 

문제의 SecurityConfig - AuthenticationManager

@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
    return authenticationConfiguration.getAuthenticationManager();
}

그럼 자연스레 AuthenticationManager는 authentication configuration에서 authenticationmanager를 불러와서 반환해줄 텐데..

 

잘 생각해보자

1. authentication configuration이 쓸 userDetailsService가 2개 이상일 경우

2. 일단 어떤 놈을 쓸지 모르니 일단  lazybean상태로 컨테이너에 빈을 가져다 넣는다

3. 여기서 authentication manager를 쓸려고 빈을 호출했는데

막상 빈에서 authentication configuration에서는 뭘 쓸지 모르는 상태라서 그냥 lazybean상태 그대로 authentication manager로 반환한다 

4. 당연히 다시 bean은 요청을 한다 "아니 내놓으라고 그래서 난 뭔데" > 무한반복

 

이러면서 stackoverflow가 발생한다 

 

여하튼.. 

지정해준 userDetailsService가 하나일 경우

그냥 userDetailsService 파일 위에다가 @Service나 @Component를 붙이면

지 알아서 authenticationconfiguration이 끌고 가는데.. 두 개 이상이면 저런 문제가 발생하지 않을까 

 

여하튼 두 개 이상일 경우 해결책의 제시로는 authenticationmanager의 내용을 직접 정의하는 것을 말하고 있다 

아래와 같이 말이다

 

해결을 위한 SecurityConfig - AuthenticationManager

@Bean
public AuthenticationManager authenticationManager() {
    DaoAuthenticationProvider userAuthProvider = new DaoAuthenticationProvider();
    userAuthProvider.setUserDetailsService(userDetailsService);
    userAuthProvider.setPasswordEncoder(passwordEncoder());

    DaoAuthenticationProvider adminAuthProvider = new DaoAuthenticationProvider();
    adminAuthProvider.setUserDetailsService(adminDetailsService);
    adminAuthProvider.setPasswordEncoder(passwordEncoder());

    return new ProviderManager(Arrays.asList(userAuthProvider, adminAuthProvider));
}

이렇게 직접 말이다..

 

이러면 뭐.. 확실히 문제가 생길 여지는 없어 보인다 

 

스프링은 역시.. 보면 볼수록 뭔가 아직 많이 생기고 있으며 모르는 게 많다고 느껴진다 

 

대체.. 그들은 어디까지 보고 개발하고 있는 걸까 

반응형