Spring Security를 포함한 테스트 작성하기

개요

@WebMvcTest를 이용하면 웹 계층의 슬라이싱 테스트를 수행할 수 있습니다. 이는 웹 계층 요청과 관련된 필터, 인터셉터 등의 컨텍스트를 포함하여 테스트하게 된다는 의미입니다. 여기에는 당연히 Spring Security 필터 체인도 포함되는데, Spring Security 필터 체인이 DelegateFilterProxy로 동작한다는 것을 기억해야 합니다.

Spring Security를 포함하여 WebMvcTest를 수행할 때는 몇 가지 주의해야 할 점들이 있습니다. 이 글에서는 디버깅 과정을 통해 이를 해결하는 방법을 알아보겠습니다.

테스트 코드

먼저 테스트할 클래스는 다음과 같습니다:

@WebMvcTest(AuthController.class)
public class AuthControllerTest {
    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private AuthService authService;

    @Test
    @DisplayName("토큰 재발급 요청이 성공적으로 처리되어야 한다")
    void reissueAccessTokenTest() throws Exception {
        // Given
        String newAccessToken = "new.access.token";
        String newRefreshToken = "new.refresh.token";
        TokenResponse response = TokenResponse.builder()
            .accessToken(newAccessToken)
            .refreshToken(newRefreshToken)
            .build();
        when(authService.reissueAccessToken(any())).thenReturn(response);

        // When & Then
        mockMvc.perform(post("/v1/api/path")
                .contentType(MediaType.APPLICATION_JSON))
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.status").value(200))
                .andExpect(jsonPath("$.message").value("토큰 재발급 성공"))
                .andExpect(jsonPath("$.data.accessToken").value(newAccessToken))
                .andExpect(jsonPath("$.data.refreshToken").value(newRefreshToken));
    }
}

AuthController에서 호출하는 AuthService::reissueAccessToken() 메서드를 모킹하여 API 반환값을 검증하는 간단한 클래스입니다.

Security 설정

우리 애플리케이션은 다음과 같은 Spring Security 설정을 사용하고 있습니다:

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    @Bean
    protected SecurityFilterChain configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
            .httpBasic(HttpBasicConfigurer::disable)
            .csrf(CsrfConfigurer::disable)  // CSRF 비활성화 설정
            .cors(security -> {
                security.configurationSource(corsConfigurationSource);
            })
            .sessionManagement((sessionManagement) ->
                sessionManagement
                    .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers(PERMIT_ALL).permitAll()
                .requestMatchers(HttpMethod.GET, PERMIT_USER_GET).hasRole("USER")
                .requestMatchers(HttpMethod.POST, PERMIT_USER_POST).hasRole("USER")
                .requestMatchers(HttpMethod.DELETE, PERMIT_USER_DELETE).hasRole("USER")
                .requestMatchers(HttpMethod.PUT, PERMIT_USER_PUT).hasRole("USER")
                .requestMatchers(HttpMethod.PATCH, PERMIT_USER_PATCH).hasRole("USER")
                .anyRequest().authenticated()
            )
            .oauth2Login(oauth2 -> oauth2
                .userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig
                    .userService(customOAuth2Service)
                )
                .successHandler(oAuth2SuccessHandler))
            .addFilterBefore(new JwtExceptionHandlerFilter(),
                UsernamePasswordAuthenticationFilter.class)
            .addFilterBefore(new JwtAuthFilter(jwtProvider),
                UsernamePasswordAuthenticationFilter.class)
            .exceptionHandling(handler -> handler
                .accessDeniedHandler(accessDeniedHandler)
                .authenticationEntryPoint(authenticationEntryPoint)
            );

        return httpSecurity.build();
    }

    String[] PERMIT_ALL = { "PATHS" };
    String[] PERMIT_USER_GET = { "PATHS" };
    // ...
}

 

테스트 실행 및 문제 발견

테스트를 실행해보면, 200 상태코드를 기대했으나 403 Forbidden이 반환되며 테스트가 실패합니다.

"Forbidden? 내가 시큐리티에서 Forbidden을 명시적으로 던진 적이 있던가?"라는 의문이 생깁니다.

디버깅 시작

문제를 찾기 위해 브레이크포인트를 설정하고 디버깅을 시작했습니다.

Spring Security의 필터 체인 호출과 관련된 doFilter()doFilterInternal() 메서드에 브레이크포인트를 설정하고 디버깅을 계속 진행했습니다.

필터 체인을 따라가다 보면 다음 순서로 필터들이 실행됩니다:

  1. DisableEncodeUrlFilter
  2. WebAsyncManagerIntegrationFilter
  3. HeaderWriterFilter
  4. CorsFilter
  5. CsrfFilter

필터 체인을 따라가다 보면 결국 CsrfFilter에 도달하고, 이곳에서 403 응답을 내려보내는 것을 확인했습니다.

