티스토리 뷰
요약
401 Unauthorized -> @WithMockUser, @WithMockUserDetails 사용
403 Forbidden -> with(csrf()) 추가
@WebMvcTest
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 (i.e. @Controller, @ControllerAdvice, @JsonComponent, Converter/GenericConverter, Filter, WebMvcConfigurer and HandlerMethodArgumentResolver beans but not @Component, @Service or @Repository beans).
By default, tests annotated with @WebMvcTest will also auto-configure Spring Security and MockMvc
MVC와 관련된 애노테이션(Controller, ControllerAdvice, Filter, WebMvcConfigurer 등..)만 불러오고,
@Component, @Service, @Repository와 같은 빈들은 불러오지 않는다고 되어있다.
그리고! @WebMvcTest는 Spring Security를 auto-configure 한다고 하는데, 이것도 뭔 소리인지 아래에서 알아보자.
1. WebMvcTest와 SpringBootTest의 차이?
공식 문서로만 들으면 사실 잘 감이 안오는데 진짜 안불러올까?
- SpringBootTest 에서의 ApplicationContext Bean
=> @Controller, @Service, @Component, @Bean 등등 모두 다 불러와서 실제 환경과 동일하게 테스트 하고자 할 때 사용하는 @SpringBootTest를 사용한다
- WebMvcTest에서의 ApplicationContext Bean
@WebMvcTest(TownApiController.class)
class TownApiControllerTest {
@Autowired private MockMvc mvc;
@MockBean private UserService userService;
....
}
정말로 WebMvcTest가 절반 가랑의 스프링 빈만 불러와서 사용하는 것을 확인할 수 있다.
그리고 beanDefinitionMap을 일일히 뒤져봐도 repository나 component같이 내가 설정해둔 빈들을 불러오지 않은 것을 볼 수 있다.
대신에 내가 @MockBean으로 주입해준 UserService 빈은 #0이 뒤에 붙어서 스프링 빈으로 저장되어있는 것을 확인할 수 있다
그리고 이 UserService 빈은 MockitoMock을 통해 반들어져있고 내부는 다 null로 채워져있다
그러니까 이 텅 비어있는 userService 빈을 실제 동작하는 것처럼 흉내내기 위해서 (Mocking)
given(userService.findById(1L)).willReturn(user);
이런 given() 메서드를 대신 활용하는 것.
2. Spring Security의 Auto Congifure
WebMvcTest는 컨트롤러 테스트를 하기 위해 꼭 필요한 빈들만을 불러와서 사용한다는 것을 직접 확인해 보았다.
그래서 문제가 되는 것은 내가 만들어둔 Spring Security Configuration, Bean들도 불러오지 않는다는 것이다.
그 대신에 스프링 시큐리티가 자동으로 구성하는 Configuration 파일들을 불러와서 사용한다.
자동 구성되는 클래스들이 엄청 많고, 하나 하나 알아보기에는 너무 힘들지만
대표적으로 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();
}
}
이거 딱 스프링 시큐리티 맨 처음 적용했을 때 페이지 들어가면 바로 로그인 창 뜨게 만드는 그것이다.
모든 요청에 대해 아무 권한이나 갖고있으면 되도록 간단하게만 설정되어있다.
그렇기 때문에?
컨트롤러 계층 테스트를 하기 위해
mvc.perform(delete("/api/towns/1"));
이러한 요청을 해보면 스프링 시큐리티가 적용되어 있기 때문에 401 Unauthorized에러를 반환하는 것을 확인할 수 있다.
모든 요청에 대해 권한이 필요하도록 기본적으로 적용이 되어있기 때문이다.
그러니까 결국 요청을 할 때 권한을 같이 넘겨줘야 한다는 것이다.
1. ExcludeFilter를 이용해서 Security를 회피하는 방법
2. @WithMockUser, @WithUserDetails 와 같은 애노테이션을 이용해 권한을 넘겨주는 방법
(+) @TestConfiguration 파일을 별도로 설정하는 방법도 있다!
1번의 경우 일단 되긴 하나 근본적인 해결이 되지 못하는 방법이다. 패스
2번 방법을 사용해 임의의 UserDetails를 만들어서 이를 같이 넘겨주는 방법을 사용하면 된다!
@WithMockUser
@WithAnonymousUser - 미인증 사용자
@WithUserDetails - 메서드가 principal 내부의 값을 직접 사용하는 경우 (별도의 사전 설정 필요)
등등 다앙한 권한을 설정해서 같이 넘겨줄 수 있다.
@RequiredArgsConstructor
@Validated
@RestController
@RequestMapping(value = "/api/towns")
public class TownApiController {
private final TownService townService;
private final UserService userService;
private final AdminService adminService;
...
@Secured({"ROLE_SUPER"})
@DeleteMapping("/{townId}")
public ResponseEntity<String> remove(@PathVariable @Positive Long townId) {
townService.delete(townId);
return ResponseEntity.ok("성공적으로 삭제했습니다");
}
...
}
@Test
@WithMockUser(username = "테스트_최고관리자", roles = {"SUPER"})
void test() throws Exception {
ResultActions actions = mvc.perform(
delete("/api/towns/1")
.with(csrf()));
actions.andExpect(status().isOk());
}
는 것으로 알고 있었고 해결이 되었다면 블로깅을 안했을 것인데 추가적인 문제가 있었다.
@WithMockUser를 사용하고 나니 401 에러가 아니라 403 Forbidden 에러가 떴다
401 Unauthorized는 아예 비로그인 상태에서 권한이 필요한 요청을 했을 때 발생하는 에러
403 Forbidden은 로그인은 했으나 권한이 맞지 않는 경우에 발생하는 에러라고 보면 된다.
근데 request 봐도 세션에 SpringContext 내부에 WithMockUser로 만든 권한들이 다 잘 들어가있는 걸 확인할 수 있고, Authorities도 "ROLE_SUPER"으로 잘 담겨있는데 대체 왜 안된다는걸까??
구글링 해보니 csrf와 관련된 문제라고 한다.
"org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf" 를 추가해주면 된다고 하는데,,
ResultActions actions = mvc.perform(
delete("/api/towns/1")
.with(csrf()));
정말로 with.(csrf()) 를 추가해주니 참 어처구니 없게도 잘 된다..
대체 csrf가 뭔데 그러는 걸까?
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf().disable()
...
}
사실 맨 처음에 Spring Security를 얕게 배울 때부터 그냥 csrf().disable() 이거는 솔직히 뭔지도 잘 모르고 관례적으로;;; 그러려니 하고 기본으로 깔고 사용하고 있었는데.... 이 참에 뭔지 알아보자
# csrf(Cross-Site Request Forgery)
- 공격자가 악의적인 코드를 심어놓은 사이트를 만들어놓고, 로그인 된 사용자가 클릭하게 만들어 사용자 의지와 무관한 요청을 발생시키는 공격
- 사용자는 로그인 한 상태고 쿠키, 권한을 갖고있기 때문에 공격자가 위조한 웹사이트에 방문하게 되면 사용자 모르게 악의적인 POST, DELETE 요청을 정상 수행하도록 만들어버리는 공격
- 이를 해결하기 위해 스프링 시큐리티에서는 "CSRF 토큰" 을 이용해 토큰 값을 비교해서 일치하는 경우에만 메서드를 처리하도록 만든다. (Synchronizer Token Pattern 이라고 한다)
# Synchronizer Token Pattern
- 서버가 뷰를 만들어줄 때 사용자 별 랜덤값을 만들어 세션에 저장한 다음 이를 뷰 페이지에 같이 담아 넘겨주게 된다.
- 클라이언트는 HTTP 요청마다 숨겨진 csrf 토큰을 같이 넘겨줘야 하는 방식.
- 서버는 HTTP Request에 있는 csrf 토큰값과 세션에 저장되어있는 토큰값을 비교해 일치하는 경우에만 처리를 진행하는 방식이다
-> 위조된 사이트의 경우 csrf 토큰값이 일치하지 않기 때문에 공격자가 악의적인 코드를 심어놔도 이를 실행하지 않음.
* GET 요청에 대해서는 csrf 검증을 수행하지 않는다
* with(csrf()) 를 추가한 경우 파라미터로 _csrf 값을 같이 보내주는 것을 실제로 확인할 수 있다.
* with(csrf())를 사용하지 않은 경우 -> 세션에 저장된 CSRF 값과 매치되지 않음
-> 요청을 수행하지 않고 403 에러 반환
# 그럼 왜 안전한 csrf 기능을 disable 한걸까??
csrf 토큰 방식을 살펴보면 각 사용자에 대한 세션을 이용하는 방식이라는 것을 확인할 수 있다.
때문에 웹 브라우저를 통한 접근을 하는 경우, 세션/쿠키를 사용해 상태를 유지하려고 하는 경우 csrf를 사용하는 것이 안전하다.
하지만 REST API의 경우는 대개 무상태성을 유지하며 JWT와 같은 토큰 방식으로 인증하게 되면 요청이 세션에 의존하지 않기 때문이다
* 타임리프와 같은 템플릿 엔진을 통해 View를 같이 제공하는 애플리케이션 / 웹 브라우저를 통해 요청을 받는 애플리케이션 -> csrf 사용 권장
* Rest API만 제공하는 애플리케이션 = csrf 사용 안해도 무방.
어째 알면 알수록 더 어려운 것 같다.
<Security 참고>
https://tecoble.techcourse.co.kr/post/2020-09-30-spring-security-test/
<CSRF 참고>
https://codevang.tistory.com/282
https://docs.spring.io/spring-security/reference/features/exploits/csrf.html
'웹 > Spring' 카테고리의 다른 글
@RequestBody 공식문서를 읽어보자 + HttpMessageConverter (0) | 2022.11.28 |
---|---|
@ModelAttribute 공식 문서를 읽어보자 (0) | 2022.11.23 |
스프링 + Thymeleaf로 게시글 비밀번호 기능 구현하기 (0) | 2022.10.29 |
DTO는 대체 어디서 변환하는 것이 좋을까? (2) | 2022.10.27 |
@Valid를 이용한 회원가입 입력값 검증 - 1단계 (0) | 2022.10.19 |