스프링 시큐리티의 예외 처리 흐름에서 궁금했던 부분이 있었다. 따라서 해당 부분을 간단하게 정리하고자 한다.
스프링 시큐리티와 JWT 인증, 인가 방식을 사용해 서버를 구성했다. 아래 시큐리티 관련 설정에서 이해가 안간 부분은 exception handler를 등록하는 과정이다. 처음 시큐리티를 다루다보니 많은 블로그 글들을 참고했는데 대부분의 블로그 글에서 아래와 같이 exception handler를 등록해줬다.
대부분의 스프링 프레임워크를 사용하는 사람들은 전역 예외 처리(Global Exception Handler)를 사용할 것이다. (사용안하는 경우도 있나?) 그럼 전역 예외 처리와 스프링 시큐리티 설정에서 등록한 예외 처리는 어떠한 차이가 있는걸까?
/**
* Security 설정
*/
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@EnableMethodSecurity(securedEnabled = true)
public class SecurityConfig {
private final CustomAuthenticationEntryPointHandler customAuthenticationEntryPointHandler;
private final CustomAccessDeniedHandler customAccessDeniedHandler;
@Bean
public SecurityFilterChain config(HttpSecurity http, HandlerMappingIntrospector introspector)
throws Exception {
MvcRequestMatcher.Builder mvc = new MvcRequestMatcher.Builder(introspector);
// white list (Spring Security 체크 제외 목록)
MvcRequestMatcher[] permitAllWhiteList = {
mvc.pattern("/auth/login"),
mvc.pattern("/auth/refresh"),
mvc.pattern("/auth/register")
};
// exception handler
http.exceptionHandling(conf -> conf
.authenticationEntryPoint(customAuthenticationEntryPointHandler)
.accessDeniedHandler(customAccessDeniedHandler)
);
// 권한 규칙 작성
http.authorizeHttpRequests(authorize -> authorize
.requestMatchers(permitAllWhiteList).permitAll()
.requestMatchers("/api/users").hasRole("ADMIN")
.anyRequest().authenticated()
);
...(생략)...
// build
return http.build();
}
}
간단하게 말하면 Security Filter의 예외 처리가 전역 예외 처리인 ControllerAdvice보다 상위 단계에 있다고 생각하면 된다. 따라서 어디서 예외가 터지고 어디서 예외를 잡을지를 생각하는게 중요할 것 같다.
인증과 인가 관련해서 예외가 발생하는 부분이 있는데(@PreAuthorize, @Secured ...) 해당 부분에서 예외가 발생할 경우 어떻게 처리되는지 정리해보자.
위 시큐리티 설정과 같이 RequestMatchers메소드를 설정해줬다. 로그인, 토큰 갱신, 회원 가입의 경우에는 인증 과정 필요없이 통과시키고 다른 모든 URL은 인증이 필요하다는 의미이다. 또한, /api/users/ URL의 경우엔 어드민 권한이 필요하다는 설정도 걸어줬다.
이때 유효하지 않은 토큰이나 헤더에 토큰이 없는 상태로 요청이 들어오면 AuthenticationEntryPoint를 구현한 핸들러를 호출해 예외를 처리하게 된다. 즉, Spring MVC계층까지 전파되지 않고 예외 처리한다. 따라서 ControllerAdvice를 거치지 않고 바로 AuthenticationEntryPoint를 구현한 CustomAuthenticationEntryPointHandler로 예외가 전달된다. 마찬가지로 /api/users/ URL로 어드민 권한이 없는 유저가 요청을 보냈다면 AccessDeniedHandler를 구현한 CustomAccessDeniedHandler로 예외가 전달된다.
/**
* 인증(Authentication)되지 않은 사용자 요청에 대한 처리
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class CustomAuthenticationEntryPointHandler implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
log.info("[CustomAuthenticationEntryPointHandler] :: {}", authException.getMessage());
log.info("[CustomAuthenticationEntryPointHandler] :: {}", request.getRequestURL());
log.info("[CustomAuthenticationEntryPointHandler] :: 토큰 정보가 만료되었거나 존재하지 않습니다.");
ErrorResponseDto errorResponseDto = new ErrorResponseDto(300,
authException.getMessage(), LocalDateTime.now());
String responseBody = objectMapper.writeValueAsString(errorResponseDto);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setCharacterEncoding("UTF-8");
response.getWriter().write(responseBody);
}
}
@PreAuthorize, @Secured를 사용하면 메서드 단위에서 인가를 제한할 수 있다. 시큐리티 설정에서 모든 URL에 대해서 관리하기보다 각각의 컨트롤러 엔드포인트에서 인가를 결정하고 싶을 경우 많이들 사용하는 방법이다. (개인적으로 이게 더 직관적이라고 생각)
이때 요청 헤더에 토큰이 없거나 유효하지 않아 SecurityContextHolder에 UsernamePasswordAuthenticationToken을 넣어주지 못한경우 AuthorizationDeniedException 예외가 발생한다. 마찬가지로 권한을 만족하지 못할 경우에도 해당 예외를 발생시킨다.
그럼 해당 예외는 시큐리티 설정에서 등록한 핸들러가 처리하는 것이 아닌 ControllerAdvice로 먼저 넘어가게 된다. 보통 전역 예외 처리(Global Exception Hanlder)를 구현하고 모든 예외에 대해서 처리하기 때문에 현재 발생한 예외는 시큐리티 예외 처리로 넘어가지 못한다. 물론 모든 예외를 처리하지 않으며 AuthorizationDeniedException에 대한 예외 처리도 진행하지 않으면 시큐리티 설정에서 등록한 예외 처리로 넘어가게 된다.
이러한 이유는 아래와 같다고 한다.

이렇게 되면 당연히 일관성을 위해 시큐리티에서 등록한 예외 처리를 사용하고 싶을 수 있다. (아니면 그냥 예외 처리 코드를 중복해서 2군데서 작성하면 되긴 함.) 추천하는 방법은 그냥 전역 예외 처리에서 re-throw 하는 방법이다.

다른 방법도 호돌맨님이 적어주심.

참고