resolverCresfTokenValue()에서 실제 토큰(actualToken)을 가져와야 합니다. 

 

이 코드는 다음과 같이 생겼는데,

public final class XorCsrfTokenRequestAttributeHandler extends CsrfTokenRequestAttributeHandler {
    public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
        String actualToken = super.resolveCsrfTokenValue(request, csrfToken);
        return getTokenValue(actualToken, csrfToken.getToken());
    }
}

 

부모 클래스인 CsrfTokenRequestAttributeHandler는 요청 헤더에서 토큰을 가져오려고 합니다.

물론, csrf 토큰 관련 설정을 하지 않았으니 그런것은 존재하지 않죠.

 

 

그 결과 MissingCsrfTokenException이 발생합니다.

이는 AccessDeniedHandler에서 처리되어 클라이언트에게 FORBIDDEN 응답을 내리게 됩니다.

response 객체를 확인해보면 "403 Forbidden" 상태 코드가 설정되어 있는 것을 볼 수 있습니다.

 

원인 분석

CsrfFilterdoFilterInternal() 메서드를 자세히 살펴보면:

protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) {
    DeferredCsrfToken deferredCsrfToken = this.tokenRepository.loadDeferredToken(request, response);
    request.setAttribute(DeferredCsrfToken.class.getName(), deferredCsrfToken);
    CsrfTokenRequestHandler var10000 = this.requestHandler;
    Objects.requireNonNull(deferredCsrfToken);
    var10000.handle(request, response, deferredCsrfToken::get);

    if (!this.requireCsrfProtectionMatcher.matches(request)) {
        // CSRF 보호가 필요없는 요청은 그냥 통과
        filterChain.doFilter(request, response);
    } else {
        CsrfToken csrfToken = deferredCsrfToken.get();
        String actualToken = this.requestHandler.resolveCsrfTokenValue(request, csrfToken);

        if (!equalsConstantTime(csrfToken.getToken(), actualToken)) {
            boolean missingToken = deferredCsrfToken.isGenerated();
            // 로그 메시지...
            AccessDeniedException exception = !missingToken ? 
                new InvalidCsrfTokenException(csrfToken, actualToken) : 
                new MissingCsrfTokenException(actualToken);
            this.accessDeniedHandler.handle(request, response, (AccessDeniedException)exception);
        } else {
            filterChain.doFilter(request, response);
        }
    }
}

 

앞서, 요청에 CSRF 토큰이 없어서 missingTokentrue가 되고, 이로 인해 MissingCsrfTokenException이 발생하면서 403 Forbidden이 반환되고 있음을 알았습니다.

그런데 돌이켜 생각해보면, 우리의 Security 설정을 보면 CSRF 보호는 비활성화되어 있습니다(csrf(CsrfConfigurer::disable)). 그렇다면 왜 여전히 CSRF 필터가 동작하고 있을까요?

 

WebMvcTest와 Spring Security 자동 구성

API 문서를 찾아보니 @WebMvcTest는 Spring Security를 자동 구성한다고 나와있습니다:

By default, tests annotated with @WebMvcTest will also auto-configure Spring Security and MockMvc (include support for HtmlUnit WebClient and Selenium WebDriver).

https://docs.spring.io/spring-boot/3.3/api/java/org/springframework/boot/test/autoconfigure/web/servlet/WebMvcTest.html

즉, 자동 구성이 적용됨에 따라 내가 작성한 SecurityConfig.class가 아닌 자동 구성된 기본 Spring Security 설정이 적용되고 있었던 것입니다. 디버깅 시 필터 체인에 우리가 선언한 JwtAuthFilter가 포함되지 않은 것도 이 때문입니다.

 

해결 방법들

문제를 알았으니 해결 방법에 대해서도 알아보겠습니다.

방법 1: SecurityAutoConfiguration 비활성화

이 방법은 Security를 적용하지 않고 우회하는 방식입니다. (OAuth 관련 구성도 함께 배제해야 빈 생성에 문제가 발생하지 않습니다)

@WebMvcTest(controllers = AuthController.class, excludeAutoConfiguration = {
    SecurityAutoConfiguration.class,
    OAuth2ClientAutoConfiguration.class,
    OAuth2ResourceServerAutoConfiguration.class
})
//@Import(SecurityConfig.class)
public class AuthControllerTest {
    // 테스트 코드
}

방법 2: 실제 SecurityConfig 클래스 Import

@WebMvcTest(controllers = AuthController.class)
@Import(SecurityConfig.class)
public class AuthControllerTest {
    // 테스트 코드
}

이 방법을 사용할 때는 주의할 점이 있습니다. @WebMvcTest@Component로 선언한 빈을 불러오지 않습니다. 따라서 사용 중인 SecurityConfig.class가 다른 컴포넌트 빈에 의존한다면 해당 빈까지 함께 불러오도록 설정해야 합니다.

