티스토리 뷰
[Spring] 스프링 시큐리티 환경에서 @WebMvcTest 사용 시에 발생하는 401, 403 에러 해결하기
heemang.dev 2024. 10. 11. 02:22
우리 프로젝트는 Spring Security를 사용하여 사용자의 인증 정보와 권한 정보를 관리하고 있다. 따라서 사용자의 인증과 권한 정보에 따라서 API 요청이 서버로 도달하게 할 것인지 결정한다.
@WebMvcTest 애너테이션을 사용하여 Spring MVC의 Presentation Layer 테스트가 가능하다. 즉, 스프링에서는 Controller에 대한 테스트를 하게 된다.
1. 테스트 환경
1-1. Controller
- @CurrentUser 애너테이션을 사용하여, 요청 헤더에 담긴 Bearer 토큰을 통해 인증된 사용자 정보를 가져온다.
- @ResponseStatus(HttpStatus.CREATED)를 통해 응답 상태 코드로 201을 갖는다.
@RestController
@RequestMapping("/sections")
@RequiredArgsConstructor
@SecurityRequirement(name = "JWT")
public class SectionController {
private final SectionService sectionService;
private final CommentService commentService;
@ResponseStatus(HttpStatus.CREATED)
@PostMapping
public CommonApiResponse<CreateSectionResponse> createSection(@CurrentUser User user,
@Valid @RequestBody CreateSectionRequest request) {
CreateSectionResponse response = sectionService.createSection(user, request);
return CommonApiResponse.successResponse(HttpStatus.CREATED, response);
}
}
1-2. 라이브러리 추가
MockMvc 테스트를 할 때 Spring Security 설정이 필요하므로, Spring Security와 관련된 테스트를 지원하는 라이브러리를 build.gradle에 추가한다. 예를 들어, 테스트 시에 인증된 사용자를 추가하거나 요청 헤더에 CSRF 토큰을 추가할 수 있다.
testImplementation 'org.springframework.security:spring-security-test'
2. 401 예외가 아닌 403 예외가 발생한다?
2-1. 테스트 코드
@WebMvcTest(controllers = SectionController.class)
class SectionControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private SectionService sectionService;
@MockBean
private CommentService commentService;
@Test
@DisplayName("신규 회고카드를 등록한다.")
void createSection() throws Exception {
//given
CreateSectionRequest request = CreateSectionRequest.builder()
.retrospectiveId(1L)
.templateSectionId(2L)
.sectionContent("내용")
.build();
//when //then
mockMvc.perform(
post("/sections")
.content(objectMapper.writeValueAsString(request))
.contentType(APPLICATION_JSON)
)
.andDo(print())
.andExpect(status().isCreated())
.andExpect(jsonPath("$.code").value("201"))
.andExpect(jsonPath("$.message").doesNotExist());
}
}
2-2. 403 예외 발생
Presentation Layer 테스트 코드를 처음으로 작성한 필자는 당당하게 테스트 실행을 해보았다. 그러나.... 아래 사진과 같이 상태 코드로 201을 기대했지만, 실제로는 403을 반환한다. 내가 알고 있는 403 예외는 사용자가 인증은 되었으나, 권한이 없어 API 요청을 거절한 것으로 알고 있다.
그런데 내가 알고 있는 게 잘못된 것인가? 의문이 든다. 생각해 보면 @WebMvcTest를 사용하여 테스트할 때 사용자 정보를 제공하지 않았는데 401 예외가 발생하지 않았다. 반면에 403 에러는 발생한다...? 이해되지 않는 상황이다.
3. 요청 헤더에 담긴 정보가 이상하다.
3-1. 요청을 처리하는 핸들러가 없다.
스프링에서는 사용자의 요청을 처리하기 위해 핸들러(handler)가 필요하다. 그러나 @WebMvcTest 환경에서 API 요청을 하였을 때, 이 요청을 처리하는 핸들러가 매핑되지 않았다.
3-2. 테스트 환경에서는 csrf.disable()이 적용되지 않는다.
우리 프로젝트는 CSRF 보호를 비활성화하기 위해서 csrf.disable()를 사용한다. 따라서 애플리케이션 레벨에서 사용자의 요청을 받을 때 CSRF 토큰을 요구하지 않는다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.. 생략
.csrf((csrf) -> csrf.disable())
}
}
아래 사진을 통해 서버 측 세션에는 Spring Security에 의해 생성된 CSRF 토큰이 담겨있다. 이 토큰은 클라이언트가 보내는 CSRF 토큰과 비교하기 위한 용도로 사용된다.
CSRF 토큰 검증에 실패하면 서버는 403 Forbidden 오류를 반환한다. 엇... 오류가 발생하는 원인을 찾은 것 같다.
사용자 인증 정보를 포함하지 않았기 때문에 401 예외가 발생해야 할 것 같은데, 401이 아닌 403 예외를 반환하는 걸 봐서는 CSRF 검증에 실패한 것으로 예상이 된다.
따라서 요청 헤더에 CSRF 토큰을 추가하기 위해 with(csrf())를 추가하였다. with(csrf())는 스프링 시큐리티가 자동으로 생성하는 CSRF 토큰을 요청 헤더에 추가하여 서버 측의 세션에 저장된 CSRF 토큰과 비교할 수 있도록 한다.
요청 헤더에 CSRF 토큰을 추가하니 이전과 달리 Session Attrs에 담긴 CSRF 토큰이 사라졌다. 요청 헤더에 담긴 CSRF 토큰과 서버 측 세션에 담긴 CSRF 토큰이 일치함에 따라 사라진 듯하다. 그러나 여전히 요청을 처리하는 Handler는 못 찾고 있는 듯하다.
이전과 달라진 게 하나 더 있다면, 에러 코드가 403이 아닌 401로 바뀌었다는 것이다. 앞전에 사용자 인증 정보가 없는데 왜 401이 안 뜨고 403이 뜨는지 의아했는데, CSRF 토큰 문제를 해결하면서 드디어 401 에러가 발생하게 되었다.
4. 401 에러를 해결해보자.
요청 헤더에 CSRF 토큰을 추가해 403 에러는 해결되었고, 이제 401 에러를 해결할 차례이다. 401 에러는 인증 정보가 없을 때 발생하므로, 테스트 환경에 사용자 정보를 추가하면 해결할 수 있다.
스프링 시큐리티 테스트 환경에서는 @WithMockUser 애너테이션을 사용해 Mock 사용자를 생성할 수 있다. @WithMockUser는 테스트에서 인증된 가짜 사용자를 생성하며, 기본적으로 “user”라는 이름과 “ROLE_USER” 권한을 가진 사용자가 생성된다. 또한, @WithMockUser(username = "user1", roles = {"USER", "ADMIN"})처럼 사용자 이름과 역할을 직접 지정할 수도 있다.
따라서, 테스트에서 인증 정보가 없어 발생하는 401 에러를 해결하기 위해 @WithMockUser 애너테이션을 사용하여 가짜 사용자 정보를 추가하였다.
그 결과, 아래 사진처럼 401 에러를 해결하고 드디어 테스트 통과가 되었다..!
또한 서버 측 세션 정보에 인증된 사용자 정보가 저장되었고, 이 요청을 처리하는 Handler 또한 확인할 수 있다.
5. 결론
실제 애플리케이션에서 CSRF 보호를 비활성화했더라도, @WebMvcTest로 테스트할 경우 테스트 환경에서는 기본적으로 CSRF 보호가 활성화된다. 따라서 요청에 CSRF 토큰을 추가해야 한다. 또한, Spring Security를 사용하는 환경에서는 인증된 사용자 정보가 필요하므로, 테스트 시 @WithMockUser 애노테이션을 사용해 가짜 인증 정보를 제공해야 한다.
또한 with(csrf()) 설정을 자동화할 수 있는데, 이와 관련된 내용은 다음 글을 확인한다. -> 클릭📌