Spring Security & Testing
5/20
Bài 5 / 20~12 phútSecurity Architecture & Filter ChainLộ trình · chặng 18/21Miễn phí… lượt xem

Đa Chuỗi SecurityFilterChain và Cấu Hình Stateless JWT

Cấu hình nhiều SecurityFilterChain song song bằng @Order và securityMatcher, thiết lập cơ chế stateless JWT và tối ưu tài nguyên tĩnh với ignoring.

TL;DR: FilterChainProxy quản lý một danh sách các SecurityFilterChain được sắp xếp theo độ ưu tiên @Order. Khi request đến, hệ thống duyệt từ trên xuống và dừng lại ở chuỗi đầu tiên có securityMatcher khớp với URL. Nhờ cơ chế này, một ứng dụng duy nhất có thể áp dụng các chính sách bảo mật khác nhau: /api/** dùng JWT không trạng thái (SessionCreationPolicy.STATELESS kèm tắt CSRF), /admin/** áp dụng kiểm tra quyền nghiêm ngặt, trong khi giao diện web dùng Session Cookie truyền thống. Để tối ưu hóa hiệu năng, WebSecurityCustomizer.ignoring() cho phép tài nguyên tĩnh bỏ qua hoàn toàn chuỗi filter.

Tại sao một ứng dụng thực tế lại cần nhiều SecurityFilterChain?

Một hệ thống doanh nghiệp tiêu chuẩn thường phải đồng thời phục vụ nhiều nhóm đối tượng với yêu cầu bảo mật hoàn toàn khác nhau:

Phân vùng URLChính sách áp dụngPhương thức nhận diện
/api/**Stateless REST, tắt CSRFJWT Bearer token
/admin/**Stateless, yêu cầu quyền ROLE_ADMINJWT Bearer + Audit log
/static/**Bỏ qua toàn bộ bảo mậtKhông kiểm tra
Trang web quản trịCó trạng thái (Stateful), bật CSRFCookie Form login

Nếu cố nhồi nhét tất cả vào một bean SecurityFilterChain duy nhất, mã nguồn sẽ trở nên phân mảnh với hàng loạt điều kiện rẽ nhánh và xung đột chính sách vòng đời session. Tách thành nhiều bean SecurityFilterChain độc lập giúp cô lập phạm vi trách nhiệm.

FilterChainProxy điều phối và lựa chọn chuỗi bảo mật ra sao?

FilterChainProxy duyệt qua danh sách các chain được sắp xếp theo thứ tự @Order tăng dần:

Một request GET /api/orders/42 vào FilterChainProxy, nó duyệt danh sách theo @Order: chain 1 khai securityMatcher /admin/** nên không khớp và bị bỏ qua, chain 2 khai securityMatcher /api/** thì khớp nên chạy toàn bộ filter của riêng nó, còn chain 3 catch-all không có mũi tên nào đi vào vì chỉ nhận URL mà chain trên đã bỏ qua

Nguyên tắc vàng: Mỗi HTTP request chỉ được thực thi bởi đúng MỘT SecurityFilterChain duy nhất. Chain nào khớp trước sẽ nhận việc, các chain còn lại bị bỏ qua hoàn toàn.

securityMatcher vs requestMatchers
  • securityMatcher (Cấp chuỗi filter): Đóng vai trò cánh cổng của chain. Quyết định request có được đưa vào chuỗi này hay không.
  • requestMatchers (Cấp phân quyền): Nằm bên trong authorizeHttpRequests, chỉ chạy khi chain đã được chọn để quyết định cho qua hay chặn quyền.

Cấu hình đa chuỗi với @Order và securityMatcher như thế nào?

Mẫu cấu hình chuẩn với ba chuỗi độc lập:

Java
@Configuration
@EnableWebSecurity
public class MultiChainSecurityConfig {

    // Chain 1: Admin API - Highest priority
    @Bean
    @Order(1)
    public SecurityFilterChain adminChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/admin/**")
            .csrf(csrf -> csrf.disable())
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .anyRequest().hasRole("ADMIN")
            )
            .oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()));

        return http.build();
    }

    // Chain 2: Public & Business API
    @Bean
    @Order(2)
    public SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/api/**")
            .csrf(csrf -> csrf.disable())
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**", "/api/public/**").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()));

        return http.build();
    }

    // Chain 3: Catch-all for Web Form Login
    @Bean
    @Order(3)
    public SecurityFilterChain defaultChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/login", "/error").permitAll()
                .anyRequest().authenticated()
            )
            .formLogin(form -> form.loginPage("/login").permitAll());

        return http.build();
    }
}

Cơ chế Stateless và việc tắt CSRF cho JWT API có ý nghĩa gì?

1. Bản chất của SessionCreationPolicy.STATELESS

Khi ứng dụng dùng form login truyền thống, SecurityContext được lưu trong HttpSession. Để nhận diện người dùng, máy chủ phụ thuộc vào cookie JSESSIONID và phải lưu trạng thái trong RAM của từng Pod (Stateful).

Hai cột đối chiếu nơi cất state: cột session thì request 1 gửi username và mật khẩu, server tạo HttpSession giữ SecurityContext trong đó, nên request 2 chỉ mang JSESSIONID và phải về đúng pod đang giữ session; cột JWT thì mỗi request mang Bearer token, server verify chữ ký và hạn rồi dựng SecurityContext tại chỗ, nên request sau đi vào pod khác vẫn dựng lại được

Ngược lại, với kiến trúc REST API JWT:

  • Mỗi request tự mang theo đầy đủ bằng chứng nhận diện trong header Authorization: Bearer <jwt>.
  • SessionCreationPolicy.STATELESS ra lệnh cho Spring Security không tạo mới và không tìm kiếm HttpSession.
  • Sau khi gửi xong phản hồi, đối tượng context trong ThreadLocal được giải phóng ngay lập tức. Đây là nền tảng để ứng dụng mở rộng ngang (horizontal scale) không giới hạn trên Kubernetes.

2. Tại sao tắt CSRF lại an toàn cho Stateless API?

Lỗ hổng CSRF chỉ phát sinh khi trình duyệt web tự động đính kèm cookie xác thực khi người dùng bị lừa truy cập trang web độc hại.

Với REST API stateless, mã định danh JWT được lưu ở client (memory/storage) và chỉ được đính kèm vào header khi mã JavaScript chủ động gửi đi. Trình duyệt không tự động gửi header tùy ý cross-origin, vì vậy bề mặt tấn công CSRF hoàn toàn bị triệt tiêu:

Java
http.csrf(csrf -> csrf.disable());

Khi nào nên dùng WebSecurityCustomizer để bỏ qua filter?

Các tài nguyên tĩnh (HTML, CSS, ảnh đại diện, font chữ) hoàn toàn công khai và không chứa dữ liệu nhạy cảm. Nếu dùng permitAll() trong SecurityFilterChain, request vẫn phải đi qua toàn bộ 15+ filter, tiêu tốn CPU và sinh log dư thừa.

Sử dụng WebSecurityCustomizer.ignoring() giúp request rẽ nhánh ngay từ cổng vào, không chạm tới FilterChainProxy:

Java
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
    return web -> web.ignoring()
        .requestMatchers("/static/**", "/css/**", "/js/**", "/images/**", "/favicon.ico");
}

Cùng một request GET /css/app.css đi theo hai cấu hình: với permitAll trong authorizeHttpRequests, request vào FilterChainProxy như mọi request khác và chạy hết chuỗi filter gồm context, header, CSRF, xác thực, phân quyền rồi mới được rule cho qua, nên vẫn có security header và audit log; với WebSecurityCustomizer.ignoring, request bị gạt ra trước khi vào FilterChainProxy nên không filter nào chạy, DispatcherServlet trả file luôn và không còn header, log hay HTTPS redirect

Cẩn trọng với ignoring()

Không bao giờ áp dụng ignoring() cho các endpoint nhạy cảm như /actuator/health hay các API công khai. Các endpoint này vẫn cần chạy qua chuỗi filter với permitAll() để được bảo vệ bởi các HTTP Security Header (như CSP, HSTS) và hỗ trợ ghi nhận log truy cập.

Những cạm bẫy phổ biến khi cấu hình đa chuỗi là gì?

  • Quên securityMatcher ở chain ưu tiên cao: Nếu @Order(1) không có securityMatcher(), nó sẽ coi là khớp với mọi URL và nuốt trọn mọi request, khiến các chain phía sau không bao giờ được kích hoạt.
  • Trùng lặp giá trị @Order: Khai báo hai bean SecurityFilterChain có cùng chỉ số @Order sẽ khiến Spring ném ngoại lệ IllegalStateException khi khởi động.
  • Bật CSRF cùng với STATELESS: Nếu session ở chế độ STATELESS mà không tắt CSRF, token CSRF không có chỗ lưu trữ trong session và sẽ khiến mọi request thay đổi trạng thái (POST/PUT/DELETE) bị từ chối với mã 403.

Những điểm mấu chốt cần ghi nhớ là gì?

  • Sử dụng @Order kết hợp securityMatcher để tách biệt cấu hình bảo mật cho từng phân vùng URL.
  • Mỗi request chỉ chạy qua một chain duy nhất: chain đầu tiên khớp sẽ xử lý toàn bộ vòng đời.
  • REST API stateless với JWT bắt buộc phải kết hợp SessionCreationPolicy.STATELESS và csrf.disable().
  • Tách riêng tài nguyên tĩnh ra khỏi chuỗi filter bằng WebSecurityCustomizer.ignoring() để tối ưu hóa tài nguyên.

Bạn đã làm chủ cơ chế đa chuỗi và Stateless JWT chưa?

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Điều gì sẽ xảy ra nếu bean SecurityFilterChain có @Order(1) quên không khai báo phương thức securityMatcher()?
  2. Q2
    Tại sao thiết lập SessionCreationPolicy.STATELESS lại hỗ trợ mở rộng ngang (horizontal scale) cho hệ thống backend?
  3. Q3
    Sự khác biệt cốt lõi giữa permitAll() trong authorizeHttpRequests và WebSecurityCustomizer.ignoring() là gì?
  4. Q4
    Tại sao việc tắt CSRF (csrf.disable()) lại được xem là an toàn đối với các REST API sử dụng JWT Bearer token?
  5. Q5
    Trong cấu hình đa chuỗi, chain cuối cùng đóng vai trò catch-all nên có đặc điểm gì?

Bài tiếp theo: Tổng kết module

Bài này có giúp bạn hiểu bản chất không?

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Lộ trình Junior Java Backend Developer

Chặng 18/21 · Authentication & Authorization — bài 5/19 của chặng

Thảo luận & góp ý

Hỏi đáp về bài này

Bài tiếp theo

Tổng Kết Module: Security Architecture & Filter Chain