문제 상황
팀원이 spring filter가 이상하다고 도움을 요청했다. 확인해보니 swagger에서 실행한 API 요청이 filterChain을 2번 타는 상황이었다. 더 자세히 살펴보니 AccessDeniedHandler를 거친 요청이 다시 필터를 거쳐서 AuthenticationEntryPoint로 도달하는 현상이다. 403이 발생해야 하는 요청이 401이 발생한 것이다.
그래서 내가 진행하던 프로젝트에서 실행해보니 이런 문제는 발생하지 않았다. 팀원의 spring boot 버전은 3.x이고, 내가 테스트해본 환경은 spring boot 2.x 버전이다.
뿐만 아니라 custom filter를 추가하고, 해당 필터에서 exception을 던져도 다시 그 filter를 호출한다. doFilter를 하지 않았는데 어떻게 다시 요청이 들어오지?
원인 파악
디버그 모드로 request의 uri를 찍어보면 처음은 요청한 url(/users)이 잘 나오는데, 두번째에서는 에러 url(/error)이 나타난다. 처음 요청에서 문제가 발생했고, 이를 해결하기 위해 /error로 rediect 한 것으로 보인다. 즉 실제로 요청이 2번 있었기 때문에 filter chain을 2번 탄 것이다.
그럼 왜 redirect를 하고 있을까?
HttpServletResponse 의 sendError() 함수를 사용하기 때문에 발생했다. 문제가 발생한 코드를 살펴보자.
@Component
class JwtAccessDeniedHandler : AccessDeniedHandler {
@Throws(IOException::class, ServletException::class)
override fun handle(
request: HttpServletRequest,
response: HttpServletResponse,
accessDeniedException: AccessDeniedException
) {
response.sendError(HttpServletResponse.SC_FORBIDDEN)
}
}
AccessDeniedHandler를 custom하게 등록했는데, 이 때 sendError 함수를 사용했다. 해당 함수의 주석을 보면 다음과 같이 나와있다.
sendError 코드의 주석 내용
Sends an error response to the client using the specified status code and clears the output buffer. The server defaults to creating the response to look like an HTML-formatted server error page containing the specified message, setting the content type to "text/ html", leaving cookies and other headers unmodified. If an error-page declaration has been made for the web application corresponding to the status code passed in, it will be served back in preference to the suggested msg parameter. If the response has already been committed, this method throws an IllegalStateException. After using this method, the response should be considered to be committed and should not be written to.
또한 Spring 공식 문서에도 잘 나와있는데, /error 경로를 맵핑하여 JSON 응답 혹은 WhiteLabel 에러 페이지를 보여준다고 설명한다.
즉 Spring에서는 HttpServletContainer의 에러를 처리하기 위해 /error 경로를 활용하고 있고, sendError() 함수 호출 시 /error 경로로 redirect 하여 등록된 WhiteLabel 페이지를 보여주거나 error json을 내려준다.
그럼 왜 spring boot 2.x에서는 잘 됐을까?
stackoverflow를 확인해보면, spring boot 2.x부터 error endpoint가 bypass 되던 설정을 제거하고, secure 설정을 default로 넣는다고 되어있다.
Spring Boot 2.0 doesn’t deviate too much from Spring Security’s defaults, as a result of which some of the endpoints that bypassed Spring Security in Spring Boot 1.5 are now secure by default. These include the error endpoint and paths to static resources such as /css/**, /js/**, /images/**, /webjars/**, /**/favicon.ico. If you want to open these up, you need to explicitly configure that.
여기서 알 수 있는 점은, /error 경로에 대해서 spring boot 2 부터는 bypass가 아니기 때문에 filterChain을 거친다는 의미이다.
Spring Security 5.7 공식문서를 확인해보면, DispatcherType.ERROR 요청은 AuthorizationFilter를 타지 않는다고 한다.
By default, the AuthorizationFilter does not apply to DispatcherType.ERROR and DispatcherType.ASYNC. We can configure Spring Security to apply the authorization rules to all dispatcher types by using the shouldFilterAllDispatcherTypes method
그러나 Spring Security 6 부터는 모든 Dispatch에 대해서는 AuthorizationFilter를 타게 변경되었다. [공식문서]
All Dispatches Are Authorized
The AuthorizationFilter runs not just on every request, but on every dispatch. This means that the REQUEST dispatch needs authorization, but also FORWARDs, ERRORs, and INCLUDEs.
추가로 filter chain이 2번 탈 수 있기 때문에 주의하라는 내용도 있다.
then Boot will dispatch it to the ERROR dispatch.
In that case, authorization also happens twice; once for authorizing /endpoint and once for dispatching the error.
For that reason, you may want to permit all ERROR dispatches.
정리하자면 Authorization에서 해당 요청에 대한 권한 처리를 수행하는데, DispatcherType.ERROR 인 경우 5.7에서는 AuthorizationFilter를 타지 않고, 6.0 부터는 AuthorizationFilter를 타게 되므로 /error 에 대한 권한 처리가 필요하다.
결론
- request에서 에러가 발생하면 whitelabel 페이지를 보여주거나, error json을 내려주는 작업을 위해서 /error 로 요청을 다시 보낸다. (DispatcherType.ERROR)
- Spring Boot 2.x 부터 error endpoint에 대해서 bypass 설정은 없어지고, secure가 기본 설정이 되었다
- Spring Security 5.7.9에서는 DispatcherType.ERROR 는 AuthorizationFilter 를 거치지 않기 때문에 filter를 타긴 하지만 권한 에러는 발생하지 않는다
- Spring Security 6.3.2 에서는 모든 Dispatcher에 대해서 AuthorizationFilter 를 타기 때문에 /error 요청에 대한 권한 에러가 발생하게 된다
- 에러발생 케이스 흐름
- AccessDeniedHandler 에서 403 후 sendError → /error → AuthenticationEntryPoint에서 401 발생 (인증정보가 없기 때문)
해결방법
1. DispatcherType.ERROR 를 통과시키기
/error 로 요청이 갔을 때 필터를 타는걸 막을 순 없으므로, 해당 DispatcherType을 permitAll 해준다.
이렇게 추가해준다면, 결국 filterChain은 2번 호출되게 된다.
http {
authorizeHttpRequests {
authorize(DispatcherTypeRequestMatcher(DispatcherType.FORWARD), permitAll)
authorize(DispatcherTypeRequestMatcher(DispatcherType.ERROR), permitAll)
authorize("/endpoint", permitAll)
authorize(anyRequest, denyAll)
}
}
2. sendError() 를 쓰지 않기
ExceptionFilter, AuthenticationEntryPoint , AccessDeniedHandler 등 에러를 처리하는 곳에서 sendError 함수를 사용하지 않는다.
filter를 2번 탔을 때 filter를 거치면서 발생하는 오버헤드가 존재하기 때문에, /error 로 에러처리를 하지 않는 이 방법을 추천한다.
- AS-IS
class JwtAuthenticationEntryPoint : AuthenticationEntryPoint { @Throws(IOException::class) override fun commence( request: HttpServletRequest, response: HttpServletResponse, authException: AuthenticationException ) { response.sendError(HttpServletResponse.SC_UNAUTHORIZED) } } - TO-BE
class JwtAuthenticationEntryPoint : AuthenticationEntryPoint { @Throws(IOException::class) override fun commence( request: HttpServletRequest, response: HttpServletResponse, authException: AuthenticationException ) { response.status = HttpServletResponse.SC_UNAUTHORIZED } }
비고
FilterChain에서 AuthorizationFilter 가 안보임
spring security 5.7에서는 가능한 현상이다.
만약 security 설정에서 authorizeRequests 를 사용했다면, FilterChainProxy 가 맨 마지막에 생성되어 있을 것이다.
spring 공식문서에서는 AuthorizationFilter를 사용하고 싶다면, authorizeRequests → authorizeHttpRequest 로 바꾸어서 사용하라고 권장한다. [공식문서]
그럼 바꿔서 얻는 이점은 무엇일까?
spring은 이렇게 설명하고 있습니다
1. Uses the simplified AuthorizationManager API instead of metadata sources, config attributes, decision managers, and voters. This simplifies reuse and customization.
2. Delays Authentication lookup. Instead of the authentication needing to be looked up for every request, it will only look it up in requests where an authorization decision requires authentication.3. Bean-based configuration support.
migration을 하려면 [공식문서]를 참고하면 된다.
관련 문서
- DispatcherType이란?
- Spring 공식 문서
- stackoverflow
'Spring' 카테고리의 다른 글
| PermitAll의 함정 (0) | 2024.08.16 |
|---|---|
| Spring @RequestParam 주의할 점 (0) | 2024.08.16 |
| 테스트 환경 설정하기 (1) | 2024.08.08 |








