1. 개요
이 예제에서는 @ExceptionHandler 및 @ControllerAdvice 를 사용하여 Spring Security 예외를 전역적으로 처리하는 방법을 배웁니다. 컨트롤러 어드바이스 는 애플리케이션 전체에서 동일한 예외 처리를 사용할 수 있도록 하는 인터셉터입니다 .
2. 스프링 Security 예외
AuthenticationException 및 AccessDeniedException 과 같은 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에서 사용할 수 있습니다 .