본문 바로가기
CS/스프링

spring security 적용시 @WebMvcTest 코드에서 csrf() 없애기

by LDY3838 2024. 6. 6.
반응형

이번 포스트에서는 프로젝트에 spring security 적용에 따른 Controller 단위 테스트에서 겪었던 문제 상황을 해결하는 과정에 대해서 다루도록 하겠습니다.


spring security를 적용하면서 테스트 코드에서도 변경이 필요했습니다.

테스트 코드는 spring security의 범위에서 제외를 하거나 각각의 controller 단위 테스트에서 authentication을 임의로 만들어주는 해결 방법이 존재했고 이 중에서 authentication을 만들어주는 방식을 선택하였습니다.

위와 같이 선택한 이유는 controller에 대한 단위 테스트는 실제와 같은 요청이 들어오는 것을 확인하고 이에 대한 응답이 제대로 내려가는지 확인하는 것이 중요하다고 생각하여 spring security를 테스트 코드에서는 제외하면 추후 이와 관련된 문제가 발생할 경우 테스트 코드로 이 문제를 해결할 수 없고 또 다른 테스트 코드를 만들어야 하는 문제가 있다고 생각하였기 때문입니다.


controller 테스트에서 authentication을 만들어주기 위해서 아래와 같은 코드를 이용하였습니다.

public class CommonControllerTest {

    @MockBean
    public JwtTokenProvider jwtTokenProvider;

    @BeforeEach
    public void setUp() {
        AuthenticationUtil.makeAuthentication(MEMBER1);
    }
}

각각의 메서드를 실행하기 전에 authentication을 만들어 주겠다는 의미입니다.

AuthenticationUtil은 이전에 제가 미리 만들어둔 클래스로 authentiation을 만들어주는 역할을 맡고 있고 코드는 아래와 같습니다.

@RequiredArgsConstructor
@Component
public class AuthenticationUtil {

    public static Authentication getAuthentication(AuthUser authUser) {

        List<GrantedAuthority> grantedAuthorities = authUser.roles().stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());

        return new UsernamePasswordAuthenticationToken(authUser, "", grantedAuthorities);
    }

    public static void makeAuthentication(Member member) {
        // Authentication 정보 만들기
        AuthUser authUser = AuthUser.builder()
                .memberId(member.getMemberId())
                .socialId(member.getSocialId())
                .email(member.getEmail())
                .roles(Collections.singletonList(member.getRoleKey()))
                .build();

        // ContextHolder 에 Authentication 정보 저장
        Authentication auth = AuthenticationUtil.getAuthentication(authUser);
        SecurityContextHolder.getContext().setAuthentication(auth);
    }
}

위와 같은 코드로 SecurityContextHolder에 authentication 객체를 넣어 security filter를 통과할 수 있었습니다.

    @MockBean
    public JwtTokenProvider jwtTokenProvider;

또한 위와 같은 코드를 사용한 이유는 제가 만들어서 등록한 filter인 JwtFilter에 이 스프링 빈이 필요하기 때문입니다.

JwtFilter의 전체 코드는 아래와 같습니다. 이때 JwtTokenProvider라는 의존성이 필요하기 때문에 MockBean으로 등록하여 스프링 빈으로 주입이 될 수 있게 만들었습니다.

또한 위와 같이 @WebMvcTest에서 필요한 공통 설정들을 따로 클래스로 뺀 이유는 이 중복되는 내용들을 모든 controller 단위 테스트에서 만들어주는 것보다 한 곳에 모아서 관리하는 것이 좋다고 생각하였기 때문입니다.

위와 같이 만든 클래스를 @WebMvcTest를 이용하여 단위 테스트를 진행하는 곳마다 아래와 같이 상속받게 만들어 주었습니다.

@Slf4j
@WebMvcTest(DirectoryController.class)
class DirectoryControllerTest extends CommonControllerTest

이제 저는 authentiaction을 다 만들어서 넣어주었으니 테스트 코드가 잘 작동할 것이라 예상하였지만 get을 사용하는 테스트는 정상적으로 작동하였지만 이외의 메서드를 사용하는 테스트 코드들은 403 에러가 발생하였습니다.

아래의 코드는 제가 작성한 테스트 코드와 실행 결과입니다.

@Slf4j
@WebMvcTest(DirectoryController.class)
class DirectoryControllerTest extends CommonControllerTest {

