ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 인터셉터를 활용한 인증/인가 공통처리
    프로젝트/게시판 프로젝트 2022. 6. 29. 02:40

    로그인을 할 때 사용자에게 "access-token"을 제공합니다.

    게시글 작성을 위해서는 클라이언트의 HTTP 헤더의 "access-token"에서 token값을 가져와서 검증하고 토큰이 유효하다면 게시글을 작성하는 로직입니다.

     

    코드로 보면 다음과 같습니다.

     @PostMapping
        public ResponseEntity<BasicResponseDTO> posting(@Valid @RequestBody BoardRequestDTO boardRequestDTO, HttpServletRequest request) {
            String token = request.getHeader("access-token");
            if (!jwtService.isUsable(token)) {
                return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                                     .body(makeBasicResponseDTO(FAIL));
            }
            boardService.posting(boardRequestDTO);
            return ResponseEntity.status(HttpStatus.CREATED)
                                 .body(makeBasicResponseDTO(SUCCESS));
    
        }

     

    인증시 중복되는 코드의 발생

    만약에 여기서 게시글 수정 로직이 추가되거나, 삭제, 회원 수정 등등 로그인 권한이 필요한 기능들이 추가된다면 모든 메서드에 토큰을 받아와서 유효성 검증을 하는 로직이 추가될 것입니다.

     

    스프링에서 코드를 공통으로 처리할 수 있는 방법들

    필터, 인터셉터, AOP 등이 존재합니다.

    위의 개념에 대해서 잘 모르신다면 다음 글을 읽고 오시면 좋습니다.

    https://junuuu.tistory.com/302

     

    필터와 인터셉터의 차이점

    필터란? 필터는 J2EE 표준 스펙 기능으로 디스패처 서블릿에 요청이 전달되기 전/후에 url 패턴에 맞는 모든 요청에 대해 부가 작업을 처리할 수 있는 기능을 제공합니다. 인터셉터란? 인터셉터는

    junuuu.tistory.com

     

     

    이때 인증과 인가를 다루기 위해서 필터와 인터셉터 둘 중 하나를 선택하려고 합니다.

     

    인터셉터가 필요한 상황

    1. 일반적으로 인터셉터가 request, response 이외에 Exception, ModelAndView, handler 정보 등을 추가적으로 제공합니다.

    2. 필터의 경우 단순하게 doFilter() 메서드가 제공되지만 인터셉터는 preHandle, postHandle, afterCompletion과 같이 단계적으로 잘 세분화되었습니다.

     

    필터가 필요한 상황

    response, request를 조작해야 하는 상황

     

    결론

    필터를 꼭 사용해야 하는 상황이 아니라면 인터셉터를 사용하는 것이 더 편리하고 많은 기능들을 제공합니다.

    따라서 인터셉터를 적용해보고자 합니다.

     

     

    인터셉터 적용하기

    package anthill.Anthill.interceptor;
    
    import anthill.Anthill.service.JwtService;
    import lombok.RequiredArgsConstructor;
    import org.apache.tomcat.websocket.AuthenticationException;
    import org.springframework.stereotype.Component;
    import org.springframework.web.servlet.HandlerInterceptor;
    
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    
    @Component
    @RequiredArgsConstructor
    public class PermissionInterceptor implements HandlerInterceptor {
    
        final JwtService jwtService;
    
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
            String token = request.getHeader("access-token");
            if (!jwtService.isUsable(token)) {
                throw new AuthenticationException("토큰이 유효하지 않습니다.");
            }
            return true;
    
        }
    }

     

    우선 인터셉터를 만들어보겠습니다.

    인터셉터를 구현하기 위해 PermissionInterceptor는 HandlerInterceptor 인터페이스를 구현해야 합니다. 

    인터셉터는 request 정보에서 헤더 값을 꺼내와 JwtService에서 해당 토큰이 유효한지 검사합니다.

    따라서 JwtService에 의존적입니다.

    그리고 토큰이 유효하지 않다면 AuthenticationException을 발생시키고 이는 ControllerAdvice에서 관리해줍니다.

     

    또한 해당 클래스는 SpringBean으로 관리되지 않기 때문에 @Component 어노테이션을 통해 스프링에 의해 관리되도록 합니다.

     

    WebMvcConfigurer 오버라이드

    package anthill.Anthill.config;
    
    import anthill.Anthill.interceptor.PermissionInterceptor;
    import lombok.RequiredArgsConstructor;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
    import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
    
    @Configuration
    @RequiredArgsConstructor
    public class AppConfig implements WebMvcConfigurer {
        private final PermissionInterceptor permissionInterceptor;
    
        @Override
        public void addInterceptors(InterceptorRegistry registry) {
            registry.addInterceptor(permissionInterceptor)
                    .order(1)
                    .addPathPatterns("/boards");
        }
    }

    스프링 인터셉터를 적용하기 위해서는 WebMvcConfigurer 인터페이스를 오버 라이딩해줘야 합니다.

    @Configuration 어노테이션을 통해 설정 정보를 관리하는 클래스임을 명시합니다.

    이후에 위에 구현했던 PermissionInterceptor 클래스 의존성 주입을 받습니다.

     

    addInterceptors 메서드를 오버 라이딩하는데 registry에 addInterceptor 메서드를 통해 permissionInterceptor를 추가합니다.

    이후에 order메서드를 통해 우선순위는 1순위로, addPathPatterns 메서드를 통해 "/boards" URL로 들어온 경우에만 검사할 수 있도록 합니다.

     

    이렇게 하면 인터셉터를 통해 access-token에 대한 검증이 이루어지기 때문에 BoardController가 더 이상  JwtService를 의존하지 않아도 됩니다.

     

    같은 URL이지만 HTTP 메서드에 대한 분기법

    WebMvcConfigurer 에서는 추가할 패턴들만 정의할 수 있으며 같은 URL일 경우에는 동일하게 인터셉터가 적용되어 버립니다.

     

    만약 같은 URL이지만 POST, DELETE, PUT 시에만 권한이 필요하고 GET할때는 권한이 필요하지 않은 게시글조회같은 경우는 어떻게 해야 할까요?

     

    HaddlerInterceptor를 구현할 때 request의 getMethod를 활용하여 GET요청만 분기시키면 해결할 수 있습니다.

    final String method = request.getMethod();
    
    if (method.equals(GET)) {
        return true;
    }

     

    테스트가 깨짐

    하지만 인터셉터를 적용하자 기존의 테스트가 깨지는 문제가 발생하였습니다.

     

    java.lang.IllegalStateException: Failed to load ApplicationContext

     

    Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'appConfig' defined in file [C:\intellij\Anthill\Anthill\out\production\classes\anthill\Anthill\config\AppConfig.class]: Unsatisfied dependency expressed through constructor parameter 0; nested exception is org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'permissionInterceptor' defined in file [C:\intellij\Anthill\Anthill\out\production\classes\anthill\Anthill\interceptor\PermissionInterceptor.class]: Unsatisfied dependency expressed through constructor parameter 0; nested exception is org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'anthill.Anthill.service.JwtService' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}

     

    위의 긴 영문을 요약해보자면 permissionInterceptor를 생성하는 도중에 JwtService라는 Bean을 찾을 수 없기 때문에 UnsatisfiedDependencyException이 발생한 것 같습니다.

     

    이를 해결하기 위해서는 @MockBean을 통해 JwtService의 의존성을 넣어주면 해결되었습니다.

     

    테스트 코드

    package anthill.Anthill.controller;
    
    import anthill.Anthill.dto.board.BoardRequestDTO;
    import anthill.Anthill.service.BoardService;
    import anthill.Anthill.service.JwtService;
    import com.fasterxml.jackson.core.JsonProcessingException;
    import com.fasterxml.jackson.databind.ObjectMapper;
    import org.junit.jupiter.api.Test;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
    import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
    import org.springframework.boot.test.mock.mockito.MockBean;
    import org.springframework.http.MediaType;
    import org.springframework.test.web.servlet.MockMvc;
    import org.springframework.test.web.servlet.ResultActions;
    
    import static org.junit.jupiter.api.Assertions.*;
    import static org.mockito.ArgumentMatchers.any;
    import static org.mockito.BDDMockito.given;
    import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
    import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
    
    @AutoConfigureRestDocs
    @WebMvcTest(BoardController.class)
    class BoardControllerTest {
        @Autowired
        MockMvc mvc;
    
        @MockBean
        private BoardService boardService;
    
        @MockBean
        private JwtService jwtService;
    
        @Test
        void 게시글_작성_인증실패() throws Exception {
    
            BoardRequestDTO boardRequestDTO = makeBoardRequestDTO("test");
    
            String body = new ObjectMapper().writeValueAsString(boardRequestDTO);
    
            ResultActions resultActions = mvc.perform(post("/boards")
                    .content(body)
                    .contentType(MediaType.APPLICATION_JSON)
                    .accept(MediaType.APPLICATION_JSON));
    
            resultActions.andExpect(status().isUnauthorized());
    
        }
    
        @Test
        void 게시글_작성_인증성공() throws Exception {
    
            BoardRequestDTO boardRequestDTO = makeBoardRequestDTO("test");
            String body = new ObjectMapper().writeValueAsString(boardRequestDTO);
    
            given(jwtService.isUsable(any())).willReturn(true);
    
            ResultActions resultActions = mvc.perform(post("/boards")
                    .content(body)
                    .contentType(MediaType.APPLICATION_JSON)
                    .accept(MediaType.APPLICATION_JSON));
    
            resultActions.andExpect(status().isCreated());
    
        }
    
        private BoardRequestDTO makeBoardRequestDTO(String value) {
            BoardRequestDTO boardRequestDTO = BoardRequestDTO.builder()
                                                             .title(value)
                                                             .content(value)
                                                             .writer(value)
                                                             .build();
            return boardRequestDTO;
        }
    
    }

    BoardService와 JwtService를 @MockBean을 통해 주입받고 given() 메서드를 통해 jwtService.isUsable() 메서드가 true를 반환하는 경우에는 게시글이 생성되고 그렇지 않은 경우에는 인증 에러가 발생하도록 테스트 완료되었습니다.

    댓글

Designed by Tistory.