인증 예외 처리 문제 해결 (1) - Filter와 Interceptor
최근에 spring security를 이용한 인증 처리 과정을 구현하다가
리더님께 다음의 코드에서 예외처리가 예상한대로 동작하지 않을 것이라는 리뷰를 받았다.
내가 구현한 예외처리 코드를 예시로 보자면 예외 발생시 Controller Advice에서 예외를 정해진 ErrorResponse의 타입으로 반환하는 코드였다.
@ControllerAdvice
class ApiControllerAdvice {
@ExceptionHandler(Exception::class, RuntimeException::class)
fun exceptionHandle(exception: Exception): ResponseEntity<ErrorResponse> {
val standardError = ErrorResponse.of(exception)
return ResponseEntity(standardError, standardError.httpStatus)
}
}
data class ErrorResponse(
val message: String,
val code: String,
val httpStatus: HttpStatus,
)
사용자를 인증하는 과정에서는 다음과 같이 필터를 이용해서 인증되지 않은 사용자라면 예외를 터뜨리도록 구현하였다.
class AuthenticationFilter(
// ... 생략
) : OncePerRequestFilter() {
override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) {
val token = resolveToken(request)
checkIsValidToken(accessToken)
val authentication = //... 생략
SecurityContextHolder.getContext().authentication = authentication
filterChain.doFilter(request, response)
}
private fun resolveToken(request: HttpServletRequest): String? {
val bearerToken = request.getHeader("Authorization")
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7)
}
return null
}
private fun checkIsValidToken(token: String?) {
if (!isValidAccessToken(accessToken)) {
throw InvalidTokenException("유효하지 않은 사용자입니다. token : $token")
}
}
}
그런데 막상 AuthenticationFilter에서 예외가 발생할 때 ErrorResponse 타입으로 반환하고 있지 않았다.
왜일까?
요청 처리 순서
어플리케이션의 사용자의 요청 전달 순서를 보면 다음과 같다.
사용자의 요청 -> Servlet Container-> Filter -> Spring Context -> Dispatcher Servlet -> Interceptor -> Controller
구조가 위와 같을 때, 만약 들어오는 모든 요청에 대해서 공통의 인증과정이 필요하다면 과연 인증 구현 대상은 과연 어떤 phase일까?
Filter와 Interceptor에 대해 알아보자.
Filter
Filter는 Spring이 아닌 J2EE 표준 스펙인 Java의 기능으로 웹 컨테이너(서블릿 컨테이너)에서 동작한다. filter chain 방식으로 여러개의 필터를 등록할 수도 있고 필터의 실행 순서를 지정할 수도 있다. 따라서, Spring Security나 Spring MVC에 종속적이지않고, Spring Context에 도달하기 전에 동작하여 공통적으로 적용되어야 하는 인증이나 인가에 대해 사용 된다.
javax.servlet의 Filter 인터페이스(init, doFilter, destroy)를 구현하여 필터를 만들 수 있다.
public interface Filter {
public default void init(FilterConfig filterConfig) throws ServletException {}
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException;
public default void destroy() {}
}
1) init
- 필터 객체를 초기화하고 서비스에 추가
- 웹 컨테이너가 1회 init 메소드를 호출하여 필터 객체를 초기화
- 이후의 요청들은 doFilter를 통해 처리됨
2) doFilter
- 웹 컨테이너에 의해 url 패턴에 맞는 모든 HTTP 요청이 디스패처 서블릿으로 전달되기 전에 실행됨
- 파라미터로 전달되는 FilterChain의 doFilter 통해 다음 대상으로 요청을 전달
- chain.doFilter() 전/후에 원하는 처리 과정을 추가할 수 있음
3) destroy
- 필터 객체를 서비스에서 제거하고 사용하는 자원을 반환
- 웹 컨테이너에 의해 1번 호출
- destroy 이후 doFilter에 의해 처리되지 않음
Interceptor
인터셉터는 필터가 동작한 후에 실행되며 Filter와 달리 Spring이 제공하는 기술로 Spring Context 안에서 디스패처 서블릿(Dispatcher Servlet)이 컨트롤러를 호출하기 전과 후에 요청과 응답을 참조하거나 가공할 수 있는 기능이다. 디스패처 서블릿은 핸들러 매핑(Handler Mapping)을 통해서 적절한 컨트롤러를 찾고 실행 체인(HandlerExecutionChain)을 받아 순차적으로 등록된 여러 인터셉트를 실행 후에 컨트롤러가 실행되도록 한다. 이를 통해서 세밀한 URL설정을 할 수도 있고, 적절하지 못한 요청을 제한할 수 도 있다.
org.springframework.web.servlet의 HandlerInterceptor 인터페이스(preHandle, postHandle,afterCompletion)를 구현하여 인터셉터를 만들 수 있다.
public interface HandlerInterceptor {
default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
return true;
}
default void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable ModelAndView modelAndView) throws Exception {
}
default void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {
}
}
1) preHandle
- 컨트롤러가 호출되기 전에 실행되어 컨트롤러 전에 처리되는 전처리 작업이나 요청 정보를 가공하거나 추가하는 경우에 사용됨
- 반환값이 true이면 다음 단계로 진행이 되지만, false라면 작업을 중단하여 이후의 인터셉터 또는 컨트롤러 작업은 중지됨
- handler 파라미터는 핸들러 매핑이 찾아준 컨트롤러 빈에 매핑되는 HandlerMethod라는 새로운 타입의 객체로 @RequestMapping이 붙은 메소드의 정보를 추상화한 객체
2) postHandle
- postHandle 메소드는 컨트롤러를 호출된 후에 실행되어 컨트롤러 후에 처리해야 하는 후처리 작업이 있을 때 사용됨
- 컨트롤러 하위 계층 진행 중 예외가 발생하면 postHandle은 호출되지 않음
- 최근 RestAPI 기반의 컨트롤러(@RestController)컨트롤러가 사용되면서 ModelAndView 타입의 정보는 자주 사용되지 않음
3) afterCompletion
- 모든 작업이 완료된 후에 실행되어 사용한 리소스를 반환할 때 사용됨
- 컨트롤러 하위 계층 진행하다가 예외가 발생하더라도 반드시 호출됨
Filter vs Interceptor
필터와 인터셉터를 비교하면 다음과 같다.
Filter | Interceptor | |
관리 주체 | 웹 컨테이너 | Spring 컨테이너 |
스프링 예외 처리 적용 여부 |
X | O |
Request/Response 조작 가능 여부 |
O | X |
사용 대상 |
- 공통적인 인증이나 인가 - 모든 요청에 대한 공통 작업 - 이미지, 문자열 인코딩 - Spring에 종속적이지 않은 작업 |
- 세밀한 인증이나 인가 - API호출 별 작업 - Controller로 넘겨주는 데이터 가공 |
그렇다면 공통적으로 적용되어야 하는 인증이나 인가 작업을 하는 Spring Security가 구현되는 phase는 어디일까? 바로 Filter이다.
문제 발생 부분
그렇다면 Filter(ex)Spring Security)에서 발생하는 예외는 왜 @ControllerAdvice에 의해서 처리되지 않았을까?
@ControllerAdvice는 컨트롤러 계층에서 발생되는 예외를 처리 한다.
하지만 Spring Security는 Filter를 사용하므로 요청이 컨트롤러에 도달하기 전에 예외가 발생하여 컨트롤러 계층까지 전달되지 못했기 때문 이다. 따라서 컨트롤러에서 예외가 발생되지 않았기 때문에 @ControllerAdvice가 동작하지 않는다.
그렇다면 Filter에서 발생한 예외는 어떻게 처리 할 수 있을까?
Filter의 예외 처리 방법
Filter에서 발생한 예외를 처리하는 방법으로는 Filter에서 발생한 예외를 처리하는 또 다른 Filter를 구현하는 방법이 있다.
@Component
class ExceptionHandlerFilter : OncePerRequestFilter() {
override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) {
val objectMapper = ObjectMapper()
try {
filterChain.doFilter(request, response)
} catch (e: InvalidTokenException) {
response.status = e.userErrorCode.status.value()
response.contentType = MediaType.APPLICATION_JSON_VALUE
response.characterEncoding = "UTF-8"
objectMapper.writeValue(
response.writer,
Response.error(ErrorResponse(e.userErrorCode.name, e.toString())
)
)
}
}
}
에러를 처리하는 필터를 다음과 같이 SecurityConfig에 추가해주면 된다.
.addFilterBefore(exceptionHandlerFilter(), UsernamePasswordAuthenticationFilter::class.java)
문제가 해결된 방법
현재 구현은 Controller까지 도달되어 요청을 처리할 때 발생하는 예외들에 대해서만 처리하는 @ControllerAdvice에 ErrorResponse로 변환하여 반환하도록 구현하고, Filter에서 발생하는 예외 처리는 구현되지 않았기 때문에 예상한대로 동작하지 않았다. Filter에서 문제가 발생하면 이를 처리하는 또 다른 Filter를 구현하여 문제를 해결할 수 있다고 했다.
그런데, 또 다른 Filter를 구현하지 않아도 다음과 같이 AuthenticationEntryPoint에서 응답 처리를 하는 것만으로 문제를 해결할 수 있었다.
@Component
class AuthenticationEntryPoint(
private val objectMapper: ObjectMapper,
) : AuthenticationEntryPoint {
override fun commence(
request: HttpServletRequest,
response: HttpServletResponse,
exception: AuthenticationException,
) {
val errorResponse = ErrorResponse.of(exception)
//... 생략 Object Mapper를 이용해서 Json타입의 ErrorResponse로 반환하도록 수정
}
}
어떻게 인증 필터에서 발생한 예외에 대한 응답이 AuthenticationEntryPoint를 통해서 원하는 ErrorResponse 형식으로 응답할 수 있었을까?
(다음 편에 계속...)
↓
https://dodop-blog.tistory.com/448
(참고한 사이트)
https://www.baeldung.com/spring-security-exceptionhandler
https://mangkyu.tistory.com/173
https://jaehun2841.github.io/2018/08/25/2018-08-18-spring-filter-interceptor/#Spring-Request-Flow
https://gmlwjd9405.github.io/2018/11/04/servlet-vs-jsp.html