+4

Part 2: Chi tiết về các Filter trong Spring Boot và ví dụ dùng trong JWT authentication như thế nào

Chào Mọi người,

Tiếp nối từ Part 1, Các loại Filter trong Spring Boot?

Trong bài viết này, mình sẽ giải thích tại sao nên sử dụng OncePerRequestFilter để triển khai xác thực JWT, thay vì dùng một lớp implement Filter thông thường hoặc kế thừa từ GenericFilterBean.

Lý do chính nằm ở chỗ OncePerRequestFilter được thiết kế đặc biệt cho các tác vụ xử lý một lần duy nhất trong mỗi yêu cầu HTTP, đảm bảo không bị xử lý lặp lại. Đây là một điểm mấu chốt để hiểu tại sao OncePerRequestFilter là lựa chọn tối ưu khi triển khai các cơ chế xác thực JWT.


1. Tại sao không dùng Filter?

  • Filter là interface gốc trong Java Servlet API, cung cấp các phương thức cơ bản như init, doFilter, và destroy. Tuy nhiên, nếu tự triển khai Filter, bạn cần tự quản lý toàn bộ luồng xử lý, bao gồm cả logic để tránh việc xử lý lặp lại trong cùng request.
  • Nhược điểm:
    • Dễ dẫn đến lỗi khi filter xử lý lặp lại trong các trường hợp như forward hoặc error dispatch.
    • Code phức tạp hơn do bạn phải tự viết các logic kiểm tra và quản lý.

Ví dụ:

@Component
public class JwtFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        // Kiểm tra và xử lý JWT
        // Lặp lại không kiểm soát khi có forward
        chain.doFilter(request, response);
    }
}

Demo trường hợp Filter có thể chạy 2 lần

Ví dụ mình có một cái StandardFilter được implement như sau:

@Component
public class StandardFilter implements Filter {

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        System.err.println("StandardFilter.init");
        Filter.super.init(filterConfig);
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        System.err.println("StandardFilter.doFilter");
        chain.doFilter(request, response);
    }

    @Override
    public void destroy() {
        System.err.println("StandardFilter.destroy");
        Filter.super.destroy();
    }
}

Đây là DemoController của mình trong hàm handleRequest mình có forward tới api /demo/forward anh em có thể triển khai tương tự để thấy dòng err StandardFilter.doFilter sẽ được in ra 2 lần khi mình gọi /demo/request.

@RestController
@RequestMapping("/demo")
public class DemoController {

    @GetMapping("/request")
    public void handleRequest(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        request.getRequestDispatcher("/demo/forward").forward(request, response);
    }

    @GetMapping("/forward")
    public String handleForward() {
        return "Forwarded Response";
    }
}

Vậy thì nếu tui cố chấp dùng Filter có được không, câu trả lời là vẫn được, sau đây là code demo sử dụng Filter để xác thực JWT cho request:

Demo JWT Authentication dùng Filter

Class JWTAuthFilter được cài đặt như sau:

@Component
public class JWTAuthFilter implements Filter {

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        System.out.println("JWTAuthFilter initialized");
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {

        if (request instanceof HttpServletRequest httpRequest) {
            System.err.println("Filter executed for: " + httpRequest.getRequestURI() + " " + httpRequest.getMethod());
            System.err.println("DispatcherType: " + httpRequest.getDispatcherType());
        }
        
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;

        System.err.println("JWTAuthFilter invoked");

        // Extract Authorization header
        String authHeader = httpRequest.getHeader("Authorization");
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            httpResponse.getWriter().write("Unauthorized");
            return;
        }

