@TestConfiguration 으로 시큐리티 테스트 환경 구성하고, 학습하기

Kim Dong Kyun·2024년 9월 4일
1
post-thumbnail

저번 게시물 에서, 세션 방식으로 간단히 OAuth2 인증을 처리하는 방법을 소개했다. 간단한 동작 방식은 아래와 같다.

  1. Spring Security + Oauth2 Client 를 사용하면, Security 의 Deafult 필터를 통해서 인증을 거치게 된다.
  2. 이 필터의 인증 방식은 세션 방식으로 이루어진다.
  3. 세션 안에 Security Context 에 사용자의 정보가 저장된다.

원래는 깃허브 리뷰 할당 프로젝트를 개인 프로젝트로 하려다가, 프론트분들과 디자이너분들도 합류해서 같이 프로젝트를 진행하기로 변경했기 때문에 인증 방식을 좀 바꾸고 싶었다. (세션을 사용하지 않고, JWT에 사용자의 인증 정보를 암호화하여 담는 방식으로)

따라서 Security의 세션 config를 변경해야했다. 그리고 컨피그를 변경하면서, 테스트 코드를 어떻게 변경해야 할 지도 고민했다. (세션이 있는 상태에서, 없는 상태로 변경되었으니까)

그런데, 실패할 줄 알았던 세션을 사용하는 테스트가, 성공하는디?


왜?

@WebMvcTest 어노테이션을 활용한 테스트에서는 기본적으로 세션을 생성하지 않는다. 다음 테스트를 보자.

@WebMvcTest(controllers = {AuthController.class})
class StatelessSessionTest {

	// ...

    @Test
    void testWebMvcTestUsesMockSessions() throws Exception {
        MvcResult mvcResult = mockMvc.perform(get("/oauth2/login"))
                .andExpect(status().isOk())
                .andReturn();

        HttpSession session = mvcResult.getRequest().getSession(false);
        assertThat(session).isNull(); // 아무 설정이 없다면 세션을 생성하지 않는다.
    }
}
  • 테스트는 성공한다.

그러나, 우리가 이전에 만든 커스텀 어노테이션에서 Security Context 를 설정했었다. 기억이 가물가물하니 다시 봐보자.

public class WithMockOAuth2UserSecurityContextFactory implements WithSecurityContextFactory<WithMockOAuth2User> {

    @Override
    public SecurityContext createSecurityContext(WithMockOAuth2User withMockOAuth2User) {
        SecurityContext context = SecurityContextHolder.createEmptyContext();

        Map<String, Object> attributes = Arrays.stream(withMockOAuth2User.attributes())
                .map(attr -> attr.split(":"))
                .collect(Collectors.toMap(arr -> arr[0], arr -> arr[1]));

        CustomOauth2User principal = new CustomOauth2User(
                (String) attributes.get(withMockOAuth2User.nameAttributeKey())
        );

        context.setAuthentication(new OAuth2AuthenticationToken(principal, principal.getAuthorities(), "github"));

        return context;
    }
}
  • @WithMockOauth2User 라는 어노테이션을 사용하면, 위와 같은 시큐리티 컨텍스트를 사용 할 수 있다.
  • 그렇다면, 이 어노테이션을 사용한다면 세션도 있고, 세션안에 시큐리티 컨텍스트도 존재 할 것이다.
  • 똑같은 테스트를 작성하되, 어노테이션만 붙여보자. 어떤 이유로 실패하는지가 궁금하다.
@WebMvcTest(controllers = {AuthController.class})
class StatelessSessionTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private JwtAuthenticationFilter jwtAuthenticationFilter;

    @Test
    @WithMockOAuth2User(attributes = {"name:99999"})
    void testSecurityContextNotStoredInSession_Stateless() throws Exception {
        MvcResult mvcResult = mockMvc.perform(get("/oauth2/login"))
                .andExpect(status().isOk())
                .andReturn();

        HttpSession session = mvcResult.getRequest().getSession(false);
        assertThat(session).isNull(); // 세션이 존재해야 하므로, 실패해야함.
    }

    @Test
    void testWebMvcTestUsesMockSessions() throws Exception {
        MvcResult mvcResult = mockMvc.perform(get("/oauth2/login"))
                .andExpect(status().isOk())
                .andReturn();

        HttpSession session = mvcResult.getRequest().getSession(false);
        assertThat(session).isNull(); // 아무 설정이 없다면 세션을 생성하지 않는다.
    }
}

위 테스트는 실패한다. 우리가 설정한 어노테이션에서 시큐리티 컨텍스트를 집어넣어줬기 때문에.

이 테스트를 통해서

  1. @WebMvcTest 를 이용한 테스트 환경은, 별다른 설정이 없다면 시큐리티 관련 컨피그의 영향을 받지 않는다.
  2. 우리가 직접 만든 어노테이션과 컨텍스트 팩토리를 통해서 시큐리티 컨텍스트를 넣어 줄 수 있다. (MockHttpSession 이라는 객체로 저장된다)

는 사실을 알았다. 그렇다면, 세션이 있을 때와 없을 때를 테스트하기 위해서 어떤 방법을 써야 할까?


