이번 포스팅에서는 서블릿 필터를 이용한 권한 검사를 구현해보려고 합니다.
현재 진행중인 프로젝트에서 게시글 작성자 본인만 볼 수 있는 게시글 이벤트 통계 페이지가 있습니다. 해당 페이지에 접근할 때 해당 사용자가 게시글 작성자인지 확인한 후 아닐 시 요청을 거절해야하는데, 이렇게 공통적으로 인증 또는 권한 검사를 하는데 유용하게 사용할 수 있는 것이 바로 서블릿 필터입니다.
물론 이러한 공통 관심사를 처리하는데 스프링의 AOP를 사용하거나 스프링 인터셉터를 사용할 수 있지만 스프링 시큐리티 필터에서 사용자 인증을 검사한 다음으로 권한 검사를 간단하게 할 수 있도록 서블릿 필터를 사용하였습니다.
이제 서블릿 필터를 생성하여 권한 검사에 적용해보도록 하겠습니다. 우선 필터 인터페이스를 구현하는 커스텀 필터를 생성해보도록 하겠습니다.
@Slf4j
public class PostLogFilter implements Filter {
private PostRepository postRepository;
public PostLogFilter(PostRepository postRepository) {
this.postRepository = postRepository;
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {
log.info("PostLogFilter init");
Filter.super.init(filterConfig);
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null) {
return;
} else {
Long memberId = Long.parseLong(authentication.getName());
String requestURI = httpRequest.getRequestURI();
Pattern pattern = Pattern.compile("/api/post-logs/(\\d+)/(\\w+)");
Matcher matcher = pattern.matcher(requestURI);
try {
if (matcher.find()) {
Long postId = Long.parseLong(matcher.group(1));
postRepository.getPostByPostIdAndMemberId(memberId, postId).orElseThrow(() -> new ApplicationException(ErrorCode.NOT_WRITER));
chain.doFilter(request,response);
} else {
log.info("알맞지 않은 URL 요청", requestURI);
throw new ApplicationException(ErrorCode.URL_NOT_FOUND);
}
} catch (ApplicationException e) {
sendErrorResponse(httpResponse, e);
return;
}
}
}
private void sendErrorResponse(HttpServletResponse response, ApplicationException e) throws IOException {
ObjectMapper mapper = new ObjectMapper();
String jsonInString = mapper.writeValueAsString(ApiResponse.error(ErrorResponse.of(e.getErrorCode())));
response.setContentType("application/json");
response.setStatus(e.getErrorCode().getStatus().value());
response.getWriter().write(jsonInString);
}
@Override
public void destroy() {
log.info("PostLogFilter destroy");
Filter.super.destroy();
}
}
필터 인터페이스에서 오버라이딩하는 메서드는 3개입니다.
init()
메서드는 필터 초기화 메서드이며 서블릿 컨테이너가 생성될 때 호출됩니다. doFilter()
메서드는 고객의 요청이 올 때 마다 해당 메서드가 호출됩니다. 해당 메서드에 필터의 로직을 구현하면 됩니다. destroy()
메서드는 필터 종료 메서드로 서블릿 컨테이너가 종료될 때 호출됩니다.
doFilter()
메서드를 살펴보도록 하겠습니다.
먼저 파라미터로 전달받는 ServletRequest, ServletResponse
객체를 URL 정보를 읽어들이거나 HTTP 응답을 반환해주기 위해 HttpServletRequest, HttpServletResponse
로 다운캐스팅해줍니다.
또한 스프링 시큐리티 필터에서 사용자 인증이 정상적으로 통과될 시 생성해준 Authentication
객체를 받아와 사용자 정보(여기서는 사용자 pk)를 확인하게 됩니다.
HTTP 요청에서 URI를 가져와 정규 표현식을 이용하여 /api/post-logs/로 시작하고, 그 뒤에 하나 이상의 숫자, 그리고 하나 이상의 알파벳 문자가 오는 문자열인지 패턴을 확인하게 됩니다.
패턴이 일치하지 않을 시 알맞지 않은 URL 요청이라 로그를 출력하고 404 에러를 반환하게 됩니다. 패턴이 일치할 시 DB를 조회하여 해당 사용자가 게시글 작성자가 맞는지 확인하게 됩니다.
doFilter()
메서드에서 return;
을 하게 될 시 다음 필터를 통과하지 않고 WAS 서버단으로 반환이 되며 chain.doFilter(request,response);
를 통해 다음 필터로 넘어갈 수 있습니다.
이제 생성한 PostLogFilter
를 필터 체인에 등록해보도록 하겠습니다. 서블릿 컨테이너는 필터 체인에 등록된 해당 필터를 싱글톤 객체로 생성하고, 관리하게 됩니다.
@Configuration
@RequiredArgsConstructor
public class WebConfig {
private final PostRepository postRepository;
@Bean
public FilterRegistrationBean postLogFilter() {
FilterRegistrationBean<Filter> filterRegistrationBean = new
FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new PostLogFilter(postRepository));
filterRegistrationBean.setOrder(SecurityProperties.DEFAULT_FILTER_ORDER+1);
filterRegistrationBean.addUrlPatterns("/api/post-logs/*");
return filterRegistrationBean;
}
}
FilterRegistrationBean
를 사용해서 필터 체인에 등록해주었습니다. setFilter()
는 등록할 필터를 지정해주고, setOrder()
는 필터 체인 내에서 순서를 지정해줍니다. addUrlPatterns()
는 필터를 적용할 URL 패턴을 지정해주게 됩니다.
setFilter()
에 위에서 생성한 PostLogFilter
객체를 생성하여 넣어주고, setOrder()
에 스프링 시큐리티 필터의 순서 다음으로 지정해줍니다. addUrlPatterns()
에 URL의 공통 부분을 기입해줍니다.
이제 테스트를 진행해보도록 하겠습니다. 먼저 스프링 시큐리티 필터가 동작한 다음 게시글 작성자에 대한 권한 검사가 이뤄지는지 테스트하고 알맞지 않는 URL 패턴 및 게시글 작성자 본인이 아닌 경우의 요청에 대해 테스트해보겠습니다.
해당 요청을 토큰을 빼고 요청해보니 다음과 같이 정상적으로 인증 에러가 반환된 것을 확인할 수 있었습니다.
/api/post-logs
URL의 뒤에 알맞지 않은 URL 패턴의 요청을 보낸 결과
필터에서 설정해둔 알맞지 않은 URL 요청 로그가 출력되고 404 에러를 반환하는 것을 확인할 수 있었습니다.
Authorization
토큰의 사용자는 게시글 작성자가 아니며 해당 토큰으로 요청을 보낸 결과
정상적으로 403 Forbidden 에러를 반환하는 것을 확인할 수 있었습니다.
정상적인 응답을 받는 것을 확인할 수 있었습니다.