    @MockBean
    private DirectoryService directoryService;

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    @DisplayName("과목, 폴더 정보 가져 오기")
    void getDirectoryListTest() throws Exception {
        //given
        List<DirectoryResponse> directoryResponseList =
                List.of(DirectoryResponse.from(CHILD_DIRECTORY1), DirectoryResponse.from(CHILD_DIRECTORY2));
        List<ReviewSimpleResponse> reviewSimpleResponseList =
                List.of(ReviewSimpleResponse.from(DUMMY_REVIEW1), ReviewSimpleResponse.from(DUMMY_REVIEW2));
        DirectoryTotalShowResponse directoryTotalShowResponse =
                DirectoryTotalShowResponse.of(directoryResponseList, reviewSimpleResponseList);

        given(directoryService.getDirectorySubList(anyLong(), eq(PARENT_DIRECTORY1.getId())))
                .willReturn(directoryTotalShowResponse);

        log.info("directoryTotalShowResponse: {}", directoryTotalShowResponse);

        //when
        ResultActions resultActions =
                mockMvc.perform(get("/goat/directory?directoryId=" + PARENT_DIRECTORY1.getId()))
                        .andDo(print());

        //then
        resultActions
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.results.directoryResponseList[0].directoryId")
                        .value(CHILD_DIRECTORY1.getId()))
                .andExpect(jsonPath("$.results.directoryResponseList[1].directoryId")
                        .value(CHILD_DIRECTORY2.getId()))
                .andExpect(jsonPath("$.results.reviewSimpleResponseList[0].reviewId")
                        .value(DUMMY_REVIEW1.getReviewId()))
                .andExpect(jsonPath("$.results.reviewSimpleResponseList[1].reviewId")
                        .value(DUMMY_REVIEW2.getReviewId()));
    }

    @Test
    @DisplayName("폴더 생성")
    void initDirectoryTest() throws Exception {
        //given
        DirectoryInitRequest request =
                new DirectoryInitRequest("폴더 이름", PARENT_DIRECTORY1.getId(), "#FFFFFF");

        //when
        ResultActions resultActions =
                mockMvc.perform(post("/goat/directory")
                                .content(objectMapper.writeValueAsString(request))
                                .contentType(MediaType.APPLICATION_JSON))
                        .andDo(print());

        //then
        resultActions
                .andExpect(status().isCreated());
    }

    @Test
    @DisplayName("폴더 삭제")
    void deleteDirectoryTest() throws Exception {
        //given

        //when
        ResultActions resultActions =
                mockMvc.perform(delete("/goat/directory/temporal/" + PARENT_DIRECTORY1.getId()))
                        .andDo(print());

        //then
        resultActions
                .andExpect(status().isOk());
    }
}

401 에러 코드였다면 authentication을 넣는 과정에서 문제가 있었구나라고 생각했겠는제 403에러면 관련 권한이 없다는 의미이기 때문에 왜 이런 문제가 생기는지 혼란스러웠습니다.

위와 같은 문제가 생기는 원인을 찾아본 결과 csrf 토큰이 없기 때문이라는 원인을 찾게 되었습니다.

제가 spring security 관련 설정을 진행할 때 분면 csrf 관련 기능을 JWT를 쓸 것이기 때문에 disable 했는데 왜 이런 문제가 생겼는지는 잘 이해하지 못하였으나 우선 아래와 같이 with(csrf()) 코드를 이용하여 테스트 코드를 동작이 가능하게는 만들었습니다.

@Test
@DisplayName("폴더 삭제")
void deleteDirectoryTest() throws Exception {
    //given

    //when
    ResultActions resultActions =
            mockMvc.perform(delete("/goat/directory/temporal/" + PARENT_DIRECTORY1.getId())
                            .with(csrf()))
                    .andDo(print());

    //then
    resultActions
            .andExpect(status().isOk());
}

우선 코드가 동작하니까 넘어가고 다음에 더 찾아보자... 라고 생각하고 지나갔지만 spring security라는 기능에 종속적인 코드가 된 거 같아 찜찜함이 계속 남아 이에 대해서 해결할 수 있는 방법을 찾아보게 되었습니다.


제가 찾아낸 문제점은 @WebMvcTest는 컨트롤러 등 MVC와 관련된 빈들만 생성하기 때문에 기존의 SpringSecurity와 관련된 설정을 해놓았던 빈들이 등록되지 않고 기본 설정으로 진행되기 때문이었습니다.

따라서 저는 csrf를 비활성화했지만 이 설정이 제대로 동작하지 않았던 것입니다.

따라서 저는 테스트 코드에서 사용할 config 파일을 만들었습니다.

