1. 개요

이 예제에서는 @ExceptionHandler@ControllerAdvice 를 사용하여 Spring Security 예외를 전역적으로 처리하는 방법을 배웁니다. 컨트롤러 어드바이스애플리케이션 전체에서 동일한 예외 처리를 사용할 수 있도록 하는 인터셉터입니다 .

2. 스프링 Security 예외

AuthenticationExceptionAccessDeniedException 과 같은 Spring Security 핵심 예외 는 런타임 예외입니다. 이러한 예외는 DispatcherServlet 뒤의 인증 필터에 의해 발생 하고 컨트롤러 메소드를 호출하기 전에 @ControllerAdvice 이러한 예외를 포착할 수 없습니다.

Spring Security 예외 는 사용자 정의 필터를 추가하고 Response body을 구성하여 직접 처리할 수 있습니다. @ExceptionHandler@ControllerAdvice 를 통해 전역 수준에서 이러한 예외를 처리하려면 AuthenticationEntryPoint 의 사용자 정의 구현이 필요합니다 . AuthenticationEntryPoint 는 클라이언트로부터 자격 증명을 요청하는 HTTP 응답을 보내는 데 사용됩니다 . Security 진입점에 대한 여러 내장 구현이 있지만 사용자 지정 응답 메시지를 보내기 위한 사용자 지정 구현을 작성해야 합니다.

먼저 @ExceptionHandler 를 사용하지 않고 전역적으로 Security 예외를 처리하는 방법을 살펴 보겠습니다 .

3. @ExceptionHandler 없이

Spring Security 예외는 AuthenticationEntryPoint 에서 시작됩니다 . Security 예외를 가로채는 AuthenticationEntryPoint 에 대한 구현을 작성해 보겠습니다 .

3.1. AuthenticationEntryPoint 구성

AuthenticationEntryPoint 를 구현하고 begin() 메서드 를 재정의 해 보겠습니다.

@Component("customAuthenticationEntryPoint")
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) 
      throws IOException, ServletException {

        RestError re = new RestError(HttpStatus.UNAUTHORIZED.toString(), "Authentication failed");
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        OutputStream responseStream = response.getOutputStream();
        ObjectMapper mapper = new ObjectMapper();
        mapper.writeValue(responseStream, re);
        responseStream.flush();
    }
}

여기에서는 Response body에 대한 메시지 변환기로 ObjectMapper 를 사용했습니다.

3.2. SecurityConfig 구성

다음 으로 인증 경로를 가로채 도록 SecurityConfig 를 구성해 보겠습니다. 여기서 우리는 위의 구현을 위한 경로로 ' /login '을 구성할 것입니다. 또한 'ADMIN' 역할로 'admin' 사용자를 구성합니다.

@Configuration
@EnableWebSecurity
public class CustomSecurityConfig {

    @Autowired
    @Qualifier("customAuthenticationEntryPoint")
    AuthenticationEntryPoint authEntryPoint;

    @Bean
    public UserDetailsService userDetailsService() {
        UserDetails admin = User.withUsername("admin")
            .password("password")
            .roles("ADMIN")
            .build();
        InMemoryUserDetailsManager userDetailsManager = new InMemoryUserDetailsManager();
        userDetailsManager.createUser(admin);
        return userDetailsManager;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.requestMatchers()
            .antMatchers("/login")
            .and()
            .authorizeRequests()
            .anyRequest()
            .hasRole("ADMIN")
            .and()
            .httpBasic()
            .and()
            .exceptionHandling()
            .authenticationEntryPoint(authEntryPoint);
        return http.build();
    }
}

3.3. 나머지 컨트롤러 구성

이제 이 엔드포인트 '/login'을 수신하는 나머지 컨트롤러를 작성해 보겠습니다.

@PostMapping(value = "/login", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<RestResponse> login() {
    return ResponseEntity.ok(new RestResponse("Success"));
}

3.4. 테스트

마지막으로 이 끝점을 모의 테스트로 테스트해 보겠습니다.

먼저 성공적인 인증을 위한 테스트 케이스를 작성해 보겠습니다.

@Test
@WithMockUser(username = "admin", roles = { "ADMIN" })
public void whenUserAccessLogin_shouldSucceed() throws Exception {
    mvc.perform(formLogin("/login").user("username", "admin")
      .password("password", "password")
      .acceptMediaType(MediaType.APPLICATION_JSON))
      .andExpect(status().isOk());
}

다음으로 인증 실패 시나리오를 살펴보겠습니다.

@Test
public void whenUserAccessWithWrongCredentialsWithDelegatedEntryPoint_shouldFail() throws Exception {
    RestError re = new RestError(HttpStatus.UNAUTHORIZED.toString(), "Authentication failed");
    mvc.perform(formLogin("/login").user("username", "admin")
      .password("password", "wrong")
      .acceptMediaType(MediaType.APPLICATION_JSON))
      .andExpect(status().isUnauthorized())
      .andExpect(jsonPath("$.errorMessage", is(re.getErrorMessage())));
}

이제 @ControllerAdvice@ExceptionHandler 를 사용하여 동일한 결과를 얻을 수 있는 방법을 살펴 보겠습니다 .

4. @ExceptionHandler 사용

이 접근 방식을 사용하면 정확히 동일한 예외 처리 기술을 사용할 수 있지만 @ExceptionHandler 어노테이션이 달린 메서드를 사용하여 컨트롤러 어드바이스에서 더 깨끗하고 훨씬 더 나은 방식으로 사용할 수 있습니다.

4.1. AuthenticationEntryPoint 구성

위의 접근 방식과 유사하게 AuthenticationEntryPoint 를 구현한 다음 예외 처리기를 HandlerExceptionResolver 에 위임합니다 .

@Component("delegatedAuthenticationEntryPoint")
public class DelegatedAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Autowired
    @Qualifier("handlerExceptionResolver")
    private HandlerExceptionResolver resolver;

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) 
      throws IOException, ServletException {
        resolver.resolveException(request, response, null, authException);
    }
}

