From e99038539914f81a9e493dbb5ddf2bae68fff4fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=EA=B7=9C=ED=98=81?= <126947828+Gyuhyeok99@users.noreply.github.com> Date: Sat, 15 Feb 2025 16:40:56 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=9C=A0=ED=9A=A8=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EC=9D=80=20=ED=86=A0=ED=81=B0=EC=9C=BC=EB=A1=9C=20api?= =?UTF-8?q?=20=EC=9A=94=EC=B2=AD=20=EC=8B=9C=20500=20error=20=EB=B0=9C?= =?UTF-8?q?=EC=83=9D=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95=20(#210)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 인증/인가 실패 예외 처리 핸들러 추가 * fix: 인증/인가 실패 핸들러 적용 및 필터 순서 변경 * refactor: ExceptionHandlerFilter 인가 예외 처리 제거 --- .../security/SecurityConfiguration.java | 11 +++- .../exception/CustomAccessDeniedHandler.java | 31 +++++++++++ .../CustomAuthenticationEntryPoint.java | 31 +++++++++++ .../custom/response/ErrorResponse.java | 4 ++ .../filter/ExceptionHandlerFilter.java | 8 --- .../CustomAccessDeniedHandlerTest.java | 51 ++++++++++++++++++ .../CustomAuthenticationEntryPointTest.java | 52 +++++++++++++++++++ .../filter/ExceptionHandlerFilterTest.java | 28 ---------- 8 files changed, 178 insertions(+), 38 deletions(-) create mode 100644 src/main/java/com/example/solidconnection/custom/exception/CustomAccessDeniedHandler.java create mode 100644 src/main/java/com/example/solidconnection/custom/exception/CustomAuthenticationEntryPoint.java create mode 100644 src/test/java/com/example/solidconnection/custom/exception/CustomAccessDeniedHandlerTest.java create mode 100644 src/test/java/com/example/solidconnection/custom/exception/CustomAuthenticationEntryPointTest.java diff --git a/src/main/java/com/example/solidconnection/config/security/SecurityConfiguration.java b/src/main/java/com/example/solidconnection/config/security/SecurityConfiguration.java index 6afc199de..1d0b110bb 100644 --- a/src/main/java/com/example/solidconnection/config/security/SecurityConfiguration.java +++ b/src/main/java/com/example/solidconnection/config/security/SecurityConfiguration.java @@ -1,5 +1,7 @@ package com.example.solidconnection.config.security; +import com.example.solidconnection.custom.exception.CustomAccessDeniedHandler; +import com.example.solidconnection.custom.exception.CustomAuthenticationEntryPoint; import com.example.solidconnection.custom.security.filter.ExceptionHandlerFilter; import com.example.solidconnection.custom.security.filter.JwtAuthenticationFilter; import com.example.solidconnection.custom.security.filter.SignOutCheckFilter; @@ -13,7 +15,6 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.access.ExceptionTranslationFilter; import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; @@ -30,6 +31,8 @@ public class SecurityConfiguration { private final ExceptionHandlerFilter exceptionHandlerFilter; private final SignOutCheckFilter signOutCheckFilter; private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + private final CustomAccessDeniedHandler customAccessDeniedHandler; @Bean public CorsConfigurationSource corsConfigurationSource() { @@ -62,9 +65,13 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/admin/**").hasRole(ADMIN.name()) .anyRequest().permitAll() ) + .exceptionHandling(exception -> exception + .authenticationEntryPoint(customAuthenticationEntryPoint) + .accessDeniedHandler(customAccessDeniedHandler) + ) .addFilterBefore(jwtAuthenticationFilter, BasicAuthenticationFilter.class) .addFilterBefore(signOutCheckFilter, JwtAuthenticationFilter.class) - .addFilterAfter(exceptionHandlerFilter, ExceptionTranslationFilter.class) + .addFilterBefore(exceptionHandlerFilter, SignOutCheckFilter.class) .build(); } } diff --git a/src/main/java/com/example/solidconnection/custom/exception/CustomAccessDeniedHandler.java b/src/main/java/com/example/solidconnection/custom/exception/CustomAccessDeniedHandler.java new file mode 100644 index 000000000..52b1725fc --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/exception/CustomAccessDeniedHandler.java @@ -0,0 +1,31 @@ +package com.example.solidconnection.custom.exception; + +import com.example.solidconnection.custom.response.ErrorResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle(HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException { + ErrorResponse errorResponse = + new ErrorResponse(ErrorCode.ACCESS_DENIED); + response.setStatus(ErrorCode.ACCESS_DENIED.getCode()); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/src/main/java/com/example/solidconnection/custom/exception/CustomAuthenticationEntryPoint.java b/src/main/java/com/example/solidconnection/custom/exception/CustomAuthenticationEntryPoint.java new file mode 100644 index 000000000..20f0786b7 --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/exception/CustomAuthenticationEntryPoint.java @@ -0,0 +1,31 @@ +package com.example.solidconnection.custom.exception; + +import com.example.solidconnection.custom.response.ErrorResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) throws IOException { + ErrorResponse errorResponse = + new ErrorResponse(ErrorCode.AUTHENTICATION_FAILED); + response.setStatus(ErrorCode.AUTHENTICATION_FAILED.getCode()); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/src/main/java/com/example/solidconnection/custom/response/ErrorResponse.java b/src/main/java/com/example/solidconnection/custom/response/ErrorResponse.java index 22c173f1d..83cc02622 100644 --- a/src/main/java/com/example/solidconnection/custom/response/ErrorResponse.java +++ b/src/main/java/com/example/solidconnection/custom/response/ErrorResponse.java @@ -9,6 +9,10 @@ public ErrorResponse(CustomException e) { this(e.getMessage()); } + public ErrorResponse(ErrorCode e) { + this(e.getMessage()); + } + public ErrorResponse(ErrorCode e, String detail) { this(e.getMessage() + " : " + detail); } diff --git a/src/main/java/com/example/solidconnection/custom/security/filter/ExceptionHandlerFilter.java b/src/main/java/com/example/solidconnection/custom/security/filter/ExceptionHandlerFilter.java index 1b8fac2bb..2db133b8f 100644 --- a/src/main/java/com/example/solidconnection/custom/security/filter/ExceptionHandlerFilter.java +++ b/src/main/java/com/example/solidconnection/custom/security/filter/ExceptionHandlerFilter.java @@ -10,16 +10,12 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.NonNull; import lombok.RequiredArgsConstructor; -import org.springframework.security.access.AccessDeniedException; -import org.springframework.security.authentication.AnonymousAuthenticationToken; -import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; -import static com.example.solidconnection.custom.exception.ErrorCode.ACCESS_DENIED; import static com.example.solidconnection.custom.exception.ErrorCode.AUTHENTICATION_FAILED; @Component @@ -36,10 +32,6 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, filterChain.doFilter(request, response); } catch (CustomException e) { customCommence(response, e); - } catch (AccessDeniedException e) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - ErrorCode errorCode = auth instanceof AnonymousAuthenticationToken ? AUTHENTICATION_FAILED : ACCESS_DENIED; - generalCommence(response, e, errorCode); } catch (Exception e) { generalCommence(response, e, AUTHENTICATION_FAILED); } diff --git a/src/test/java/com/example/solidconnection/custom/exception/CustomAccessDeniedHandlerTest.java b/src/test/java/com/example/solidconnection/custom/exception/CustomAccessDeniedHandlerTest.java new file mode 100644 index 000000000..7e4cae5b2 --- /dev/null +++ b/src/test/java/com/example/solidconnection/custom/exception/CustomAccessDeniedHandlerTest.java @@ -0,0 +1,51 @@ +package com.example.solidconnection.custom.exception; + +import com.example.solidconnection.custom.response.ErrorResponse; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.access.AccessDeniedException; + +import java.io.IOException; + +import static com.example.solidconnection.custom.exception.ErrorCode.ACCESS_DENIED; +import static org.assertj.core.api.Assertions.assertThat; + +@TestContainerSpringBootTest +@DisplayName("커스텀 인가 예외 처리 테스트") +class CustomAccessDeniedHandlerTest { + + @Autowired + private CustomAccessDeniedHandler accessDeniedHandler; + + @Autowired + private ObjectMapper objectMapper; + + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @BeforeEach + void setUp() { + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + } + + @Test + void 권한이_없는_사용자_접근시_403_예외_응답을_반환한다() throws IOException { + // given + AccessDeniedException accessDeniedException = new AccessDeniedException(ACCESS_DENIED.getMessage()); + + // when + accessDeniedHandler.handle(request, response, accessDeniedException); + + // then + ErrorResponse errorResponse = objectMapper.readValue(response.getContentAsString(), ErrorResponse.class); + assertThat(response.getStatus()).isEqualTo(ACCESS_DENIED.getCode()); + assertThat(errorResponse.message()).isEqualTo(ACCESS_DENIED.getMessage()); + } +} diff --git a/src/test/java/com/example/solidconnection/custom/exception/CustomAuthenticationEntryPointTest.java b/src/test/java/com/example/solidconnection/custom/exception/CustomAuthenticationEntryPointTest.java new file mode 100644 index 000000000..2cef64481 --- /dev/null +++ b/src/test/java/com/example/solidconnection/custom/exception/CustomAuthenticationEntryPointTest.java @@ -0,0 +1,52 @@ +package com.example.solidconnection.custom.exception; + +import com.example.solidconnection.custom.response.ErrorResponse; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.core.AuthenticationException; + +import java.io.IOException; + +import static com.example.solidconnection.custom.exception.ErrorCode.AUTHENTICATION_FAILED; +import static org.assertj.core.api.Assertions.assertThat; + +@TestContainerSpringBootTest +@DisplayName("커스텀 인증 예외 처리 테스트") +class CustomAuthenticationEntryPointTest { + + @Autowired + private CustomAuthenticationEntryPoint authenticationEntryPoint; + + @Autowired + private ObjectMapper objectMapper; + + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @BeforeEach + void setUp() { + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + } + + @Test + void 인증되지_않은_사용자_접근시_401_예외_응답을_반환한다() throws IOException { + // given + AuthenticationException authException = new AuthenticationServiceException(AUTHENTICATION_FAILED.getMessage()); + + // when + authenticationEntryPoint.commence(request, response, authException); + + // then + ErrorResponse errorResponse = objectMapper.readValue(response.getContentAsString(), ErrorResponse.class); + assertThat(response.getStatus()).isEqualTo(AUTHENTICATION_FAILED.getCode()); + assertThat(errorResponse.message()).isEqualTo(AUTHENTICATION_FAILED.getMessage()); + } +} diff --git a/src/test/java/com/example/solidconnection/custom/security/filter/ExceptionHandlerFilterTest.java b/src/test/java/com/example/solidconnection/custom/security/filter/ExceptionHandlerFilterTest.java index fd4bd62a8..f1b3c7359 100644 --- a/src/test/java/com/example/solidconnection/custom/security/filter/ExceptionHandlerFilterTest.java +++ b/src/test/java/com/example/solidconnection/custom/security/filter/ExceptionHandlerFilterTest.java @@ -85,34 +85,6 @@ void setUp() { assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED); } - @Test - void 익명_사용자의_접근_거부시_401_예외_응답을_반환한다() throws Exception { - // given - Authentication anonymousAuth = getAnonymousAuth(); - SecurityContextHolder.getContext().setAuthentication(anonymousAuth); - willThrow(new AccessDeniedException("Access Denied")).given(filterChain).doFilter(request, response); - - // when - exceptionHandlerFilter.doFilterInternal(request, response, filterChain); - - // then - assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED); - } - - @Test - void 인증된_사용자의_접근_거부하면_403_예외_응답을_반환한다() throws Exception { - // given - Authentication auth = new TestingAuthenticationToken("user", "password", "ROLE_USER"); - SecurityContextHolder.getContext().setAuthentication(auth); - willThrow(new AccessDeniedException("Access Denied")).given(filterChain).doFilter(request, response); - - // when - exceptionHandlerFilter.doFilterInternal(request, response, filterChain); - - // then - assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_FORBIDDEN); - } - private static Stream provideException() { return Stream.of( new RuntimeException(),