@Import

예~ 전에, @Import vs @ComponentScan 을 다룬 적이 있었다. 여기서 @Import 어노테이션은 어플리케이션 구동에 필요한 빈 팩토리에 클래스 하나(혹은 여러개) 를 추가 할 수 있다고 했었다.

그렇다면 @Import 어노테이션을 통해서 내가 직접 구성요소를 편집하여, 테스트 환경을 구성 할 수도 있을 것이다.

1. Session 있는 상태

@TestConfiguration
public class IfRequiredSecurityConfig {
    @Bean
    @Primary
    public SecurityFilterChain config(HttpSecurity http) throws Exception {
        return http
                .sessionManagement(a -> a.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED))
                .build();
    }
}
  • @Primary 어노테이션은 빈으로 등록된 후보자(Candidates) 가 여러 개일 때, 이 어노테이션이 붙은 빈을 우선적으로 선택하게 해주는 어노테이션이다.

  • 간단하게, 세션 상태는 IF_REQUIRED 로 결정했다.

테스트코드도 작성해보자.

@WebMvcTest(controllers = {AuthController.class})
@Import(IfRequiredSecurityConfig.class)
@DisplayName("세션 상태가 IF_REQUIRED 일 때를 테스트한다.")
class WithSessionTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private JwtAuthenticationFilter jwtAuthenticationFilter;

    @Test
    @WithMockOAuth2User(attributes = {"name:99999"})
    @DisplayName("SecurityContext 는 세션에 저장된다. (IF_REQUIRED)")
    void testSecurityContextStoredInSession_IfRequired() throws Exception {
        MvcResult mvcResult = mockMvc.perform(get("/oauth2/login"))
                .andExpect(status().isOk())
                .andReturn();

        HttpSession session = mvcResult.getRequest().getSession(false);
        assertThat(session).isNotNull();

        SecurityContext securityContext = (SecurityContext) session.getAttribute("SPRING_SECURITY_CONTEXT");
        assertThat(securityContext).isNotNull();

        OAuth2User principal = (OAuth2User) securityContext.getAuthentication().getPrincipal();
        assertThat(principal.getName()).isEqualTo("99999");
    }
}
  • 디스플레이 네임 대로, 세션이 있을 때 (IF_REQUIRED) 일 때를 테스트한다.
  • 만들어둔 @WithMockOAuth2User 어노테이션의 설정에 따라, 시큐리티 컨텍스트에 저장된다.

오케이 여기까지는 뭐...그렇다면, STATELESS 설정으로 하면 어떻게될까?


2. 세션이 없는 상태

똑같은 방식으로 컨피규레이션을 잡아준다.

@TestConfiguration
public class StatelessSecurityConfig {
    @Bean
    @Primary
    public SecurityFilterChain config(HttpSecurity http) throws Exception {
        return http
                .sessionManagement(a -> a.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .build();
    }
}

테스트코드도 작성한다.

@WebMvcTest(controllers = {AuthController.class})
@Import(StatelessSecurityConfig.class)
@DisplayName("세션 상태가 STATELESS 일 때를 테스트한다.")
class StatelessSessionTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private JwtAuthenticationFilter jwtAuthenticationFilter;

    @Test
    @WithMockOAuth2User(attributes = {"name:99999"})
    @DisplayName("SecurityContext 는 세션에 저장되지 않는다. (STATELESS)")
    void testSecurityContextNotStoredInSession_Stateless() throws Exception {
        MvcResult mvcResult = mockMvc.perform(get("/oauth2/login"))
                .andExpect(status().isOk())
                .andReturn();

        HttpSession session = mvcResult.getRequest().getSession(false);
        assertThat(session).isNull(); // Stateless 설정에서는 세션이 null이어야 함
    }

    @Test
    void testWebMvcTestUsesMockSessions() throws Exception {
        MvcResult mvcResult = mockMvc.perform(get("/oauth2/login"))
                .andExpect(status().isOk())
                .andReturn();

        HttpSession session = mvcResult.getRequest().getSession(false);
        assertThat(session).isNull(); // 아무 설정이 없다면 세션을 생성하지 않는다.
    }
}
  • 시큐리티 컨텍스트를 만들도록 설정하는 어노테이션과 함께하는 테스트 1과, 별다른 조작 없는 상태에서의 테스트 2를 작성했다.
  • 테스트1은 우리의 예상대로라면 Configuration 에서 직접 만든 Security Context 가 존재해야 할 것이다.
  • 그러나, 테스트는 둘 다 성공한다.

세 줄 요약

  1. 시큐리티 필터, UserNameAuthenticationFilter 라는 스프링 시큐리티 디폴트 필터에서는 Security Context에 저장된 유저의 정보를 판별해서 인증여부를 결정한다.
  2. 그러므로 UserNameAuthenticationFilter 이전에 필터를 끼워넣어서 시큐리티 컨텍스트를 먼저 set 해두면, 세션 상태가 STATELESS 여도 문제없이 인증이 잘 된다.
  3. @TestConfiguration 및 @Import 를 사용해서 테스트 환경에서 사용할 빈들을 결정 할 수 있다.

0개의 댓글