[Spring]Security적용 후 Controller 테스트코드 작성 시 발생했던 오류들 (Feat. Junit5, csrf)

JANG SEONG SU·2023년 11월 9일
0

Sping

목록 보기
9/9
post-thumbnail

🟥401 오류 일대기

Controller에서 모든 Board를 조회하는 메소드를 테스트 코드를 작성하던 중 일어난 일이다. 우선 코드를 먼저보자

@Test
void index_success() throws Exception {
        //given
        String index = "index";
        Pageable pageable = PageRequest.of(0,6);
        Page<BoardDetailDTO> pageResult =
                new PageImpl<>(List.of(defaultBoardDetailDto), pageable, 0);
        given(boardService.findAllBoards(any(Pageable.class)))       //any 필수!!
                .willReturn(pageResult);

        //when
        //then
        mvc.perform(
                get("/")
                        .contentType(contentType)
                )
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(view().name(index));
    }

사실 이 로직상은 아무 문제가 없다.

MockHttpServletResponse:
           Status = 401
    Error message = Unauthorized
          Headers = [WWW-Authenticate:"Basic realm="Realm"", X-Content-Type-Options:"nosniff", X-XSS-Protection:"0", Cache-Control:"no-cache, no-store, max-age=0, must-revalidate", Pragma:"no-cache", Expires:"0", X-Frame-Options:"DENY"]
     Content type = null
             Body = 
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

java.lang.AssertionError: Status expected:<200> but was:<401>
Expected :200
Actual   :401
<Click to see difference>

하지만 다음과 같이 401 Unauthorized가 발생하는 이유가 도대체 뭘까..하다가😮‍💨

느낌이 Security와 관련된 문제라는 직감이 들었다. 왜냐하면 401오류는 인증/인가 중 인증의 문제이기 때문이다.

하지만 Security 설정을 다시 확인해 보니, 해당 테스트 코드의 url은 @GetMapping("/")비로그인 사용자도 접근이 허용된 곳이었다!!!

서칭을 해보니 접근 허용이 된 url임에도 불구하고, 401 Unauthorized가 뜨는 이유는 바로 @WebMvcTest와 관련되어 있는거 같다.

@WebMvcTest(BoardController.class)
@MockBean(JpaMetamodelMappingContext.class)
public class BoardControllerTest {
...
}

위 테스트 코드의 클래스는 단위테스트를 위해 다음과 같은 어노테이션이 셋팅되어 있는 상태다.

공식 문서에는 다음과 같이 나와있다.

Annotation that can be used for a Spring MVC test that focuses only on Spring MVC components.
Using this annotation will disable full auto-configuration and instead apply only configuration relevant to MVC tests

@SpringBootTest와 달리, @WebMvcTest는 모든 Bean들을 불러오지 않고, 오직 SpringMVC에 필요한 Bean들만 불러온다는 것이다. 그렇다면 Security와 관련된 Bean들을 불러오지 않아서 발생한 문제라고 예측하였다.

그런데 좀 더 읽어보니,

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

Spring Security와 MockMvc에 대한 것은 auto-configure 한다고 적혀있다.


그 전에 Mocking에 대해 잠깐 다루겠다.

Springboot에서 컨트롤러 테스트를 진행할 때, Mock이라는 가상의 객체를 만들어(Mocking) 접속을 테스트하는 방식으로 진행한다.

그 이유는 Mock을 하지 않으면 단위 테스트가 불가능하고, 많은 의존성이 엮어 있어 생성 과정이 복잡한 객체를 테스트 하기 위해 생성해야 한다.(이때, 많은 시간이 소요됨)

하지만 Mocking은 테스트에 필요한 기능만 있는 가짜 객체를 만들고, Mock 객체를 이용하여 의존성을 단절시킬 수 있어 비교적 쉽고 가벼운 테스트가 가능하다.

이러한 이유로, 웹 어플리케이션에서 Controller를 테스트할 때, 서블릿 컨테이너를 모킹하기 위하여 @SpringBootTest + @AutoConfigureMockMvc 또는 @WebMvcTest를 사용하면 된다.

  • 여기서 서블린 컨테이너를 모킹한다는 것은 실제 서블릿 컨테이너가 아닌 테스트용 모형 컨테이너를 구동하여 DispatcherServlet 객체를 메모리에 올린다는 것.

따라서

@SpringBootTest
@AutoConfigureMockMvc
public class BoardControllerTest {
...
}

와 같이 설정하면 우리가 정의한 Spring Security Configuration이 불러와지기 때문에, 401 오류는 발생하지 않는다.

하지만 우리는 초기에 @WebMvcTest를 사용한 단위테스트로 시작했고, 이것을 유지하면서 해결하고 싶다.

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

아까 확인했던 것을 다시 가져오면 @MockMvcTestauto-configure Spring SecurityMockMvc를 가져온다고 했다. 하지만 우리가 정의한 Security Configuration이 불러오지지 않아서 오류가 발생했는데, 뭔가 모순이지 않나?라는 생각이 들거다.

위에서 말한 auto-configure Spring Security는 말 그대로 Spring Security가 자동으로 구성하는 Configuration 파일들을 불러온다는 의미이고,
우리가 설정한 Security Configureation은 아무 관계가 없다!!


자동으로 구성되는 많은 클래스 중 SpringBootWebSecurityConfiguration를 살펴보면 아래와 같다.

@Configuration(proxyBeanMethods = false)
@ConditionalOnDefaultWebSecurity
@ConditionalOnWebApplication(type = Type.SERVLET)
class SpringBootWebSecurityConfiguration {

