목차
들어가기 전에
스프링부트 프로젝트에 스프링 시큐리티를 처음 적용해보면 여러 난관에 부딪히게 된다. 그 중 하나가 컨트롤러나 서비스 계층에서 발생한 예외의 종류에 상관없이 403 Forbidden 응답이 반환되는 것이다.
기본적으로 스프링에서는 따로 예외 처리를 하지 않았다면 예외 발생 시 500 에러가 발생한다. 그런데 스프링 시큐리티를 적용하면 메소드에서 예외가 발생했을 때 403 에러가 발생한다. 심지어 존재하지 않는 URL로 접속하여 404 Not Found가 발생해야 하는 상황에서도 403 Forbidden이 발생한다.
그 이유와 해결 방법에 대해서 정리하려고 한다. 다만 아직 스프링 시큐리티의 동작 과정을 완전히 이해하진 못했기 때문에 틀린 내용이 있을 수도 있다.
403 에러가 발생하는 원인
우선 이 현상의 원인을 파악하기 위해선 스프링부트에서 에러가 발생했을 때 나타나는 Whitelabel Error Page가 어떻게 나타나는지 알아야한다.
스프링 공식 블로그에 따르면 스프링부트에서는 에러가 발생하면 /error
라는 URI로 매핑을 시도한다. 실제로 해당 URI로 이동하면 아래와 같은 페이지가 나타난다.
따로 에러가 발생하여 자동으로 나타난 것이 아니라 강제로 이동했기 때문에 status가 999로 나타났지만 굉장히 익숙한 Whitelabel Error Page가 나타난다.
WhiteLabel Error Page가 403이랑 무슨 연관이 있는데?
그런데 이 페이지와 스프링 시큐리티를 설정한 상태에서 에러 발생 시 403 에러만 발생하는 것이 무슨 연관이 있는 것일까?
Whitelabel Error Page 자체는 403 에러와 관련이 없지만 에러가 발생하면 /error
로 매핑을 시도한다는 것이 핵심이다.
일반적으로 스프링 시큐리티 설정을 할 땐 아래와 같은 형태로 설정하게 될 것이다.(JWT 기준)
@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()!!
일반적으로 permitAll()
을 통해 모든 사용자의 접근을 허용할 URI에는 권한 검증이 필요하지 않은 URI만 추가한다. 그리고 위에서 언급했듯이 스프링부트 프로젝트에서 에러가 발생하면 /error
로 매핑한다. 그런데 /error
는 모두에게 허용된 URI에 포함되지 않는다.
이때문에 에러 페이지에도 인증 절차가 요구되어 403 에러가 발생하는 것이다.
403 에러가 발생하는 과정
스프링 시큐리티의 예외 처리 과정은 다음과 같다.
- 예외가 발생하면 ExceptionTranslationFilter에서 요청 프로세스를 계속 진행한다.
- 사용자 인증되지 않았거나 AuthenticationException이 발생했다면 사용자 인증을 시작한다. 이 과정에서 기존의 SecurityContextHolder가 비워지고, AuthenticationEntryPoint는 클라이언트에게 자격 증명을 요청하기 위한 절차를 수행한다.
- 그 외에는 AccessDeniedHandler가 AccessDeniedException을 발생시킨다.
이 과정을 자바 의사코드로 나타내면 다음과 같다.
try {
filterChain.doFilter(request, response);
} catch (AccessDeniedException | AuthenticationException ex) {
if (!authenticated || ex instanceof AuthenticationException) {
startAuthentication();
} else {
accessDenied();
}
}
startAuthentication()
을 진행하는 과정에서 AuthenticationEntryPoint에 자격 증명 요청을 위임하게 되는데, 스프링 시큐리티는 별도로 설정하지 않으면 여기서 Http403ForbiddenEntryPoint를 사용한다.
그리고 Http403ForbiddenEntryPoint를 살펴보면 아래와 같이 아무것도 하지 않고 단순히 403 에러를 반환하는 것을 볼 수 있다.
그리고 그 외의 경우에는 어쨌든 AccessDeniedException을 발생시켜서 403 에러가 발생한다.
어떻게 해결할 수 있을까?
이 문제는 여러 방법으로 해결할 수 있다. 차근차근 살펴보자.
permitAll()
URI 목록에 /error
추가
일단 이 문제는 anyRequest().authenticated()
로 인해 /error
도 인증이 필요한 것으로 간주되어 발생한 것이기 때문에 모두에게 허용할 URI 목록에 /error
를 추가하면 단순하게 해결할 수 있다.
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)
.build()!!
이렇게 하면 정상적으로 에러 코드가 담긴 Whitelabel Error Page나 스프링 기본 에러 응답 JSON을 받을 수 있다.
정말 간단한 해결책이지만 이렇게 하면 적절한 응답 메시지를 전달할 수 없기 때문에 좋은 해결책은 아니다.
적절한 예외 처리
컨트롤러나 서비스 등의 메소드에서 발생하는 예외는 적절한 예외처리를 통해 아무 내용도 없는 403 에러가 반환되는 것을 막을 수 있다. 예를 들어 @ControllerAdvice
나 @RestControllerAdvice
를 통해 예외가 발생했을 때의 응답을 지정한다면 메소드에서 예외가 발생하더라도 정해놓은 응답을 반환하게 된다.
@RestControllerAdvice
class ExceptionHandler {
...
@ExceptionHandler(Exception::class)
fun handleUnexpectedException() =
ResponseEntity.internalServerError().body(ApiResponse.error("서버에 문제가 발생했습니다."))
...
}
이 방법은 컨트롤러 계층에서 발생하는 모든 예외에 대해 적절한 응답을 할 수는 있지만 404 에러처럼 컨트롤러를 거치지 않고 발생하는 에러 코드에는 대응할 수 없기 때문에 아래의 방법과 병행하는 것이 좋다.
AuthenticationEntryPoint 구현체 등록
위에서 이유를 불문하고 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()!!
}
이렇게 설정하고 다시 존재하지 않는 페이지로 이동하면 의도했던 결과를 얻을 수 있다.
엔트리포인트 구현체에 처리 로직을 일일이 작성하지 않고 [Spring Security] Spring Security 예외를 @ControllerAdvice와 @ExceptionHandler를 사용하여 전역으로 처리해보자에서 사용했던 방식처럼 HandlerExceptionResolver에 예외를 넘겨버리고, @ControllerAdvice
나 @RestControllerAdvice
에서 예외에 따라 적절한 응답을 반환하게 할 수도 있다. 아래 코드에서 NotFoundException은 별도로 작성한 커스텀 예외이다.
@Component
class CustomAuthenticationEntryPoint(
@Qualifier("handlerExceptionResolver")
private val resolver: HandlerExceptionResolver
) : AuthenticationEntryPoint {
override fun commence(request: HttpServletRequest?, response: HttpServletResponse?, authException: AuthenticationException?) {
val exception = when (response!!.status) {
200 -> return
401, 403 -> request!!.getAttribute("exception") as Exception
404 -> NotFoundException("요청하신 리소스를 찾을 수 없습니다.")
408 -> TimeoutException("요청 시간이 초과되었습니다.")
else -> Exception("서버에 문제가 발생했습니다.")
}
resolver.resolveException(request!!, response, null, exception)
}
}
이 방법은 컨트롤러나 서비스 계층에서 발생하는 일반적인 예외를 잡을 수는 없기 때문에 적절한 예외 처리와 병행하는 것이 좋다.
마치며
스프링 시큐리티를 처음 사용할 때 예외 종류에 상관없이 내용이 비어있는 403 Forbidden 응답이 반환되는 것에 굉장히 의문이 들어서 상당히 많이 애를 먹었는데 역시나 이유 없는 결과는 없다고, 기본 설정이 그렇게 잡혀있어서 발생하는 문제라는 것을 알 수 있었다.
스프링 시큐리티에 대해 파고들수록 상당히 편하고 좋은 기능을 제공하는 기술이지만, 원하는대로 사용하려면 별도로 설정해야 할 것이 적지 않다는 생각이 든다.
참조 링크
'Development > Spring & Spring Boot' 카테고리의 다른 글
[Spring Boot] H2 DB Embedded Mode 사용하기 (0) | 2023.10.04 |
---|---|
[Spring Boot] 스프링이 제공하는 다양한 어노테이션을 통한 유효성 검사와 응답 처리 (0) | 2023.08.28 |
[JWT] Access Token의 한계와 Refresh Token의 필요성 (12) | 2023.07.19 |
[Spring] WebClient로 외부 API를 호출하여 받은 JSON을 객체로 역직렬화할 때 대문자 필드가 바인딩되지 않는 문제 (0) | 2023.06.29 |
[Spring Security] Spring Security 예외를 @ControllerAdvice와 @ExceptionHandler를 사용하여 전역으로 처리해보자 (4) | 2023.05.24 |
댓글