문제 발생
SpringBoot Validation 패키지를 추가한 뒤 Validation을 테스트하던 중 이상한 문제가 발생했다.
400 Bad Request가 떠야하는 부분에서 403 Forbidden이 뜨는 것이었다.
단순하게 403만 뜨는게 아니라 body가 완전히 비어져있으며, Spring의 로그에 관련한 어떠한 에러도 뜨지 않는다.


분명 Spring 로그를 보면 validation에 성공했고, 400을 내려야할 것 같은데 왜 다른 에러도 없이 403이 내려갈까??
Spring의 에러처리와 Tomcat
에러 발생
Validation쪽이든 어디서든, 에러가 발생하면 DefaultHandlerExceptionResolver
로 넘어가서 처리하게 된다.
@Nullable
protected ModelAndView handleMethodArgumentNotValidException(MethodArgumentNotValidException ex,
HttpServletRequest request, HttpServletResponse response, @Nullable Object handler) throws IOException {
return null;
}
---
...
else if (ex instanceof MethodArgumentNotValidException theEx) {
mav = handleMethodArgumentNotValidException(theEx, request, response, handler);
}
...
return (mav != null ? mav :
handleErrorResponse(errorResponse, request, response, handler));
---
protected ModelAndView handleErrorResponse(ErrorResponse errorResponse,
HttpServletRequest request, HttpServletResponse response, @Nullable Object handler) throws IOException {
if (!response.isCommitted()) {
HttpHeaders headers = errorResponse.getHeaders();
headers.forEach((name, values) -> values.forEach(value -> response.addHeader(name, value)));
int status = errorResponse.getStatusCode().value();
String message = errorResponse.getBody().getDetail();
if (message != null) {
response.sendError(status, message);
}
else {
response.sendError(status);
}
}
else {
logger.warn("Ignoring exception, response committed. : " + errorResponse);
}
return new ModelAndView();
}
스프링6 부터 구조가 약간 변경되어 기존보다 분석하기가 힘들어 졌지만, 결과적으로 response.sendError
를 호출하는, 같은 방식으로 동작하게 된다.
문제는 여기서 발생한다.
Tomcat이 처리
스프링 내부에서 발생한 Exception인 MethodArgumentNotValidException
를 처리해줬지만, response에 에러를 넘기게 되면 WAS에서 이 에러를 받게 된다.
SpringBoot의 WAS인 Tomcat은 에러를 보고 ErrorPage를 띄워달라고 다시 Spring에 요청하게 된다. 즉, /error를 요청하게 된다.
[nio-8080-exec-3] o.s.security.web.FilterChainProxy : Securing POST /api/user
[nio-8080-exec-3] o.s.s.w.a.AnonymousAuthenticationFilter : Set SecurityContextHolder to anonymous SecurityContext
[nio-8080-exec-3] o.s.s.w.a.Http403ForbiddenEntryPoint : Pre-authenticated entry point called. Rejecting access
[nio-8080-exec-3] o.a.c.c.C.[Tomcat].[localhost] : Processing ErrorPage[errorCode=0, location=/error]
[nio-8080-exec-3] o.s.security.web.FilterChainProxy : Securing POST /error
[nio-8080-exec-3] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController#error(HttpServletRequest)
[nio-8080-exec-3] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController#error(HttpServletRequest)
[nio-8080-exec-3] o.s.s.w.a.AnonymousAuthenticationFilter : Set SecurityContextHolder to anonymous SecurityContext
[nio-8080-exec-3] o.s.s.w.a.Http403ForbiddenEntryPoint : Pre-authenticated entry point called. Rejecting access
/error 접근 불가
위 에러의 마지막 부분에 보면 tomcat이 POST /error
을 요청했는데, 바로 BasicErrorController
로 넘어가는 것을 볼 수 있다.
http.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(conf -> conf.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.formLogin(AbstractHttpConfigurer::disable).httpBasic(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.POST, "/api/users").permitAll()
.requestMatchers(HttpMethod.POST, "/api/users/login").permitAll()
.anyRequest().authenticated());
SpringSecurity의 설정을 잘 보자. 특정 API 외에는 모두 authenticate가 된 상태에서만 처리되도록 설정해놓았다.
따라서, jwt토큰이 첨부되어 있지 않는 경우엔 /error 접근 시 authentication에서 에러가 나게 되므로 authentication에러를 처리하는 Http403ForbiddenEntryPoint
로 넘어가게 된다.
Http403ForbiddenEntryPoint
public class Http403ForbiddenEntryPoint implements AuthenticationEntryPoint {
private static final Log logger = LogFactory.getLog(Http403ForbiddenEntryPoint.class);
/**
* Always returns a 403 error code to the client.
*/
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException arg2)
throws IOException {
logger.debug("Pre-authenticated entry point called. Rejecting access");
response.sendError(HttpServletResponse.SC_FORBIDDEN, "Access Denied");
}
}
Http403ForbiddenEntryPoint
는 AuthenticationEntryPoint
를 구현하고 있고, SpringSecurity에 default로 저장되어있다.
구현을 보면 알겠지만, 항상 403 Forbidden이 반환되도록 구현되어있다.
해결 방법
JWT가 있는 경우엔..?
결론부터 말하면 jwt가 있는 경우에도 403에러가 뜨게 구현되어있다.
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtilities jwtUtilities;
...
}
JwtAuthenticationFilter
를 보면 OncePerRequestFilter
를 상속받고 있다. 이름에서도 알 수 있듯이, 하나의 request에 한 번만 처리된다.
(정확히는 shouldNotFilterErrorDispatch
메서드가 반드시 true를 반환하므로 dispatcher에서 skip된다)
/error를 찾는 과정도 하나의 request에서 동작하므로(위 에러 코드를 보면 같은 thread위에 있음을 알 수 있다) 두 번째 처리 과정에서는 skip된다.
/error 열어주기
http.csrf(AbstractHttpConfigurer::disable)
.exceptionHandling(config -> config.authenticationEntryPoint(jwtAuthenticationEntryPoint))
.sessionManagement(conf -> conf.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.formLogin(AbstractHttpConfigurer::disable).httpBasic(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.POST, "/api/users").permitAll()
.requestMatchers(HttpMethod.POST, "/api/users/login").permitAll()
.requestMatchers("/error").permitAll()
.anyRequest().authenticated());
해결 방법은 간단하다..requestMatchers("/error").permitAll()
한 줄만 출력해주면 된다. 에러페이지는 인증여부와 무관하게 모두가 접근할 수 있어야하므로 어떻게보면 타당하다고 볼 수 있다.
다른 방법은 없을까?(/error가 꼭 필요할까?)
사실 API만 제공하는 서버의 경우에 에러가 날 때 마다 /error를 찾는 것 자체가 불 필요한 작업처럼 보일 수 있다.
{
"timestamp": "2023-06-16T02:28:05.692+00:00",
"status": 400,
"error": "Bad Request",
"path": "/api/users"
}
하지만 이렇게 SpringBoot에서 정형화된 에러를 내려주는 것이 바로 지금까지 설명한 로직이다.
@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);
}
이렇게 정형화된 에러를 내려주는 로직은 BasicErrorController
에 구현되어 있다.
@RestControllerAdvice
와 @ExceptionHanlder
를 통해서 이를 구현할 수 있는데, 지금까지 프로젝트 하면서 항상 이런 방식으로 구현해왔기 때문에 이 글에서 발생했던 에러를 맞닥드리지 못 했던 것 같다.
정리
- 어떠한 에러가 발생하더라도 Tomcat에서 /error에 접근한다.
- /error에 대한 접근이 authenticate된 상태에서만 가능하므로 jwt토큰이 없는 경우 authentication에러를 처리한다.
- authentication 에러를
Http403ForbiddenEntryPoint
에서 처리하므로 항상 403에러가 내려간다.
- jwt를 넣어준다고 해도 Jwt처리 필터는 요청당 1번 동작하므로 처리되지 않는다.
- Jwt처리 필터를 매 filterchain 동작마다 동작하도록 변경한다고 해도, /error에 대한 처리는 authentication이 이루어지지 않았을 때도 내려가야하므로 부적합한 대응이다.
- /error에 대한 요청을 authenticate되지 않은 상태에서도 수용할 수 있도록 변경해주는 것이 정답이다.
- 사실
@RestControllerAdvice
와@ExceptionHanlder
를 통해서 원하는 형식의 에러로 내려주는 것이 궁극적인 해결방법이라고 할 수 있다.
- 사실
참고




Uploaded by N2T
(23.06.16 01:05)에 작성된 글 입니다.