여기에서 DefaultHandlerExceptionResolver 를 주입하고 핸들러를 이 해석기에 위임했습니다. 이 Security 예외는 이제 예외 처리기 메서드를 사용하여 컨트롤러 조언으로 처리할 수 있습니다.

4.2. ExceptionHandler 구성

이제 예외 처리기 의 기본 구성에 대해 ResponseEntityExceptionHandler 를 확장하고 이 클래스에 @ControllerAdvice 어노테이션을 추가합니다 .

@ControllerAdvice
public class DefaultExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler({ AuthenticationException.class })
    @ResponseBody
    public ResponseEntity<RestError> handleAuthenticationException(Exception ex) {

        RestError re = new RestError(HttpStatus.UNAUTHORIZED.toString(), 
          "Authentication failed at controller advice");
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(re);
    }
}

4.3. SecurityConfig 구성

이제 이 위임된 인증 진입점에 대한 Security 구성을 작성해 보겠습니다.

@Configuration
@EnableWebSecurity
public class DelegatedSecurityConfig {

    @Autowired
    @Qualifier("delegatedAuthenticationEntryPoint")
    AuthenticationEntryPoint authEntryPoint;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.requestMatchers()
            .antMatchers("/login-handler")
            .and()
            .authorizeRequests()
            .anyRequest()
            .hasRole("ADMIN")
            .and()
            .httpBasic()
            .and()
            .exceptionHandling()
            .authenticationEntryPoint(authEntryPoint);
        return http.build();
    }

    @Bean
    public InMemoryUserDetailsManager userDetailsService() {
        UserDetails admin = User.withUsername("admin")
            .password("password")
            .roles("ADMIN")
            .build();
        return new InMemoryUserDetailsManager(admin);
    }
}

' /login-handler ' 엔드포인트의 경우 위에서 구현한 DelegatedAuthenticationEntryPoint 를 사용하여 예외 처리기를 구성했습니다 .

4.4. 나머지 컨트롤러 구성

' /login-handler ' 엔드포인트 에 대한 나머지 컨트롤러를 구성해 보겠습니다 .

@PostMapping(value = "/login-handler", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<RestResponse> loginWithExceptionHandler() {
    return ResponseEntity.ok(new RestResponse("Success"));
}

4.5. 테스트

이제 이 끝점을 테스트해 보겠습니다.

@Test
@WithMockUser(username = "admin", roles = { "ADMIN" })
public void whenUserAccessLogin_shouldSucceed() throws Exception {
    mvc.perform(formLogin("/login-handler").user("username", "admin")
      .password("password", "password")
      .acceptMediaType(MediaType.APPLICATION_JSON))
      .andExpect(status().isOk());
}

@Test
public void whenUserAccessWithWrongCredentialsWithDelegatedEntryPoint_shouldFail() throws Exception {
    RestError re = new RestError(HttpStatus.UNAUTHORIZED.toString(), "Authentication failed at controller advice");
    mvc.perform(formLogin("/login-handler").user("username", "admin")
      .password("password", "wrong")
      .acceptMediaType(MediaType.APPLICATION_JSON))
      .andExpect(status().isUnauthorized())
      .andExpect(jsonPath("$.errorMessage", is(re.getErrorMessage())));
}

성공 테스트에서는 미리 구성된 사용자 이름과 암호로 끝점을 테스트했습니다. 실패 테스트에서 Response body의 상태 코드 및 오류 메시지에 대한 응답을 검증했습니다.

5. 결론

이 기사에서는 @ExceptionHandler 를 사용하여 Spring Security 예외 를 전역적으로 처리하는 방법을 배웠습니다 . 또한 설명된 개념을 이해하는 데 도움이 되는 완전한 기능의 예제를 만들었습니다.

기사의 전체 소스 코드는  GitHub에서 사용할 수 있습니다 .

Security footer banner