        String token = authHeader.substring(7); // Remove "Bearer " prefix
        try {
            // Validate the JWT token (pseudo-code, replace with your logic)
            String username = validateAndExtractUsername(token);

            // Set authentication in the SecurityContext if valid
            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

            System.out.println("Authentication: " + (authentication != null ? authentication.getName() : "null"));

            SecurityContextHolder.getContext().setAuthentication(
                    new UsernamePasswordAuthenticationToken(username, null, List.of())
            );


        } catch (Exception e) {
            httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            httpResponse.getWriter().write("Invalid Token");
            return;
        }
        // Continue to the next filter
        chain.doFilter(request, response);
    }

    @Override
    public void destroy() {
        System.out.println("JWTAuthFilter destroyed");
    }

    private String validateAndExtractUsername(String token) throws Exception {
        // Pseudo-logic for token validation
        if ("validToken".equals(token)) {
            return "user123"; // Extract username from the token
        }
        throw new Exception("Invalid Token");
    }
}

Class WebSecurityConfig được cài đặt như sau:

@Configuration
public class WebSecurityConfig {
    @Autowired
    JWTAuthFilter jwtAuthFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests((authorizeRequests) -> {
            authorizeRequests.requestMatchers("/api/**").authenticated().anyRequest().permitAll();

        });
        http.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
        http.exceptionHandling(exception -> exception.authenticationEntryPoint((request, response, authException) -> {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("Custom 401: Authentication required!");
        }).accessDeniedHandler((request, response, accessDeniedException) -> {
            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
            response.getWriter().write("Custom 403: Forbidden access!");
        }));
        return http.build();
    }
}

Anh em để ý System.err.println("JWTAuthFilter invoked"); nó sẽ output 2 lần:

Authentication: null

2024-12-01T21:38:36.424+07:00  WARN 33854 --- [nio-8080-exec-1] o.s.w.s.h.HandlerMappingIntrospector     : Cache miss for REQUEST dispatch to '/api/users' (previous null). Performing MatchableHandlerMapping lookup. This is logged once only at WARN level, and every time at TRACE.
2024-12-01T21:38:36.425+07:00 DEBUG 33854 --- [nio-8080-exec-1] o.s.security.web.FilterChainProxy        : Secured GET 
/api/users

Authentication: user123

Filter executed for: /api/users GET
DispatcherType: REQUEST
JWTAuthFilter invoked

Filter executed for: /api/users GET
DispatcherType: REQUEST
JWTAuthFilter invoked

Tóm tắt lý do filter được gọi lại

Code trên, bạn sẽ thấy thông tin Authentication trước và sau khi thiết lập nó trong SecurityContextHolder. Điều này sẽ giúp bạn hiểu rõ hơn về sự thay đổi context và lý do tại sao filter được gọi lại.

  1. Lần đầu tiên, SecurityContextHolder.getContext().getAuthentication() trả về null, vì chưa có authentication.
  2. Sau khi bạn thiết lập authentication vào SecurityContextHolder, Spring Security sẽ đánh giá lại security context và có thể kích hoạt lại các filter trong filter chain.
  3. Điều này có thể gây ra việc filter được gọi lại và SecurityContextHolder.getContext().getAuthentication() lúc này có giá trị xác thực (vì bạn đã thiết lập UsernamePasswordAuthenticationToken).

Kết luận:

Anh em cố đấm ăn xôi thì cũng dùng Filter để implement JWT cũng được nhưng không ai khuyến khích điều đó vì anh em phải handle cả đóng thứ, đầu tiên 3 thằng init(), doFilter(), destroy() chưa kể anh em xử lý các case mà Filter chỉ chạy một lần nữa 🥲


2. Tại sao không dùng GenericFilterBean?

  • GenericFilterBean là một abstract class được Spring cung cấp để đơn giản hóa việc triển khai Filter. Nó giúp bạn bỏ qua việc tự triển khai các phương thức initdestroy.
  • Tuy nhiên, GenericFilterBean không cung cấp cơ chế đảm bảo filter chỉ được thực thi một lần trong mỗi request. Nếu bạn cần xử lý JWT một lần duy nhất trong toàn bộ lifecycle của request, bạn phải tự viết thêm logic kiểm tra, tương tự như khi dùng Filter.

