Written By: Hoàng Mạnh Cường (Java 10)
Gmail: manhcuong200997@gmail.com
Bài viết gốc: https://www.baeldung.com/spring-security-exceptionhandler

Lưu ý: Trong bài viết gốc có một số cụm từ khi dịch sang Tiếng Việt có thể không được sát nghĩa hoặc không truyền tải hết được thông điệp của tác giả nên mình sẽ chú thích kèm trong cặp dấu “(…)”.

1. Tổng quan

Trong bài viết này, chúng ta sẽ cùng nhau tìm hiểu làm thế nào để xử lý toàn cục Spring Security exceptions với @ExceptionHandler@ControllerAdvice. Controller advice là phần ngăn cách ở giữa cho phép chúng ta sử dụng cùng một cách xử lý exception trên ứng dụng.

2. Spring Security Exceptions

Các exception cốt lõi của Spring Security như là AuthenticationExceptionAccessDeniedException là các runtime exception. Vì những exception này được ném ra bởi những bộ lọc xác thực đằng sau DispatcherServlet và trước khi gọi các phương thức của controller, @ControllerAdvice không thể bắt lấy những exception này.

Spring security exceptions có thể được xử lý trực tiếp bằng cách thêm các bộ lọc tùy chỉnh và xây dựng nội dung phản hồi. Để xử lý toàn cục các ngoại lệ này thông qua @ExceptionHandler@ControllerAdvice, chúng ta cần triển khai AuthenticationEntryPoint tùy chỉnh. AuthenticationEntryPoint được sử dụng để gửi phản hồi HTTP yêu cầu thông tin xác thực từ phía client. Mặc dù có nhiều triển khai tích hợp cho việc thông qua bảo mật, chúng ta cần viết cách triển khai tùy chỉnh để gửi một thông báo phản hồi tùy chỉnh.

Đầu tiên, hãy xem cách xử lý các security exception mà không sử dụng @ExceptionHandler

3. Không sử dụng @ExceptionHandler

Spring security exceptions được bắt đầu tại AuthenticationEntryPoint. Hãy viết triển khai AuthenticationEntryPoint để ngăn chặn security exceptions.

3.1. Cấu hình AuthenticationEntryPoint

Hãy triển khai AuthenticationEntryPoint và ghi đè phương thức commence():

@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();
    }
}

Ở đây, chúng ta sẽ sử dụng ObjectMapper làm công cụ chuyển đổi thông báo cho nội dung phản hồi.

3.2. Cấu hình SecurityConfig

Tiếp theo, cấu hình SecurityConfig để ngăn các đường dẫn xác thực. Ở đây chúng ta sẽ cấu hình “/login” là đường dẫn cho việc triển khai ở trên. Ngoài ra, chúng ta sẽ cấu hình người dùng “admin” với vai trò là “ADMIN”:

@Configuration
@EnableWebSecurity
public class CustomSecurityConfig extends WebSecurityConfigurerAdapter {

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

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.requestMatchers()
          .antMatchers("/login")
          .and()
          .authorizeRequests()
          .anyRequest()
          .hasRole("ADMIN")
          .and()
          .httpBasic()
          .and()
          .exceptionHandling()
          .authenticationEntryPoint(authEntryPoint);
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
          .withUser("admin")
          .password("password")
          .roles("ADMIN");
    }
}

3.3. Cấu hình Rest Controller

Bây giờ, chúng ta sẽ viết Rest Controller để lắng nghe yêu cầu từ “/login”:

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

3.4. Kiểm tra

Cuối cùng, hãy kiểm tra với mock tests.

Đầu tiên, hãy viết trường hợp test để xác thực thành công:

@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());
}

Tiếp theo, hãy xem trường hợp xác thực thất bại:

@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())));
}

4. Với @ExceptionHandler

Cách này cho phép chúng ta sử dụng chính xác những kỹ thuật xử lý exception giống nhau nhưng gọn gàng và tốt hơn trong controller advice với các phương thức được chú thích @ExceptionHandler.

4.1. Cấu hình AuthenticationEntryPoint

Tương tự như cách trên, chúng ta sẽ triển khai AuthenticationEntryPoint và sau đó ủy quyền xử lý exception cho 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);
    }
}

Ở đây, chúng ta đã inject DefaultHandlerExceptionResolver và ủy quyền trình xử lý cho nó. Security exception này bây giờ có thể được xử lý bởi controller advice với một phương thức xử lý exception.

4.2. Cấu hình ExceptionHandler

Bây giờ, đối với cấu hình chính của trình xử lý ngoại lệ (exception handler), chúng ta sẽ kế thừa ResponseEntityExceptionHandler và chú thích class này bằng @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. Cấu hình SecurityConfig

Bây giờ, hãy viết cấu hình bảo mật cho đường dẫn xác thực (authentication entry point) được ủy quyền này:

@Configuration
@EnableWebSecurity
public class DelegatedSecurityConfig extends WebSecurityConfigurerAdapter {

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

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.requestMatchers()
          .antMatchers("/login-handler")
          .and()
          .authorizeRequests()
          .anyRequest()
          .hasRole("ADMIN")
          .and()
          .httpBasic()
          .and()
          .exceptionHandling()
          .authenticationEntryPoint(authEntryPoint);
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
          .withUser("admin")
          .password("password")
          .roles("ADMIN");
    }
}

Với “/login-handler”, chúng ta đã cấu hình trình xử lý ngoại lệ với DelegatedAuthenticationEntryPoint triển khai ở trên.

4.4. Cấu hình Rest Controller

Hãy cấu hình rest controller cho /login-handler":

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

4.5. Kiểm thử

Bây giờ hãy tiến hành test điểm cuối cùng:

@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())));
}

Trong lần kiểm tra thành công, chúng ta đã test điểm cuối (endpoint) với username và password được cấu hình trước. Trong lần kiểm tra thất bại, chúng ta đã xác định phản hồi cho mã trạng thái và thông báo lỗi trong nội dung phản hồi.

5. Kết luận

Trong bài viết này, chúng ta đã tìm hiểu về cách xử lý toàn cục các ngoại lệ của Spring Security với @ExceptionHandler. Thêm vào đó, chúng ta đã tạo ra ví dụ với đầy đủ chức năng giúp chúng ta hiểu được các khai niệm được giải thích.

Bài viết của mình tới đây là kết thúc. Hi vọng những thông tin trong bài viết này có thể hỗ trợ tốt cho các bạn trong công việc hiện tại và trong tương lai.

Thanks for watching!