문제 발생
SpringBoot Validation 패키지를 추가한 뒤 Validation을 테스트하던 중 이상한 문제가 발생했다.
400 Bad Request가 떠야하는 부분에서 403 Forbidden이 뜨는 것이었다.
단순하게 403만 뜨는게 아니라 body가 완전히 비어져있으며, Spring의 로그에 관련한 어떠한 에러도 뜨지 않는다.
![](https://blog.kakaocdn.net/dn/cs56po/btsj6B1Q9w2/SGXQqK1mBVJrdODKdVieCK/img.png)
![](https://blog.kakaocdn.net/dn/MSnyw/btsj8WYe1k2/aHxktgDN6w1gcEkCKbg6ak/img.png)
분명 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
를 통해서 원하는 형식의 에러로 내려주는 것이 궁극적인 해결방법이라고 할 수 있다.
- 사실
참고
![](https://cdn.sstatic.net/Sites/stackoverflow/Img/apple-touch-icon.png?v=c78bd457575a)
![](https://cdn.sstatic.net/Sites/stackoverflow/Img/apple-touch-icon@2.png?v=73d79a89bded)
![](https://cdn.sstatic.net/Sites/stackoverflow/Img/apple-touch-icon.png?v=c78bd457575a)
![](https://cdn.sstatic.net/Sites/stackoverflow/Img/apple-touch-icon@2.png?v=73d79a89bded)
Uploaded by N2T
(23.06.16 01:05)에 작성된 글 입니다.