문제 상황
개발 중인 프로젝트에서 json 형태로 로그인을 처리하기 위해 AbstractAuthenticationProcessingFilter을 상속받는 커스텀 AuthenticationFilter를 구현하고 시큐리티에 필터로 등록해 사용하려고 했다.
다음과 같이 커스텀 Json 로그인 처리 필터를 구현하고
public class JsonEmailPasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
...
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
...
}
}
다음과 같이 시큐리티 설정을 작성했다. 로그인 실패 성공 시에 응답 처리도 json으로 내려주기 위해 Success, Failure Handler도 같이 등록해주었다.
@Bean
public JsonEmailPasswordAuthenticationFilter jsonEmailPasswordAuthenticationFilter() {
JsonEmailPasswordAuthenticationFilter filter = new JsonEmailPasswordAuthenticationFilter("/api/auth/login", objectMapper);
filter.setSecurityContextRepository(new HttpSessionSecurityContextRepository());
filter.setRememberMeServices(getRememberMeServices());
// 여러가지 핸들러 등록
filter.setAuthenticationManager(authenticationManager());
filter.setAuthenticationSuccessHandler(new CustomAuthenticationSuccessHandler());
filter.setAuthenticationFailureHandler(new CustomAuthenticationFailureHandler());
return filter;
}
처음에는 뭔가 로직이 실패했을 때 이 핸들러들에서 Exception을 던져주기만 하면 @RestControllerAdvice 어노테이션이 지정된 ExceptionHandler 클래스에서 이를 처리해줄 것이라고 기대했다. 그래서 예를 들어 로그인 실패 시 호출되는 CustomAuthenticationFailureHandler의 경우 아래와 같이 작성했었다.
@RequiredArgsConstructor
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
throw new UsernameNotFoundException(ErrorMessages.LOGIN_FAILURE_ERROR_MESSAGE);
}
}
코드도 간단하고 ExceptionHandler 클래스 한 곳에서 예외를 처리하므로 깔끔하군 이라고 생각하고 테스트를 돌려봤는데 ExceptionHandler에서 작성한 응답이 아니라 스프링 기본 예외 응답이 출력되는 것을 확인했다.
기대한 것
실제 출력
원인
원인은 로그인을 시도하는 필터가 말그대로 '필터'이기 때문이었다. WAS에 들어온 요청은 다음과 같은 순서로 전달되는데
ExceptionHandler 등 처리 로직은 스프링의 DispatcherServlet에서 핸들러를 호출한 후 예외가 발생했을 때 해당 예외 처리 핸들러를 호출해주는 것이기 때문에
서블릿(DispatcherServlet)까지도 전달되지 못하고 필터에서 예외가 발생했으니, 스프링의 기능을 통해 만들어진 ExceptionHandler에서는 이를 알아차릴 방법이 없는 것이다.
// DispatcherServlet.doDispatch()
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
// ...
try {
ModelAndView mv = null;
Exception dispatchException = null;
try {
// ...
// 매핑된 핸들러(컨트롤러) 호출
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
// ...
catch (Exception ex) {
// 컨트롤러에서 발생한 예외가 있다면 catch
dispatchException = ex;
}
// ...
processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);
// ...
}
// DispatcherServlet.processDispatchResult()
private void processDispatchResult(HttpServletRequest request, HttpServletResponse response,
@Nullable HandlerExecutionChain mappedHandler, @Nullable ModelAndView mv,
@Nullable Exception exception) throws Exception {
boolean errorView = false;
if (exception != null) {
// ...
else {
Object handler = (mappedHandler != null ? mappedHandler.getHandler() : null);
// processHandlerException() 이 핸들러(컨트롤러)에서 발생한 예외를 처리
mv = processHandlerException(request, response, handler, exception);
errorView = (mv != null);
}
}
// ...
}
서블릿의 예외 처리 로직
따라서, 기본 WAS의 예외 처리 로직에 따라 동작하게 된다. WAS는 WAS에 예외가 도달하면 WAS는 해당 예외와 매핑된 url이 있는지 확인하고 해당 주소로 다시 ERROR 요청을 날리는 방식으로 예외를 처리한다.
스프링부트의 기본 예외 처리 컨트롤러
스프링부트는 기본적으로 아래와 같이 예외에 대해 url을 등록하고
// ErrorMvcAutoConfiguration.ErrorPageCustomizer
static class ErrorPageCustomizer implements ErrorPageRegistrar, Ordered {
@Override
public void registerErrorPages(ErrorPageRegistry errorPageRegistry) {
ErrorPage errorPage = new ErrorPage(
this.dispatcherServletPath.getRelativePath(this.properties.getError().getPath())); // 기본은 /error
errorPageRegistry.addErrorPages(errorPage);
}
}
해당 url에 대해 기본 예외 응답을 내려주는 컨트롤러를 다음과 같이 제공한다.
@RequestMapping("${server.error.path:${error.path:/error}}")
public class BasicErrorController extends AbstractErrorController {
// ...
@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
HttpStatus status = getStatus(request);
if (status == HttpStatus.NO_CONTENT) {
return new ResponseEntity<>(status);
}
Map<String, Object> body = getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.ALL));
return new ResponseEntity<>(body, status);
}
// ...
}
따라서, 시큐리티 필터 핸들러에서 바로 예외를 내렸을 때 처음에 본 스프링의 기본 예외 응답이 내려왔던 것이다. 보면 처음에 날린 요청이 아닌 ERROR 타입의 요청이 스프링부트의 BasicErrorController에 전달된 것을 확인할 수 있다.
해결 방법
이 문제의 해결 방법은 시큐리티 필터에서 호출하는 핸들러에서 Exception을 던지지 말고 바로 원하는 응답을 만들어서 반환하면 된다. 아래와 같이 ObjectMapper와 Response의 Writer 객체를 이용해 응답에 원하는 값을 바로 작성해주었다.
// CustomAuthenticationFailureHandler 클래스
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
ErrorResponseDto errorResponse = ErrorResponseDto.builder()
.message(ErrorMessages.LOGIN_FAILURE_ERROR_MESSAGE)
.build();
response.setContentType(APPLICATION_JSON_VALUE);
response.setCharacterEncoding(UTF_8.name());
response.setStatus(SC_BAD_REQUEST);
objectMapper.writeValue(response.getWriter(), errorResponse);
}
이렇게 하면 예외가 발생한 필터에서 작성된 응답이 호출됐던 필터를 타고 다시 WAS로 전달되어 클라이언트에게 원하는 응답이 내려진다.
결론
스프링 시큐리티의 로그인 처리는 필터에서 수행되므로 스프링에서 제공하는 ExceptionHandler 를 사용할 수 없다! 직접 응답을 만들어 Response 객체에 작성해주자.