본문 바로가기
spring boot/테스트

@WebMvcTest 환경에서 Spring Security 403 에러

by junjunjun 2023. 1. 28.
반응형

본 글은 정확하지 않을 수 있습니다. 참고용으로만 봐주시면 감사하겠습니다.

 

 

미리 보기

@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);
    }

흐름

  1. 토큰 유효성 무조건 통과
  2. jwtTokenProvider.getAuthentication(token) 에서 우리가 정의한 유저 정보 반환
  3. 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()));
    }
  1. UsernamePasswordAuthenticationToken 객체의 매개변수에 들어갈 데이터를 직접 만들어 주는 과정이다. 각자의 spring security 로직에 맞게 구현해 주면 된다.
  2. 토큰을 유효성 검사를 통과하도록 처리한다. 통과처리를 해줘야지 SecurityContext 변경 로직이 수행된다.
  3. 우리가 만들어 준 유저 정보를 넣어준다. 

테스트를 진행해 보면 유저 정보가 잘 들어오는 것을 확인할 수 있다.

@LoginUser CurrentMember member

 

방법 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

 

반응형

댓글