Framework/Spring

순환 참조 문제 - SecurityConfig에서 passwordEncoder와 filterChain의 remember-me 속성을 같이 사용할 경우

kkumta 2022. 11. 22. 23:16

에러 메시지는 다음과 같습니다.

 

The dependencies of some of the beans in the application context form a cycle:

┌─────┐
|  securityConfig defined in file [C:\Users\username\IdeaProjects\Puru-Board\build\classes\java\main\com\puru\puruboard\config\SecurityConfig.class]
↑     ↓
|  inMemoryUserDetailsManager defined in class path resource [org/springframework/boot/autoconfigure/security/servlet/UserDetailsServiceAutoConfiguration.class]
└─────┘

 

기존 코드는 다음과 같습니다.

package com.puru.puruboard.config;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    
    final UserDetailsService userDetailsService;
    
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .authorizeRequests()
            .antMatchers("/").permitAll()
            .antMatchers(HttpMethod.GET, "/post").permitAll()
            .anyRequest().authenticated()
            .and()
            .formLogin()
            .and()
            .logout()
            .deleteCookies("remember-me")
            .and()
            .rememberMe()
            .userDetailsService(userDetailsService).and().build();
    }
}

 

빈으로 등록한 passwordEncoder와 filterChain의 rememberMe 설정을 위해 필요한 UserDetailsService 간에 순환 참조가 일어나 발생한 문제로 보입니다.

 

해결 방법은 클래스에 메모리가 올라갈 때 생성되는 static 변수를 활용하는 것입니다.

static final UserDetailsService userDetailsService = new InMemoryUserDetailsManager();

 

위와 같이 설정해주면 userDetailsService는 SecurityConfig 클래스가 호출될 때 생성되기 때문에 순환 참조가 일어나지 않게 됩니다.

 

개선된 코드는 아래와 같습니다.

package com.puru.puruboard.config;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    static final UserDetailsService userDetailsService = new InMemoryUserDetailsManager();
    
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .authorizeRequests()
            .antMatchers("/").permitAll()
            .antMatchers(HttpMethod.GET, "/post").permitAll()
            .anyRequest().authenticated()
            .and()
            .formLogin()
            .and()
            .logout()
            .deleteCookies("remember-me")
            .and()
            .rememberMe()
            .userDetailsService(userDetailsService).and().build();
    }
}

 

+ 20221127 추가

앞의 코드를 작성했을 때는 테스트 이전이라, 앱을 띄우는 데 문제가 없기에 괜찮다고 생각했습니다. 이후 개발을 진행하며 해당 방법으로는 구현이 불가능하다는 점을 깨달았고, 이후 개선 사항에 대해 말씀드리겠습니다.

 

SecurityConfig는 아래 방식처럼 작성해주시면 됩니다.

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    private final UserDetailsService userDetailsService;
    
    public SecurityConfig(UserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }
}

 

그리고, userDetailsService에 들어갈 Service를 UserDetailsService를 implements하셔서 직접 구현해주시면 됩니다. loadUserByUsername 메서드를 구현하시면 되고, 저의 경우에는 이메일을 username으로 사용했기에 아래와 같은 방식으로 구현했습니다.

@Service
@AllArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
    
    private UserRepository userRepository;
    
    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
    
        Optional<User> user = userRepository.findByEmail(email);
        
        if (user.isEmpty()) {
            throw new UsernameNotFoundException("해당 email로 등록된 회원이 없습니다.");
        }
        
        List<GrantedAuthority> roles = new ArrayList<>();
        roles.add(new SimpleGrantedAuthority(user.get().getRole()));
        
        UserContext userContext = new UserContext(user.get(), roles);
        
        return userContext;
    }
}

 

UserContext는 org.springframework.security.core.userdetails.User를 상속받아 직접 구현하시면 됩니다. 제 경우에는 다음과 같이 구현했습니다.

@Getter
public class UserContext extends org.springframework.security.core.userdetails.User {
    
    private final User user;
    
    public UserContext(com.puru.puruboard.domain.User user,
                       Collection<? extends GrantedAuthority> authorities) {
        super(user.getEmail(), user.getPassword(), authorities);
        this.user = user;
    }
}

 

 

추가사항입니다. 만약, 아래의 두 가지 경우에 해당하신다면 AuthenticationProvider도 직접 구현하신 후 SecurityConfig에 Bean으로 등록해서 사용하시면 됩니다.

 

1. thymeleaf를 사용하시고 로그인 이후 메인 화면에 유저 정보를 보여주고 싶다면 sec:authentication 태그를 사용하시면 되는데요. sec:authentication="principal.nickname"과 같은 방식으로 사용하면 됩니다. AuthenticationProvider의 authenticate가 Authentication으로 return한 값을 토대로 저장된 정보이니 저와 같은 방법으로 구현하신 경우 principal. 뒤에 User의 각 칼럼명을 입력해주시면 됩니다.

2. 비밀번호 인증 외 다른 인증도 추가하고 싶을 때 사용합니다. UserDetailsService만 구현하셔도 비밀번호 인증은 AuthenticationProvider를 구현한 AbstractUserDetailsAuthenticationProvider의 authenticate 메서드에 의해 알아서 처리됩니다.

 

우리가 AuthenticationProvider을 직접 구현하는 것은 AuthenticationProvider의 구현체 하나를 만들어 Spring Security의 동작 과정에서 쓰일 수 있게 하는 것입니다.

 

 

참고 강의(현재 수강중): https://www.inflearn.com/course/%EC%BD%94%EC%96%B4-%EC%8A%A4%ED%94%84%EB%A7%81-%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0

 

스프링 시큐리티 - Spring Boot 기반으로 개발하는 Spring Security - 인프런 | 강의

초급에서 중.고급에 이르기까지 스프링 시큐리티의 기본 개념부터 API 사용법과 내부 아키텍처를 학습하게 되고 이를 바탕으로 실전 프로젝트를 완성해 나감으로써 스프링 시큐리티의 인증과

www.inflearn.com

 

아직 배워가는 과정이다보니 잘못된 정보가 있을 수 있습니다. 댓글 달아주시면 개선하도록 노력하겠습니다.