Spring Security & Testing/Multiple SecurityFilterChain & Stateless JWT — tách chain theo nhóm endpoint
5/20
Bài 5 / 20~13 phútSecurity Architecture & Filter ChainMiễn phí lượt xem

Multiple SecurityFilterChain & Stateless JWT — tách chain theo nhóm endpoint

Một app Spring có thể chạy song song nhiều SecurityFilterChain bean với @Order + securityMatcher để áp dụng policy khác nhau cho từng nhóm URL. Bài này bóc cơ chế FilterChainProxy chọn chain, cách cấu hình stateless cho JWT REST API (SessionCreationPolicy.STATELESS + csrf disable), và WebSecurityCustomizer để bypass static resources.

TL;DR: FilterChainProxy giữ một danh sách SecurityFilterChain, duyệt theo @Order từ thấp đến cao, dừng ở chain đầu tiên có securityMatcher khớp URL — chain đó xử lý request, các chain sau bị bỏ qua. Mỗi chain cấu hình độc lập: /api/** dùng JWT stateless (SessionCreationPolicy.STATELESS + csrf disable), /admin/** dùng strict role, web form login dùng session. Chain cuối không cần securityMatcher — đóng vai catch-all. WebSecurityCustomizer.ignoring() bypass hẳn filter stack cho static resources. Cơ chế này cho phép API JWT và web form login cùng sống trong một app mà không tranh nhau config.

1. Vấn đề thực tế — khi một chain không đủ

Hãy xét app TaskFlow mà chúng ta đang xây: cùng một Spring Boot process cần phục vụ ba nhóm URL với policy hoàn toàn khác nhau:

Nhóm URLPolicyXác thực
/api/**Stateless, CSRF offJWT Bearer token
/admin/**Stateless, chỉ ROLE_ADMIN, audit logJWT Bearer + strict role
/static/**, /css/**, /js/**Không cần securityBypass filter hoàn toàn

Nếu gộp cả ba vào một SecurityFilterChain, config trở thành một mớ điều kiện phức tạp, khó đọc, và dễ xung đột — ví dụ, SessionCreationPolicy chỉ set được một lần cho cả chain. Giải pháp của Spring Security là nhiều SecurityFilterChain bean, mỗi cái chịu trách nhiệm một nhóm URL.

2. Cơ chế bên dưới — FilterChainProxy chọn chain như thế nào

FilterChainProxy là servlet filter thật sự được đăng ký với container. Bên trong nó giữ List<SecurityFilterChain> đã sắp xếp theo @Order. Với mỗi request đến, nó thực hiện một thuật toán đơn giả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

Điểm cốt lõi: một request chỉ đi qua đúng một chain. Chain được chọn là chain đầu tiên (theo @Order) mà securityMatcher của nó khớp URL. Nếu không chain nào khớp, FilterChainProxy trả 403 mặc định.

securityMatcher là cổng vào của chain — nó không phải authorizeHttpRequests. securityMatcher quyết định chain này có nhận request không; authorizeHttpRequests bên trong chain mới quyết định request có được phép qua không.

securityMatcher vs requestMatchers

securityMatcher: filter-level — chain này áp dụng cho URL nào? Nếu không khớp, cả chain bị bỏ qua.


requestMatchers (trong authorizeHttpRequests): authorization-level — URL này cần role/auth gì? Chỉ chạy sau khi chain đã được chọn.

3. Cấu hình nhiều SecurityFilterChain với @Order

Đây là pattern đầy đủ cho TaskFlow — ba chain, mỗi chain một trách nhiệm rõ:

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    // Chain 1 -- Admin: JWT strict + @Order thap nhat = uu tien cao nhat
    @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(oauth2 -> oauth2
                .jwt(Customizer.withDefaults())
            )
            .exceptionHandling(eh -> eh
                .authenticationEntryPoint(jsonEntryPoint())
                .accessDeniedHandler(jsonAccessDeniedHandler())
            );

        return http.build();
    }

    // Chain 2 -- REST API: JWT stateless
    @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/**").permitAll()
                .requestMatchers("/api/public/**").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(Customizer.withDefaults())
            )
            .exceptionHandling(eh -> eh
                .authenticationEntryPoint(jsonEntryPoint())
                .accessDeniedHandler(jsonAccessDeniedHandler())
            );

        return http.build();
    }

    // Chain 3 -- catch-all: khong co securityMatcher
    // Web form login hoac cac URL khac
    @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")
                .defaultSuccessUrl("/dashboard", true)
                .permitAll()
            )
            .logout(logout -> logout
                .logoutSuccessUrl("/login?logout")
            );

        return http.build();
    }

    @Bean
    public AuthenticationEntryPoint jsonEntryPoint() {
        return (req, res, ex) -> {
            res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            res.setContentType("application/problem+json");
            res.getWriter().write(
                "{\"status\":401,\"title\":\"Unauthorized\",\"instance\":\"" +
                req.getRequestURI() + "\"}");
        };
    }

    @Bean
    public AccessDeniedHandler jsonAccessDeniedHandler() {
        return (req, res, ex) -> {
            res.setStatus(HttpServletResponse.SC_FORBIDDEN);
            res.setContentType("application/problem+json");
            res.getWriter().write(
                "{\"status\":403,\"title\":\"Forbidden\",\"instance\":\"" +
                req.getRequestURI() + "\"}");
        };
    }
}

Điểm cần chú ý:

  • @Order(1) là ưu tiên cao nhất (số nhỏ = xét trước).
  • Chain cuối (@Order(3)) không có securityMatcher → đây là catch-all: nhận mọi URL không khớp chain trên.
  • Mỗi chain gọi http.build() riêng — chúng hoàn toàn độc lập, không chia sẻ config.

4. Cấu hình stateless cho JWT REST API — tại sao và cái gì

Khi REST API dùng JWT, có hai thứ bắt buộc phải cấu hình: tắt sessiontắt CSRF. Cả hai đều xuất phát từ cùng một nguyên tắc thiết kế.

4.1 SessionCreationPolicy.STATELESS — tại sao JWT không cần session

HTTP session trong Spring Security lưu SecurityContext vào HttpSession — khi request xong, context được ghi vào session, request tiếp theo đọc lại từ session để biết user là ai. Đây là cơ chế của web app truyền thống.

JWT hoạt động khác hẳn: mỗi request mang token trong header Authorization: Bearer <jwt>. BearerTokenAuthenticationFilter đọc token, validate signature + expiry, rồi dựng SecurityContext ngay tại đó — từ thông tin trong token, không cần session. Sau khi response, context bị garbage collected.

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

Khi đặt SessionCreationPolicy.STATELESS, Spring Security không tạo HttpSession mới và không đọc SecurityContext từ session có sẵn — mỗi request hoàn toàn tự lập. Đây là điều kiện tiên quyết để scale horizontally: bất kỳ pod nào cũng xử lý được bất kỳ request nào vì không có session state cần chia sẻ.

http.sessionManagement(s ->
    s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);

4.2 csrf(csrf -> csrf.disable()) — tại sao REST API không cần CSRF protection

CSRF (Cross-Site Request Forgery) là tấn công lợi dụng trình duyệt tự đính kèm cookie khi gửi request. Kịch bản: user đang đăng nhập bank.com (session cookie JSESSIONID lưu ở browser), rồi ghé trang độc hại có form ẩn POST bank.com/transfer. Trình duyệt tự gửi kèm cookie → server nhận được request hợp lệ từ góc nhìn session, nhưng thực ra do kẻ tấn công kích hoạt.

Lỗ hổng CSRF tồn tại khi: (1) xác thực dựa vào cookie/session, VÀ (2) trình duyệt tự đính kèm credential đó. REST API dùng JWT trong header Authorization không thoả điều kiện này — trình duyệt không tự đính kèm header Authorization cross-site, chỉ có JavaScript mới làm được và Same-Origin Policy chặn cross-origin JavaScript.

Vì vậy REST API stateless + JWT không có CSRF surface → disable CSRF là đúng, không phải tắt bừa:

http.csrf(csrf -> csrf.disable());
Khi nao khong nen disable CSRF

Nếu app dùng form login + session cookie (chain web form login ở ví dụ trên), giữ CSRF bật. Thymeleaf template tự inject _csrf token vào form; SPA dùng CookieCsrfTokenRepository đọc từ cookie. Tắt CSRF chỉ an toàn khi xác thực không dùng cookie session.

5. WebSecurityCustomizer — bypass filter hoàn toàn cho static resources

Static resources (CSS, JS, images) không cần security check nào cả. Nếu đặt permitAll() trong chain, request vẫn chạy qua toàn bộ filter stack — tốn CPU không cần thiết và ghi log thừa. WebSecurityCustomizer.ignoring() bypass hẳn FilterChainProxy:

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

permitAll()ignoring() cùng cho request đi lọt, nhưng chúng tách nhau ngay ở cửa:

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

Quy tắc an toàn khi dùng ignoring():

  • Dùng cho: CSS, JS, images, fonts, favicon — file public hoàn toàn, không chứa logic.
  • Không dùng cho: /api/health, /actuator/health — các endpoint này vẫn nên trong chain với permitAll() để có security headers và audit log.
  • Không dùng cho: bất kỳ path nào có thể trả dữ liệu nhạy cảm — ignoring() tắt hết security, kể cả HTTPS redirect.

6. Pitfall

Pitfall 1 — quên securityMatcher() ở chain trung gian:

// SAI -- adminChain khong co securityMatcher -> nuot tat ca request
@Bean @Order(1)
public SecurityFilterChain adminChain(HttpSecurity http) throws Exception {
    http
        // .securityMatcher("/admin/**")   <-- quen dong nay
        .authorizeHttpRequests(auth -> auth
            .anyRequest().hasRole("ADMIN")
        );
    return http.build();
}

@Bean @Order(2)
public SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
    // Chain nay KHONG BAO GIO duoc chon -- adminChain lay het
    ...
}
// DUNG -- moi chain (tru catch-all cuoi) phai co securityMatcher
@Bean @Order(1)
public SecurityFilterChain adminChain(HttpSecurity http) throws Exception {
    http
        .securityMatcher("/admin/**")   // scope ro rang
        .authorizeHttpRequests(auth -> auth
            .anyRequest().hasRole("ADMIN")
        );
    return http.build();
}

Pitfall 2 — set STATELESS nhưng quên disable CSRF, app throw 403:

// SAI -- STATELESS + CSRF con bat: Spring Security generate CSRF token
// nhung STATELESS -> session khong luu token -> moi request deu bi reject
http
    .sessionManagement(s -> s
        .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
    // .csrf(csrf -> csrf.disable())   <-- quen
    .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
// Ket qua: POST /api/orders -> 403 Forbidden (CSRF mismatch)
// DUNG -- STATELESS luon di kem voi csrf.disable() cho REST API
http
    .csrf(csrf -> csrf.disable())
    .sessionManagement(s -> s
        .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
    .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));

Pitfall 3 — @Order trùng nhau gây lỗi khởi động:

// SAI -- hai chain cung @Order(1) -> IllegalStateException khi start
@Bean @Order(1) public SecurityFilterChain adminChain(...) { ... }
@Bean @Order(1) public SecurityFilterChain apiChain(...) { ... }

Spring Security ném IllegalStateException: Found ambiguous @Order nếu hai chain trùng order. Mỗi chain phải có @Order riêng biệt.

7. Liên hệ các bài khác

Bài này chỉ là một mảnh trong kiến trúc bảo mật. Để thấy bức tranh đầy đủ:

  • SecurityFilterChain DSL: cú pháp HttpSecurity lambda đầy đủ — requestMatchers, exceptionHandling, headers; bài này dùng làm nền cho tất cả config ở trên.
  • JWT structure & validation: oauth2ResourceServer.jwt(...) ở bài này validate token thế nào — signature algorithm, exp claim, JwtAuthenticationConverter map claims thành GrantedAuthority.
  • CORS & CSRF: bài này nói csrf.disable() an toàn cho REST API; bài 06 đào sâu tại sao — cơ chế tấn công CSRF, điều kiện cần để exploit, và khi nào web app truyền thống cần CSRF bật.
  • Method Security: @EnableMethodSecurity + @PreAuthorize("hasRole('ADMIN')") là tầng thứ hai ngoài chain config — bài 05 giải thích AOP proxy mechanism và khi nào cần defense-in-depth.

Tóm tắt

  • FilterChainProxy duyệt List<SecurityFilterChain> theo @Order (số nhỏ = ưu tiên cao), chọn chain đầu tiên có securityMatcher khớp URL — một request chỉ qua một chain.
  • securityMatcher là cổng vào của chain (chain có nhận không); requestMatchers bên trong là authorization rules (được phép không).
  • Chain cuối không cần securityMatcher — đóng vai catch-all.
  • SessionCreationPolicy.STATELESS ngăn Spring Security tạo/đọc HttpSession — mỗi JWT request tự lập, không cần session state, scale horizontally dễ dàng.
  • csrf.disable() an toàn cho REST API JWT vì xác thực không dựa vào cookie — trình duyệt không tự đính kèm Authorization header cross-site.
  • WebSecurityCustomizer.ignoring() bypass hoàn toàn FilterChainProxy cho static resources — không log, không auth check, không security headers.

Tự kiểm tra

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    App có 3 SecurityFilterChain với @Order(1), @Order(2), @Order(3). Chain @Order(1) không khai báo securityMatcher. Request GET /api/orders sẽ được xử lý bởi chain nào? Hệ quả là gì?
  2. Q2
    Giải thích tại sao SessionCreationPolicy.STATELESS là điều kiện cần để scale horizontally REST API. Cơ chế bên dưới là gì?
  3. Q3
    Khi nào csrf.disable() an toàn, khi nào nguy hiểm? Giải thích theo cơ chế tấn công CSRF.
  4. Q4
    Sự khác biệt giữa WebSecurityCustomizer.ignoring()permitAll() trong authorizeHttpRequests? Khi nào dùng cái nào?
  5. Q5
    App có chain @Order(1) với securityMatcher("/api/**") và chain @Order(2) catch-all. Request POST /api/auth/login bị 403 dù đã cấu hình permitAll() cho /api/auth/** trong chain 2. Giải thích bug và cách fix.

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

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.

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

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

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên

Bài tiếp theo

Security Architecture & Filter Chain — tổng kết