@WebMvcTest 환경에서 Spring Security 403 에러
본 글은 정확하지 않을 수 있습니다. 참고용으로만 봐주시면 감사하겠습니다.
미리 보기
@WithSecurityContext 사용하면 됩니다.
상황
spring boot 사용
spring security를 다른 분이 담당하여 나는 잘 모름
jwt 토큰 방식
@AuthenticationPrincipal 커스텀해서 사용
webMvcTest, contoller unit test
1차 문제 발생
@WebMvcTest는 주로 컨트롤러 단위 테스트에서 많이 사용된다. 그렇기 때문에 @SpringBootTest와 달리 모든 bean을 가져오지 않는다. 우리가 설정해 준 SecurityConfig 클래스 또한 가져오지 않는다.
여기서 테스트의 결과가 계속 403이 나오는 문제가 발생한다.
그 이유는 Spring Security에서는 기본적으로 로그인 화면이 처음에 뜨는 설정이 적용되는데, 이 설정이 webMvcTest에서 디폴트로 지정되어 있기 때문이다.
따라서 이 설정을 꺼주면 된다.
... 생략 ...
@WebMvcTest(PostController.class)
@AutoConfigureMockMvc(addFilters = false) // 추가
public class PostControllerTest {
... 생략 ...
@Test
@DisplayName("게시글 삭제")
void deletePost_success() throws Exception {
willDoNothing().given(postService).deletePost(any(), any());
ResultActions resultActions = mockMvc.perform(delete("/posts/1")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.header(HttpHeaders.AUTHORIZATION, AUTHORIZATION_HEADER_VALUE))
.andExpect(status().isNoContent());
}
}
해당 어노테이션을 추가시켜주면 더 이상 기본 시큐리티가 동작하지 않는다. 테스트도 잘 통과된다.
2차 문제 발생
일단 임시로 위의 방법으로 프로젝트를 진행하였지만, controller 단 로직에 member 정보를 추가하게 되면서 상황이 바뀌었다.
Controller에서 로그인한 유저 정보를 가져올 수 있게 로직을 변경하였다.
@PreAuthorize("isAuthenticated()") // 추가
@DeleteMapping("/posts/{postId}")
public ResponseEntity<Void> deletePost(@PathVariable Long postId,
@LoginUser CurrentMember currentMember) { // 추가
log.info("게시글 삭제 시작");
postService.deletePost(postId, currentMember.getId());
return ResponseEntity.noContent().build();
}
- @PreAuthorize : 권한이 있는 유저만 접근 가능 = 로그인된 유저
- @LoginUser : @AuthenticationPrincipal을 커스텀한 어노테이션으로 접근한 유저 정보를 담고 있음.
@AutoConfigureMockMvc(addFilters = false)를 추가하면 Spring Security 자체를 생략하기 때문에 위처럼 코드가 추가된다고 하더라고 달라질 게 없다. 하지만 currentMemeber.getId() 부분에서 NullPoint 예외가 발생했다.
currentMemeber에 데이터를 넣어주는 과정이 Spring Security에서 일어나기 때문에 당연한 결과였다.
(Mockito를 사용하기 때문에 그냥 될 줄 알았는데 안 되었다.)
해결 방법은 currentMemeber.getId() 대신 currentMemeber 자체를 인자로 넣어주면 된다.
간단하게 해결할 수 있었지만 나는 두 가지 이유 때문에 다른 방법을 찾기로 했다.
1. currentMemeber 인스턴스로 바꿀 경우 service 단 로직부터 테스터 코드까지 수정해야 할 부분이 너무 많았다.
2. 언젠가 테스트에서 currentMember 데이터를 이용할 날이 올 거 같았다.
해결법에 들어가기 전
본인은 커스텀된 Authentication 인증 정보(@LoginUser)를 사용하였기 때문에 @WithMockUser로는 해결되지 않았습니다.
해결방법
일단 currentMemeber 에 데이터를 넣어 주기 위해서는 Spring Security를 다시 가동시켜야 한다. 따라서 기존의 어노테이션을 지워주고 우리가 설정해 준 SecurityConfig 클래스를 가져오면 된다.
@WebMvcTest(PostController.class)
@Import(SecurityConfig.class)
// @AutoConfigureMockMvc(addFilters = false) 이제 안씀
public class PostControllerTest { ... 생략 ...}
예외 발생
Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'com.global.auth.jwt.provider.JwtTokenProvider available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: ...
SecurityConfig 내부에 있는 JwtTokenProvider의 빈이 등록되지 않아서 예외가 발생하였다. 마저 추가해 주자.
@WebMvcTest(PostController.class)
@Import(SecurityConfig.class)
public class PostControllerTest {
... 생략 ...
// SecurityConfig에서 주입받는 애들도 빈 등록
@MockBean
private JwtTokenProvider jwtTokenProvider;
@MockBean
private UserDetailsService userDetailsService;
}
이제부터는 지극히 개인적인 코드이기 때문에 흐름만 보시면 됩니다.
우리 프로젝트의 시큐리티 필터의 일부이다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
... 생략 ...
if (토큰이 유효하다면) {
this.setAuthentication(token); (1)
}
filterChain.doFilter(request, response);
}
private void setAuthentication(String token) {
Authentication authentication = jwtTokenProvider.getAuthentication(token); (2)
SecurityContextHolder.getContext().setAuthentication(authentication); (3)
}
1. 토큰이 유효한 경우에 setAuthentication 메서드를 실행시킨다.
2. 토큰값을 복호화하여 Authentication 정보를 가져온다
3. Authentication 정보를 SecurityContext의 authentication에 넣어준다.
이를 해석하자면
유효한 토큰일 경우 토큰을 복호화하여 유저정보를 빼온 뒤 SecurityContext의 authentication에 넣어 주면 @AuthenticationPrincipal으로 유저 정보를 가져올 수 있다.
@AuthenticationPrincipal을 커스텀한 @LoginUser CurrentMember currentMember 에도 데이터가 들어간다.
정확한 정보는 아래 블로그 참고
한번 인증된 사용자 정보를 세션에 담아놓고 세션이 유지되는 동안 사용자 객체를 DB로 접근하는 방법 없이 바로 사용할 수 있도록 한다.
Spring Security에서는 해당 정보를 SecurityContextHolder 내부의 SecurityContext에 Authentication 객체로 저장해두고 있으며 이를 참조하는 방법은 크게 3가지가 있다.
https://velog.io/@jyleedev/AuthenticationPrincipal-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%A0%95%EB%B3%B4-%EB%B0%9B%EC%95%84%EC%98%A4%EA%B8%B0
따라서 SecurityContext의 authentication에 우리가 만든 임의의 유저 정보를 넣어주면 @LoginUser 어노테이션으로 유저 정보를 받아올 수 있다.
방법 1. Mockito 사용
아래는 시큐리티의 filter 로직의 일부이다.주석으로 처리된 부분을 Mockito를 사용하여 직접 컨트롤해주면 된다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
... 생략 ...
if (jwtTokenProvider.validateToken(token)) { // 모키토를 통해 토큰이 유효하다고 처리
this.setAuthentication(token);
}
filterChain.doFilter(request, response);
}
private void setAuthentication(String token) {
Authentication authentication = jwtTokenProvider.getAuthentication(token); // 모키토를 통해 유저정보 넣어줌
SecurityContextHolder.getContext().setAuthentication(authentication);
}
흐름
- 토큰 유효성 무조건 통과
- jwtTokenProvider.getAuthentication(token) 에서 우리가 정의한 유저 정보 반환
- SecurityContext에 유저 정보 등록
Mockito를 이용하여 직접 정의해 주자.
@BeforeEach
void beforeEach() {
/*start---UsernamePasswordAuthenticationToken에 넣을 데이터 가공 작업----*/
// (1)
Set<Authority> authoritySet = new HashSet<>();
authoritySet.add(new Authority(MemberAuthType.ROLE_USER));
List<SimpleGrantedAuthority> authList = authoritySet.stream()
.map(Authority::getAuthorityNameToString)
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
CurrentMember currentMember = CurrentMember.builder()
.id(1L)
.email("test123")
.nickname("nickname")
.password("12345")
.authorities(authoritySet)
.build();
AuthMemberAdaptor authMemberAdaptor = new AuthMemberAdaptor(currentMember, authList);
/*end----UsernamePasswordAuthenticationToken에 넣을 데이터 가공 작업----*/
// (2)
given(jwtTokenProvider.validateToken(any())).willReturn(true);
// (3)
given(jwtTokenProvider.getAuthentication(any()))
.willReturn(new UsernamePasswordAuthenticationToken(authMemberAdaptor, "", authMemberAdaptor.getAuthorities()));
}
- UsernamePasswordAuthenticationToken 객체의 매개변수에 들어갈 데이터를 직접 만들어 주는 과정이다. 각자의 spring security 로직에 맞게 구현해 주면 된다.
- 토큰을 유효성 검사를 통과하도록 처리한다. 통과처리를 해줘야지 SecurityContext 변경 로직이 수행된다.
- 우리가 만들어 준 유저 정보를 넣어준다.
테스트를 진행해 보면 유저 정보가 잘 들어오는 것을 확인할 수 있다.
방법 2 @WithSecurityContext
방법 1의 경우에는 유저 정보가 필요 없는 테스트에서도 실행되는 단점이 존재한다. (beforeEach)
사실 @WithMockUser만 테스트에 붙여주면 인증된 인증 정보를 제공하여 간단하게 인증 관련 테스트할 수 있다.
하지만 @AuthenticationPrincipal 커스텀해서 사용하기 때문에 @WithMockUser를 사용하지 못한다.
이를 해결해 주는 게 @WithSecurityContext이다.
@WithSecurityContext는 SecurityContext를 커스텀하게 생성하여 사용하도록 설정해 준다. 즉 방법 1과 같이 유저 정보를 SecurityContext에 넣어 주는 과정은 동일하며 이를 어노테이션으로 사용할 수 있게 해 준다.
1. 어노테이션 등록
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory.class)
public @interface WithMockCustomUser {
// String value() default "nickname";
}
2. WithMockCustomUserSecurityContextFactory 구현
public class WithMockCustomUserSecurityContextFactory implements WithSecurityContextFactory<WithMockCustomUser> {
@Override
public SecurityContext createSecurityContext(WithMockCustomUser customUser) {
SecurityContext context = SecurityContextHolder.getContext();
Set<Authority> authoritySet = new HashSet<>();
authoritySet.add(new Authority(MemberAuthType.ROLE_USER));
List<SimpleGrantedAuthority> authList = authoritySet.stream()
.map(Authority::getAuthorityNameToString)
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
CurrentMember currentMember = CurrentMember.builder()
.id(1L)
.email("test123")
.nickname("nickname")
.password("12345")
.authorities(authoritySet)
.build();
AuthMemberAdaptor authMemberAdaptor = new AuthMemberAdaptor(currentMember, authList);
Authentication auth =new UsernamePasswordAuthenticationToken(authMemberAdaptor, "", authMemberAdaptor.getAuthorities());
context.setAuthentication(auth);
return context;
}
}
전체적인 로직은 방법1과 동일하다.
사용법
@Test
@DisplayName("게시글 삭제")
@WithMockCustomUser // 이것만 붙여주면 된다.
void deletePost_success() {
/*생략*/
}
결론
spring security 관련 로직을 모르는 상태에서 디버그 모드를 통해 해결한 방식입니다.
그냥 @WithSecurityContext를 사용하면 됩니다. 다만 본인의 시큐리티 로직에 맞도록 설정해줘야 합니다.
참고 자료
https://jaimemin.tistory.com/2081
[SpringBoot] 로그인된 사용자가 접근할 수 있는 기능 Test 작성하는 방법
개요 로그인된 사용자를 대상으로 하는 Controller 테스트를 작성할 때 저는 @BeforeEach 어노테이션을 통해 form 로그인을 먼저 진행하고 @WithUserDetails의 setUpBefore를 TEST_EXECUTION으로 설정하여 @BeforeEach
jaimemin.tistory.com
https://tecoble.techcourse.co.kr/post/2020-09-30-spring-security-test/
Spring Security가 적용된 곳을 효율적으로 테스트하자.
Spring Security와 관련된 기능을 테스트하다보면 인증 정보를 미리 주입해야 하는 경우가 종종 발생한다. 기본적으로 생각할 수 있는 가장 간단한 방법은 테스트 전에 SecurityContext에 직접 Authenticatio
tecoble.techcourse.co.kr