@TestConfiguration
public class TestSecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http.csrf(AbstractHttpConfigurer::disable) //csrf 토큰 disable 하기
                .authorizeHttpRequests(authorizeRequests ->
                        authorizeRequests
                                .anyRequest().authenticated()
                )
                .build();
    }
}

저는 csrf 관련 기능만 disable하고 모든 요청에 대해서는 이미 CommonControllerTest 클래스에서 authentication을 만들어주었기 때문에 security의 대상이 되도록 만들어주었습니다.

@Configuration이 아니라 @TestConfiguration으로 설정 파일을 만들어준 이유는 @Configuration으로 빈을 등록을 하면 테스트 코드의 profile은 test이고 실제 코드의 profile은 dev로 해놓는 등 서로 설정을 격리시켜 놓았는데 @Configuration만 하면 스프링 빈이 profile이 dev인 경우에도 등록될 위험이 있다고 생각하였기 때문입니다.

또한 일부 class에서만 필요한 설정 파일이기 때문에 @Profile("test")와 같은 방식으로 만들면 다른 @SpringBootTest와 같은 annotation이 붙은 테스트 코드에서도 해당 빈이 생성되는 비효율성이 존재한다고 생각하여 @TestConfiguration을 사용하였습니다.


@TestConfiguration을 사용하였기 때문에 아래와 같이 해당 설정 파일을 import 해주었습니다.

@Import(TestSecurityConfig.class)
public class CommonControllerTest

 

위와 같은 코드를 작성한 다음 아래와 같이 csrf 관련 코드를 없애니 정상적으로 동작하였습니다.

@Test
@DisplayName("폴더 삭제")
void deleteDirectoryTest() throws Exception {
    //given

    //when
    ResultActions resultActions =
            mockMvc.perform(delete("/goat/directory/temporal/" + PARENT_DIRECTORY1.getId()))
                    .andDo(print());

    //then
    resultActions
            .andExpect(status().isOk());
}


위와 같이 코드를 수정함으로써 csrf를 테스트 코드에서도 사용하지 않을 수 있게 되었습니다.

해당 로그는 spring security config를 테스트 코드에서 사용하지 않았을 때의 mockMvc의 요청입니다.

csrf 토큰을 사용하는 것을 확인할 수 있습니다.

MockHttpServletRequest:
      HTTP Method = DELETE
      Request URI = /goat/directory/temporal/1
       Parameters = {_csrf=[UN7dYCJGWAqtNH7ppekmruAC7qvqYQc48OppjH0QhoV7bPWtNOzoWBRyPGmAUEbZwMQSmdFmw8rdAzAVyItQuUlytOdKCcyZ]}
          Headers = []
             Body = null
    Session Attrs = {SPRING_SECURITY_CONTEXT=SecurityContextImpl [Authentication=UserAuthentication [Principal=2, Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[USER]]]}

아래의 코드는 spring security config를 적용했을 때의 요청으로 csrf를 사용하지 않는 것을 확인할 수 있습니다.

MockHttpServletRequest:
      HTTP Method = DELETE
      Request URI = /goat/directory/temporal/1
       Parameters = {}
          Headers = []
             Body = null
    Session Attrs = {SPRING_SECURITY_CONTEXT=SecurityContextImpl [Authentication=UserAuthentication [Principal=2, Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[USER]]]}

※테스트 코드를 작성하는 중 잘못했던 내용 ※

제가 처음 프로젝트 설정을 했을 때 아래와 같이 SpringBootApplication에 설정을 붙였었습니다.

@EnableJpaAuditing
@SpringBootApplication
public class ServerApplication {

    public static void main(String[] args) {
       SpringApplication.run(ServerApplication.class, args);
    }

}

위와 같은 코드를 작성함에 따라서 @EnableJpaAuditing 때문에 @MockBean(JpaMetamodelMappingContext.class) 라는 코드를 @WebMvcTest를 붙이는 모든 곳에 추가해야 했습니다.

해당 설정은 JPA 관련 설정인데 controller 단위 테스트에 이와 같은 설정이 필요하다는 것이 이상하다는 생각을 하게 되었고 아래와 같이 설정을 하면 @SpringBootTest에서 해당 설정을 뺄 수 있다는 것을 알게 되었습니다.

@Configuration
@EnableJpaAuditing
public class JpaAuditingConfig {

}

위와 같이 설정을 하면 controller 관련 설정이 아니게 되기 때문에 @WebMvcTest에서는 해당  스프링 빈을 생성하지 않게 됩니다.

테스트 코드를 작성하면서 책임의 분리를 하는 것이 얼마나 중요한 것인지 새삼 느끼게 되었습니다.

반응형

댓글