Demo dùng GenericFilterBean

Nó cũng y chang thằng kia thôi, được cái chỉ cần implement doFilter() 2 thằng kia thì thằng GenericFilterBean nó implement cho mình rồi.

@Component
public class JWTAuthFilter extends GenericFilterBean {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {

        if (request instanceof HttpServletRequest httpRequest) {
            System.err.println("Filter executed for: " + httpRequest.getRequestURI() + " " + httpRequest.getMethod());
            System.err.println("DispatcherType: " + httpRequest.getDispatcherType());
        }

        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;

        System.err.println("JWTAuthFilter invoked");

        // Extract Authorization header
        String authHeader = httpRequest.getHeader("Authorization");
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            httpResponse.getWriter().write("Unauthorized");
            return;
        }

        String token = authHeader.substring(7); // Remove "Bearer " prefix
        try {
            // Validate the JWT token (pseudo-code, replace with your logic)
            String username = validateAndExtractUsername(token);

            // Set authentication in the SecurityContext if valid
            SecurityContextHolder.getContext().setAuthentication(
                    new UsernamePasswordAuthenticationToken(username, null, List.of())
            );
        } catch (Exception e) {
            httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            httpResponse.getWriter().write("Invalid Token");
            return;
        }
        // Continue to the next filter
        chain.doFilter(request, response);
    }

    private String validateAndExtractUsername(String token) throws Exception {
        // Pseudo-logic for token validation
        if ("validToken".equals(token)) {
            return "user123"; // Extract username from the token
        }
        throw new Exception("Invalid Token");
    }
}

Kết luận

Nói chung thì nó cũng không đảm bảo Filter chạy 1 lần, để debug cũng bù đầu mốc mắt ấy, bạn nào có hứng thú thì cứ comment, mình rãnh mình đi sâu vào phần này 🤪🤪

3. Tại sao nên dùng OncePerRequestFilter?

Cái này mục tiêu chính của bài nên đi vô cái này thôi nè, còn thằng GenericFilterBean do mình làm biếng viết tiếp ấy, nào rãnh đào sâu vào nó tiếp

a. Xử lý một lần duy nhất cho mỗi request

  • OncePerRequestFilter đảm bảo logic của bạn chỉ được thực thi một lần duy nhất trong lifecycle của request, ngay cả khi request đó như forward, include, hay error dispatch.
  • Điều này đặc biệt quan trọng trong việc xác thực JWT, vì bạn không muốn kiểm tra token lại khi request được forward hoặc xử lý lỗi.

Cách hoạt động:

  • OncePerRequestFilter sử dụng một cơ chế gắn cờ (flag) bằng cách thêm một thuộc tính vào HttpServletRequest để đánh dấu rằng filter đã được thực thi.
  • Nếu cờ này đã được gắn, filter sẽ bỏ qua và chuyển tiếp đến bước tiếp theo trong chuỗi xử lý.

b. Cung cấp phương thức doFilterInternal

  • OncePerRequestFilter yêu cầu anh em override phương thức doFilterInternal(), còn lại thì OncePerRequestFilter nó làm hết cho mình rồi, anh em thích vọc vạch thì nhảy vô code ở hàm doFilter() của nó xem cách nó handle để đảm bảo filter chỉ chạy 1 lần như thế nào, như nói ở trên thì nó có 1 cái flag đánh dấu request thôi.
  • AE có thể coi lại hàm doFilter() trong mục số 3. OncePerRequestFilter phần 1 nha.

Demo JWT dùng OncePerRequestFilter

Đây là cài đặt class JWTAuthorizationFilter anh em thấy mình extends từ OncePerRequestFilter thì mình chỉ cần override doFilterInternal() là được.

@Component
public class JWTAuthorizationFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try {
            String header = request.getHeader("Authorization");
            if (header == null || !header.startsWith("Bearer ")) {
                System.err.println("No jwt fond in request header");
                return;
            }