방법 3: 테스트용 SecurityConfig 클래스 만들기

@TestConfiguration
static class TestSecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .authorizeRequests()
            .anyRequest().permitAll(); // 테스트에서는 모든 요청 허용

        return http.build();
    }
}

테스트 클래스에서는 다음과 같이 사용합니다:

@WebMvcTest(YourController.class)
@Import(YourControllerTest.TestSecurityConfig.class)
public class YourControllerTest {
    // 테스트 코드
}

방법 4: with(csrf()) 사용하기 (자동 구성 사용)

@WebMvcTest(controllers = AuthController.class)
public class AuthControllerTest {
    @Test
    @DisplayName("토큰 재발급 요청이 성공적으로 처리되어야 한다")
    void reissueAccessTokenTest() throws Exception {
        // Given
        // ...

        // When & Then
        mockMvc.perform(post("/v1/auth/reissue")
            .contentType(MediaType.APPLICATION_JSON)
            .with(csrf()))  // CSRF 토큰 추가
            .andDo(print())
            .andExpect(status().isOk())
            // ...
    }
}

이 방법은 모든 요청에 .with(csrf())를 추가해야 하는 부담이 있습니다.

 

+인증(Authentication) 관련 문제

CSRF 토큰 문제를 해결하더라도 테스트가 실패할 수 있습니다. 특히 방법 4를 사용할 경우, 다음과 같은 응답이 반환될 수 있습니다:

리다이렉트 URL을 확인해보면 login 페이지로 리다이렉션되고 있습니다. 이는 인가(Authorization) 문제로 인해 로그인 페이지로 리다이렉트되는 것입니다.

디버깅을 통해 살펴보면 AuthorizationFilter를 거치면서 인가 여부를 체크하고 AccessDeniedException이 발생하는 것을 확인할 수 있습니다:

이는 ExceptionTranslationFilter에 의해 처리되고 최종적으로 LoginUrlAuthenticationEntryPoint.commence() 메서드가 호출되어 로그인 페이지로 리다이렉션됩니다. (이는 AuthenticationEntryPoint의 구현체입니다)

해결 방법

이 문제는 @WithMockUser 어노테이션을 사용하여 해결할 수 있습니다:

@WebMvcTest(controllers = AuthController.class)
public class AuthControllerTest {
    @Test
    @WithMockUser("testUser")  // 테스트용 사용자 인증 추가
    @DisplayName("토큰 재발급 요청이 성공적으로 처리되어야 한다")
    void reissueAccessTokenTest() throws Exception {
        // Given
        // ...

        // When & Then
        mockMvc.perform(post("/v1/auth/reissue")
            .contentType(MediaType.APPLICATION_JSON)
            .with(csrf()))
            .andDo(print())
            .andExpect(status().isOk())
            // ...
    }
}

@WithMockUser를 사용하면 디버깅을 통해 Authentication 객체에 "testUser"라는 사용자 정보가 포함된 것을 확인할 수 있습니다:

UsernamePasswordAuthenticationToken [Principal=org.springframework.security.core.userdetails.User [Username=testUser, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, CredentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[ROLE_USER]], Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[ROLE_USER]]

 

이는 우리가 @WithMockUser를 통해 설정한 테스트용 유저의 이름입니다.

 

테스트는 이제 성공적으로 통과합니다:

결론

CSRF에 대해서는 개인적으로는 방법 2나 3을 사용하는 것이 좋을 것 같습니다. 4는 부담스럽고, 1은 상황에 따라 고려를 해봐야겠습니다.

 

그런데 여기서 고민해야할 부분이 하나 더 있습니다.

Api 계층의 단위 테스트를 수행하는데, Spring Security와 관련된 세부사항들을 함께 검증하는게 올바른 방법일까요?

개인적인 생각으로는, Spring Security 모듈을 불러오는 것은 단위 테스트라는 목적에 부합하지 않을 뿐더러 불필요한 복잡성을 추가한다고 생각합니다. 예를들어 프로젝트가 커져서 MSA 구조가 된다면 인증/인가 관련 정보는 API Gateway와 인증 서버에서 처리하고 뒷단 서버에서는 이를 신뢰하는 구조가 될 것입니다. 이러한 구조에서는 각 서비스에서 인증/인가를 위한 로직을 중복 처리할 필요가 없습니다. 일반적인 모놀리식 환경에서도 인증/인가 관련 로직을 분리하여 테스트하는 것이 테스트 목적에 더 부합하고 웹 계층 검증이라는 명확한 목표에 더 집중할 수 있는 방법이 아닐까 싶습니다.

참고 자료

https://docs.spring.io/spring-security/site/docs/5.0.x/reference/html/test-method.html

https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/form.html

https://sedangdang.tistory.com/303

댓글