🌳 에러 코드
컨트롤러나 서비스 계층에서 발생한 예외의 종류에 상관없이 403 Forbidden 응답이 반환되는 문제가 생겼다.
기본적으로 스프링에서는 따로 예외 처리를 하지 않았다면 예외 발생 시 500 에러가 발생한다. 그런데 스프링 시큐리티를 적용하면 메소드에서 예외가 발생했을 때 403 에러가 발생한다. 심지어 존재하지 않는 URL로 접속하여 404 Not Found가 발생해야 하는 상황에서도 403 Forbidden이 발생했다.
🌳 해결 방법
스프링 공식 블로그에 따르면, 스프링부트에서는 에러가 발생하면 /error라는 URI로 매핑을 시도한다. 실제로 해당 URI로 이동하면 아래와 같은 페이지가 나타난다.
Whitelabel Error Page 자체는 403 에러와 관련이 없지만 에러가 발생하면 /error로 매핑을 시도한다는 것이 핵심이다.
@Bean
fun filterChain(http: HttpSecurity) = http
.csrf().disable()
.headers { it.frameOptions().sameOrigin() }
.authorizeHttpRequests {
it.requestMatchers(*allowedUris).permitAll()
.anyRequest().authenticated()
}
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.addFilterBefore(jwtAuthenticationFilter, BasicAuthenticationFilter::class.java)
.build()!!
일반적으로 스프링 시큐리티 설정을 할 땐 아래와 같은 형태로 설정하게 될 것이다.(JWT 기준)
일반적으로 permitAll()을 통해 모든 사용자의 접근을 허용할 URI에는 권한 검증이 필요하지 않은 URI만 추가한다. 그리고 위에서 언급했듯이 스프링부트 프로젝트에서 에러가 발생하면 /error로 매핑한다. 그런데 /error는 모두에게 허용된 URI에 포함되지 않는다.
그래서 에러 페이지에도 인증 절차가 요구되어 403 에러가 발생하는 것이다.
403 에러가 발생하는 과정
1. 예외가 발생하면 ExceptionTranslationFilter에서 요청 프로세스를 계속 진행한다.
2. 사용자 인증되지 않았거나 AuthenticationException이 발생했다면 사용자 인증을 시작한다. 이 과정에서 기존의 SecurityContextHolder가 비워지고, AuthenticationEntryPoint는 클라이언트에게 자격 증명을 요청하기 위한 절차를 수행한다.
4. 그 외에는 AccessDeniedHandler가 AccessDeniedException을 발생시킨다.
위에 예시의 자바 코드이다.
try {
filterChain.doFilter(request, response);
} catch (AccessDeniedException | AuthenticationException ex) {
if (!authenticated || ex instanceof AuthenticationException) {
startAuthentication();
} else {
accessDenied();
}
}
startAuthentication()을 진행하는 과정에서 AuthenticationEntryPoint에 자격 증명 요청을 위임하게 되는데, 스프링 시큐리티는 별도로 설정하지 않으면 여기서 Http403ForbiddenEntryPoint를 사용한다.
해결 방법
403 에러가 발생하는 원인은 스프링 시큐리티에 기본으로 등록된 AuthenticationEntryPoint인 Http403ForbiddenEntryPoint 때문이라고 했다. 그렇다면 별도의 엔트리포인트를 작성하여 스프링 시큐리티에 등록한다면 원하는 결과를 얻을 수 있다.
@Component
class CustomAuthenticationEntryPoint : AuthenticationEntryPoint {
override fun commence(request: HttpServletRequest?, response: HttpServletResponse?, authException: AuthenticationException?) {
response!!.contentType = MediaType.APPLICATION_JSON_VALUE
response.characterEncoding = "UTF-8"
val writer = response.writer
val objectMapper = ObjectMapper()
writer.write(objectMapper.writeValueAsString(ApiResponse.error("페이지를 찾을 수 없습니다.")))
writer.flush()
}
}
@Configuration
class SecurityConfig(
private val jwtAuthenticationFilter: JwtAuthenticationFilter,
private val entryPoint: AuthenticationEntryPoint
) {
private val allowedUris = arrayOf(..., "/error")
@Bean
fun filterChain(http: HttpSecurity) = http
.csrf().disable()
.headers { it.frameOptions().sameOrigin() }
.authorizeHttpRequests {
it.requestMatchers(*allowedUris).permitAll()
.anyRequest().authenticated()
}
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.addFilterBefore(jwtAuthenticationFilter, BasicAuthenticationFilter::class.java)
.exceptionHandling { it.authenticationEntryPoint(entryPoint) }
.build()!!
}
마치며..
스프링 시큐리티를 사용하면서 403 Forbidden 응답 반환에 대해 많은 의문이 들었는데, 이번을 통해 궁금증이 해소가 되었다.
스프링 시큐리티는 좋은 기능을 많이 제공하지만, 이렇게 별도로 설정해야하는 부분도 있는 점을 깨달았다.
참고 링크
https://spring.io/blog/2013/11/01/exception-handling-in-spring-mvc
https://velog.io/@park2348190/Spring-Security%EC%9D%98-Unauthorized-Forbidden-%EC%B2%98%EB%A6%AC
'코딩 에러 및 질문' 카테고리의 다른 글
Script does not exist at specified location: / (0) | 2023.11.27 |
---|---|
javax.persistence.NonUniqueResultException (0) | 2023.10.22 |
응답 헤더 없는 CORS 오류 with Spring Security (0) | 2023.09.17 |
월별 성별에 따른 조회 JPA - nativeQuery (0) | 2023.09.08 |
Mysql - (년도 + 월) 합쳐서 조회하기 (0) | 2023.09.07 |