            String token = header.substring(7);
            String email = JWTProvider.validateAndParseToken(token);
            if (email.isEmpty()) {
                System.err.println("Invalid token");
                return;
            }

            CustomAuthentication authentication = new CustomAuthentication(email, true);

            SecurityContextHolder.getContext().setAuthentication(authentication);

        } catch (Exception ex) {
            System.err.println(ex);
        } finally {
            filterChain.doFilter(request, response);
        }
    }
}

Anh em để ý dòng này CustomAuthentication authentication = new CustomAuthentication(email, true);

Tui có custom một cái Authentication không dùng default như code mẫu ở trên (đơn giản lần này tui không thích thôi, lý do phức tạp thì bài viết khác 😂)

Chỗ SecurityContextHolder.getContext().setAuthentication(authentication); nó nhận vào một thằng Authentication thì anh em cứ set cái gì mà nó implements cái interface Authentication là được:

Vậy nên anh em không muốn dùng thằng UsernamePasswordAuthenticationToken thì anh anh cứ custom lại một cái CustomAuthentication của anh em là được nó chỉ cần implements thằng Authentication là được (này rãnh thì chắc cũng lên bài tiếp 😄)

Đây cho vô cái hình minh họa của thằng SecuirtyContextHolder cho nó bắt mắt:

image.png

Thì đây là code cài đặt CustomAuthentication:

public class CustomAuthentication implements Authentication {
    private final String principal; // User identity, e.g., username or ID
    private boolean authenticated;

    public CustomAuthentication(String principal, boolean authenticated) {
        this.principal = principal;
        this.authenticated = authenticated;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null; // Return authorities/roles if needed
    }

    @Override
    public Object getCredentials() {
        return null; // Return credentials if applicable
    }

    @Override
    public Object getDetails() {
        return null; // Return additional details if applicable
    }

    @Override
    public Object getPrincipal() {
        return principal; // Return the principal
    }

    @Override
    public boolean isAuthenticated() {
        return authenticated;
    }

    @Override
    public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
        this.authenticated = isAuthenticated;
    }

    @Override
    public String getName() {
        return principal; // Return a name or identifier for the principal
    }
}

Rồi thì trên đây là code mình cài đặt JWT dùng OncePerRequestFilter vậy đó, anh em thử cài đặt xem có chạy được hông, hông được thì tự debug đi 😂 😂, tui đoán là lần chắc trật vuột mấy lần ấy chứ (Có khi anh em chửi thầm móa thằng này đưa code gì cóp dán vô méo chạy 😆)

Vậy khi nào không dùng OncePerRequestFilter?

  • Khi không cần đảm bảo xử lý một lần duy nhất cho mỗi request (ví dụ: xử lý cache hoặc thêm header cho từng forward).
  • Khi không làm việc với Spring Security hoặc không cần xác thực JWT.

Kết luận

  • Dùng OncePerRequestFilter trong Spring Boot là lựa chọn tối ưu để implement JWT vì:
    • Đảm bảo xử lý một lần duy nhất cho mỗi yêu cầu HTTP.
    • Đơn giản hóa logic xử lý với phương thức doFilterInternal.

Thanks

Thôi bài viết nhiêu đó thôi nhe anh em, phân tích một chút lý do tại sao không nên dùng Filter, còn anh em xem tut hướng dẫn implement JWT authentication thì họ dùng OncePerRequestFilter thì hiểu lý do rồi đó.

Anh em muốn mình đi sâu hơn phần nào thì cứ comment (để mình cũng có cơ hội tìm hiểu kỹ hơn luôn) với mình rãnh thì mình cũng edit lại một số chỗ giải thích thêm một số cái 🥹🥹

Rồi cảm ơn anh chị em mình đã đọc bài, muốn feedback gì thì cứ thoải mái feedback đi cho lên tương tác đồ 😁


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí