일단 시작.

WebMvcTest 403 Forbidden 해결하기(feat. CSRF) 본문

STUDY/Trouble Shooting

WebMvcTest 403 Forbidden 해결하기(feat. CSRF)

꾸양! 2024. 9. 27. 15:27

1. Issue Description

WebMvcTestMemberController의 로그인 메서드에 대한 테스트 코드를 작성하는 중 403 코드(Forbidden)가 발생하는 오류가 발생하였다.


2. 원인 추론

1️⃣ 첫 번째 의문
forbidden? forbidden은 보통 권한이 없을 때 볼 수 있는 에러코드인데, 나는 로그인 메서드 엔드포인트를 SecurityConfig에서 permitAll() 설정을 해준 상태였다. 왜 forbidden 코드가 떴을까?
WebMvcTest Spring Security 403으로 검색

CSRF 토큰 문제 때문이라고 한다. 실제로 테스트 실패 로그를 보니 세션 쪽에 CSRF 토큰이 있었다.

MockHttpServletRequest:
      HTTP Method = POST
      Request URI = /api/members/login
       Parameters = {}
          Headers = [Content-Type:"application/json;charset=UTF-8", Content-Length:"58"]
             Body = {"email":"test-user@email.com","password":"test-password"}
    Session Attrs = {org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository.CSRF_TOKEN=org.springframework.security.web.csrf.DefaultCsrfToken@499c4d61, SPRING_SECURITY_CONTEXT=SecurityContextImpl [Authentication=UsernamePasswordAuthenticationToken [Principal=Mock for UserDetailsImpl, hashCode: 1731036016, Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[]]]}
2️⃣ 두 번째 의문
SecurityConfig에서 CSRF를 disable() 했는데..? 왜 CSRF 토큰이 들어와 있지?

로그인 엔드포인트 permitAll()도 안 먹혀...
CSRF disable()도 안 먹혀...
SecurityConfig가 적용이 안 된다..
이게 내 결론이다(개그욕심)

좀 더 찾다가 https://seongonion.tistory.com/149 글을 보게 되었다.

Spring Boot 2의 Spring Security에서는 SecurityConfig 클래스를 WebSecurityConfigurerAdapter를 상속해서 구현했다. 이렇게 WebSecurityConfigurerAdapter를 상속한 클래스들은 @WebMvcTest 컴포넌트 스캔 대상이 된다.

그런데 Spring Boot 3의 Spring Security에서는 SecurityConfig 클래스를 @Configuration 을 통해 빈으로 등록한다. 따라서 @WebMvcTest의 컴포넌트 스캔 대상이 되지 못한다.

그래서 테스트 코드를 돌렸을 때 내 SecurityConfig 설정이 적용이 하나도 되지 않고 있었고, Spring Security의 기본 SecurityConfig 설정이 적용이 되고 있었던 것이다.


3. 해결

해결할 수 있는 방법은 두 가지가 있다.

  1. Spring Security의 기본 SecurityConfig 설정으로 유지하면서 테스트를 진행하는 방법
    • 이렇게 하는 경우 매 요청마다 CSRF 토큰을 넣어준다.
  2. 내 SecurityConfig 설정으로 바꿔서 테스트를 진행하는 방법

 

1번으로 해결하는 경우

다음 코드와 같이 csrf()를 추가해주면 된다. 하지만, 현재 내 SecurityConfig에서는 CSRF 토큰을 사용하지 않을 것이므로 테스트 목적에 어긋난다.

MvcResult result = mvc.perform(post("/foo").with(csrf()))
                .andExpect(status().isOk())
                .andReturn();

 

2번으로 해결하는 경우

내 SecurityConfig 설정으로 바꿔주려면 @Import 어노테이션을 이용하여 설정 파일을 가져오면 된다. 이 때, 내 SecurityConfig 클래스에 필요한 의존성도 같이 추가시켜줘야 한다. 나의 경우는 토큰과 관련된 JwtUtil, UserDetailsServiceImpl 의존성이 필요하여 @MockBean으로 의존성을 추가해주었다.

@Import(SecurityConfig.class)
@WebMvcTest(value = MemberController.class)
class MemberControllerTest {
    // SecurityConfig 의존성 관리
    @MockBean
    private JwtUtil jwtUtil;
    @MockBean
    private UserDetailsServiceImpl userDetailsService;

    ...

}

4. 결과

해결!


+) 추가 작성

@WithMockUser 어노테이션을 써서 인증된 사용자로써 요청을 보낼 수도 있다. 하지만 기본적으로 제공되는 UserDetails에서 제공되는 username, password, roles, authorities 필드만 사용할 수 있어서  내 경우 UserDetails는 email과 password를 쓸 수 있게끔 커스텀되어 있으므로 내 테스트의 경우와는 맞지 않는다.

아래는 @WithMockUser 를 사용하는 방법이다.

@WebMvcTest(YourController.class) // 테스트할 컨트롤러를 지정
public class YourControllerTest {

    @Autowired
    private MockMvc mockMvc;

    // username과 roles를 지정한 테스트
    @Test
    @WithMockUser(username = "testUser", roles = {"USER"})
    public void testUserRoleAccess() throws Exception {
        mockMvc.perform(get("/user-endpoint"))
                .andExpect(status().isOk());  // "USER" 권한으로 접근이 가능한 엔드포인트를 테스트
    }

    // "ADMIN" 역할로 접근이 제한된 경우
    @Test
    @WithMockUser(username = "testUser", roles = {"USER"})
    public void testAdminAccessDenied() throws Exception {
        mockMvc.perform(get("/admin-endpoint"))
                .andExpect(status().isForbidden()); // "ADMIN" 권한이 없으면 접근 불가
    }
}