	@Bean
	@Order(SecurityProperties.BASIC_AUTH_ORDER)
	SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
		http.authorizeRequests()
     	.anyRequest().authenticated()
        .and()
        .formLogin()
        .and()
        .httpBasic();
		return http.build();
	}
}
  • .authorizeRequests() : 요청에 대한 권한을 지정할 수 있다.
  • .anyRequest().authenticated() : 인증이 되어야 한다는 이야기이다.

모든 요청에 대하여 아무 권한을 가지고 있다면 허용되도록 기본적으로 설정되어 있다.
그렇기 때문에 "/" 요청에 대하여 특별한 권한 설정을 해주지 않았으므로 401 Unauthorized 에러가 발생할 수 밖에 없었다.

🙌그러므로 요청을 할 때 권한을 같이 넘겨주면 해결 가능이다! 🙌


해결 방법

요청 시에 권한을 함께 넘겨주는 방법은 세 가지가 있다.
1) ExcludeFilter를 이용해 Security를 회피하는 방법
2) @WithMockUser, @WithUserDetails 등의 Annotation을 이용해 권한을 함께 요청하는 방법
3) @TestConfiguration 파일을 별도로 설정하는 방법

필자는 이 중 2번의 방법을 활용했다. (사실상 1번은 해결이 아닌 회피)

인증된 Mock 유저를 생성하는 이유

  • 정상적으로 호출할경우 로그인을 한 뒤에 발급받은 AccessToken을 이용하여 Header에 넣고 호출해야 하지만 테스트 단계에서 그렇게 까지 하기에는 정말 중요한 코드보다 그것을 실행하기 위한 사이드 코드가 많아진다.
    출처 - https://lemontia.tistory.com/1088
  • @WithMockUser - 인증된 사용자
  • @WithAnonymousUser - 미인증 사용자 (principal에서 "anonymous"가 들어가있음)
  • @WithUserDetails - 메서드가 principal 내부의 값을 직접 사용하는 경우 (별도의 사전 설정 필요)

따라서 @WithMockUser를 붙여주면 권한이 함께 넘어가기에 401 에러가 사라질 것이다.

@Test
@WithMockUser(username = "test")
void index_success() throws Exception {
    ...

이렇게 말이다! 오류 해결 끝!


🟧403 오류 일대기

@Test
@WithMockUser(username = "test")
void addBoard_success() throws Exception {

	//given
	String title = "title";
	String content = "content";
	given(boardService.createBoard(any(), any(CreateBoardDTO.class)))
                .willReturn(defaultBoardDetailDto);

	//when
	//then
	mvc.perform(
		post("/board/addForm")
			.param("title", title)
			.param("content", content))
		.andDo(print())
		.andExpect(redirectedUrl("/board/" + defaultBoardDetailDto.id() + "?status=true"));
}

우선 이번에는 Board를 생성하는 간단한 테스트 코드이다.
위에서 말했던 401 오류도 해결이 되었는데, 이번에는 다른 오류가 발생한다.

바로403 Forbidden이다.

결론부터 말하자면 csrf문제이다.

Spring security 5.x -> Spring security 6.x 의 변화된 부분을 공식문서를 참고하면

  • 더 이상 모든 요청에 대해 세션을 로드할 필요가 없으므로 성능 향상을 위해 CsrfToken의 로드가 기본적으로 지연됩니다.
  • 이제 CsrfToken은 BREACH 공격으로부터 CSRF 토큰을 보호하기 위해 기본적으로 모든 요청에 임의성을 포함합니다.

즉, Spring security 5에서 자동으로 로드되던 것이 지연 로드, 무작위 포함이 추가 됐다.

해결 방법

실제 애플리케이션의 동작은 csrf.disable()해준다면 아무런 문제가 없다.

다만 테스트 환경에서는 이것이 적용이 안되므로, MockMvc에서 request에 csrf 제공해주면 해결된다.

ResultActions resultActions = mockMvc.perform(
                patch("/api/users").with(csrf()))

이렇게 해줘도 문제는 없지만, 모든 테스트 코드에 .with(csrf())를 붙여야 하므로 전역으로 적용할 수 있는 방법을 만든다.

spring security를 애플리케이션에 적용한 것이 전제이므로, test환경에 spring security를 적용했다면 setUp 코드가 있을 것이다.

@BeforeEach
void setUp() {
	this.mvc = MockMvcBuilders
		.webAppContextSetup(webApplicationContext)
		.apply(springSecurity())
		.defaultRequest(post("/**").with(csrf()))
		.defaultRequest(put("/**").with(csrf()))
		.defaultRequest(delete("/**").with(csrf()))
		.build();
}

이렇게 말이다.

# Synchronizer Token Pattern

  • 서버가 뷰를 만들어줄 때 사용자 별 랜덤값을 만들어 세션에 저장한 다음 이를 뷰 페이지에 같이 담아 넘겨주게 된다.
  • 클라이언트는 HTTP 요청마다 숨겨진 csrf 토큰을 같이 넘겨줘야 하는 방식.
  • 서버는 HTTP Request에 있는 csrf 토큰값과 세션에 저장되어있는 토큰값을 비교해 일치하는 경우에만 처리를 진행하는 방식이다
    -> 위조된 사이트의 경우 csrf 토큰값이 일치하지 않기 때문에 공격자가 악의적인 코드를 심어놔도 이를 실행하지 않음.

참고

  • 타임리프와 같은 템플릿 엔진을 통해 View를 같이 제공하는 애플리케이션 / 웹 브라우저를 통해 요청을 받는 애플리케이션 -> csrf 사용 권장
  • Rest API만 제공하는 애플리케이션 = csrf 사용 안해도 무방.

profile
Software Developer Lv.0

0개의 댓글