Filter chain: Spring Security chặn request ở đâu?

5 câu hỏi~25 phútTừ khóa: Servlet Filter, DelegatingFilterProxy, FilterChainProxy, SecurityFilterChain, filter order, securityMatcher, nhiều chain

Mình muốn học Spring Security.

Chúng ta học Spring Security theo hướng thực tế với Spring Boot 3 / Spring Security 6. Cách học: Bài toán → Vấn đề → Giải pháp → Code → Cơ chế → Câu hỏi phỏng vấn.

Lộ trình:

1. Filter chain           (bài này)
2. Authentication flow    AuthenticationManager, UserDetailsService, BCrypt
3. Authorization          role, authority, 401 vs 403
4. Session login          JSESSIONID, session fixation, Spring Session Redis
5. JWT                    stateless, Bearer token, revoke
6+. Refresh token, OAuth2/OIDC, Keycloak, CORS/CSRF...

1. Bài toán: ai cũng xóa được đơn hàng

Project ví dụ xuyên suốt: shop-app, một REST API quản lý đơn hàng (Spring Boot 3, Java 17).

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @GetMapping
    public String getOrders() {
        return "Order list";
    }

    @DeleteMapping("/{id}")
    public String deleteOrder(@PathVariable Long id) {
        return "Order deleted";
    }
}

Hiện tại bất kỳ ai cũng gọi được:

GET    /api/orders
DELETE /api/orders/100

2. Vấn đề

Người chưa đăng nhập vẫn xóa được đơn hàng. Ta cần hai cơ chế:

Nếu tự viết if (user == null) trong từng Controller thì lặp code, dễ quên một endpoint, và lúc đó request đã đi tới tận Controller rồi.

3. Giải pháp: chặn từ tầng Servlet Filter

Spring Security không phải Controller riêng. Nó cắm vào chuỗi Servlet Filter của Tomcat, nên kiểm tra request trước khi request tới DispatcherServlet.

HTTP Request
     |
     v
Tomcat
     |
     v
Servlet Filters
     |
     +-- Spring Security
     |       +-- SecurityFilterChain
     |               +-- Authentication
     |               +-- Authorization
     v
DispatcherServlet
     |
     v
Controller → Service → Repository

Ba thành phần cần phân biệt:

Thành phầnLà gìAi sở hữu
DelegatingFilterProxyMột Servlet Filter "cầu nối". Tomcat biết nó, nó chuyển việc sang một bean trong Spring contextServlet container
FilterChainProxyBean tên springSecurityFilterChain. Chọn SecurityFilterChain phù hợp với request rồi chạy các filter trong đóSpring context
SecurityFilterChainMột danh sách filter bảo mật + điều kiện áp dụng (request matcher). Bạn khai báo bằng @BeanCode của bạn

Vì sao cần DelegatingFilterProxy? Tomcat đăng ký filter trước khi Spring tạo xong bean. Proxy cho phép filter thật là một Spring bean (được inject, cấu hình bằng Java), được lấy ra lúc request đầu tiên đến.

4. Code từng bước

Bước 1: Dependency

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

Chỉ cần thêm dependency, Spring Boot đã khóa mọi endpoint, tạo user user với password ngẫu nhiên in ra log. Đó là cấu hình mặc định, chỉ để thử.

Bước 2: Khai báo SecurityFilterChain bean. Spring Security 6 không còn WebSecurityConfigurerAdapter; ta khai báo bean:

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .httpBasic(Customizer.withDefaults());

        return http.build();
    }
}
Cấu hìnhChức năng
permitAll()Không yêu cầu đăng nhập
authenticated()Phải xác thực
hasRole("ADMIN")Phải có authority ROLE_ADMIN
httpBasic()Bật HTTP Basic (thêm BasicAuthenticationFilter)
http.build()Tạo ra SecurityFilterChain

Bước 3: Xem chain có những filter nào

# application.properties
logging.level.org.springframework.security=DEBUG

Lúc khởi động, log in danh sách filter theo thứ tự (rút gọn):

DisableEncodeUrlFilter
WebAsyncManagerIntegrationFilter
SecurityContextHolderFilter        ← nạp SecurityContext cho request
HeaderWriterFilter                 ← security headers
CsrfFilter                         ← kiểm CSRF token
LogoutFilter
UsernamePasswordAuthenticationFilter (nếu bật formLogin)
BasicAuthenticationFilter          ← đọc header Authorization: Basic
RequestCacheAwareFilter
SecurityContextHolderAwareRequestFilter
AnonymousAuthenticationFilter      ← chưa ai đăng nhập thì gắn "anonymous"
ExceptionTranslationFilter         ← đổi exception thành 401/403
AuthorizationFilter                ← quyết định cho qua hay không (cuối cùng)

Bước 4: Test bằng curl

curl -i http://localhost:8080/api/public/products
# 200 OK

curl -i http://localhost:8080/api/orders
# 401 Unauthorized  (chưa gửi credentials)

curl -i -u john:<password> http://localhost:8080/api/admin/users
# 403 Forbidden     (đăng nhập rồi nhưng không phải ADMIN)

5. Cách hoạt động

Tomcat filter chain
  ├── CharacterEncodingFilter
  ├── DelegatingFilterProxy ("springSecurityFilterChain")
  │        │  lấy bean từ ApplicationContext
  │        v
  │   FilterChainProxy
  │        │  duyệt các SecurityFilterChain theo thứ tự,
  │        │  chain ĐẦU TIÊN có matcher khớp request sẽ được dùng
  │        v
  │   SecurityFilterChain #n
  │        ├── SecurityContextHolderFilter
  │        ├── CsrfFilter
  │        ├── BasicAuthenticationFilter
  │        ├── ...
  │        ├── ExceptionTranslationFilter
  │        └── AuthorizationFilter
  │        │
  │        v (nếu được cho qua)
  └── DispatcherServlet → Controller

Điểm hay hỏi: chỉ một SecurityFilterChain được chạy cho mỗi request, là chain đầu tiên khớp. FilterChainProxy còn làm vài việc chung: dọn SecurityContextHolder sau request (tránh rò rỉ sang thread khác trong pool), áp dụng HttpFirewall chặn URL lạ.

6. So sánh

Servlet Filter thườngFilter trong SecurityFilterChain
Đăng ký vớiTomcatFilterChainProxy (Spring)
Áp dụng theo URLurl-pattern đơn giảnRequestMatcher linh hoạt
Inject beanKhóDễ, là Spring bean
Nhiều cấu hình khác nhauKhôngNhiều chain, mỗi chain một luật

7. Khi nào nên dùng / không nên dùng

Vậy một app có nhiều SecurityFilterChain được không? Ví dụ /api dùng token còn /admin dùng form login.

Được, và đây là cách làm chuẩn. Mỗi chain có securityMatcher riêng và thứ tự bằng @Order. Chain cụ thể đặt trước, chain "bắt tất cả" đặt cuối:

@Configuration
public class SecurityConfig {

    @Bean
    @Order(1)
    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()
                .anyRequest().authenticated())
            .httpBasic(Customizer.withDefaults());
        return http.build();
    }

    @Bean
    @Order(2)
    SecurityFilterChain webChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().permitAll())
            .formLogin(Customizer.withDefaults());
        return http.build();
    }
}
GET /api/orders   → khớp apiChain (Order 1)  → chạy apiChain, BỎ QUA webChain
GET /admin/report → không khớp apiChain      → thử webChain → khớp

Lỗi hay gặp: chain không có securityMatcher (khớp mọi request) lại đặt @Order nhỏ hơn. Khi đó nó "nuốt" hết, chain sau không bao giờ chạy.

Còn requestMatchers với securityMatcher khác nhau chỗ nào? Nhìn giống nhau quá.

Bạn hiểu gần đúng là cả hai đều so URL, chỉ cần chỉnh một điểm: chúng hoạt động ở hai cấp khác nhau.

securityMatcherrequestMatchers (trong authorizeHttpRequests)
Trả lời câu hỏiChain này có xử lý request không?Request này cần quyền gì?
CấpChọn chain (FilterChainProxy)Bên trong AuthorizationFilter
Không khớp thìChuyển sang chain kế tiếpThử luật kế tiếp; luật đầu tiên khớp thắng

Thứ tự requestMatchers cũng quan trọng: luật cụ thể trước, anyRequest() luôn cuối cùng. Nếu đặt .requestMatchers("/api/**").authenticated() trước .requestMatchers("/api/public/**").permitAll() thì /api/public vẫn bị đòi đăng nhập.

Tôi muốn thêm filter của riêng mình (log request, đọc token) thì gắn vào đâu?

Đúng rồi, đây là chỗ bạn sẽ dùng khi làm JWT tự viết. Kế thừa OncePerRequestFilter (đảm bảo chạy một lần/request) và chèn vào vị trí cụ thể trong chain:

public class RequestLogFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
                                    FilterChain chain) throws ServletException, IOException {
        long start = System.currentTimeMillis();
        try {
            chain.doFilter(req, res);
        } finally {
            log.info("{} {} -> {} ({} ms)", req.getMethod(), req.getRequestURI(),
                     res.getStatus(), System.currentTimeMillis() - start);
        }
    }
}

http.addFilterBefore(new RequestLogFilter(), UsernamePasswordAuthenticationFilter.class);

Lưu ý: nếu bạn khai báo filter đó là @Component, Spring Boot sẽ tự đăng ký thêm nó vào Tomcat, nên nó chạy hai lần (một trong Tomcat, một trong security chain). Hoặc không đánh @Component, hoặc tắt đăng ký bằng FilterRegistrationBean.setEnabled(false).

Phỏng vấn hỏi về filter chain của Spring Security thì trả lời sao?

Q1. What is Spring Security?

🇻🇳 Tiếng Việt:

Spring Security là framework lo authentication, authorization và chống các tấn công phổ biến (CSRF, session fixation...) cho ứng dụng Java. Nó dùng chuỗi Servlet Filter để chặn và kiểm tra request trước khi request tới Controller.

🇬🇧 English:

Spring Security is a framework that provides authentication, authorization, and protection against common attacks for Java applications. It uses a filter chain to intercept and secure HTTP requests before they reach the application controllers.

Q2. Giải thích DelegatingFilterProxy và FilterChainProxy.

🇻🇳 Tiếng Việt:

DelegatingFilterProxy là Servlet Filter đăng ký với Tomcat, nó ủy quyền cho bean springSecurityFilterChain trong Spring context, chính là FilterChainProxy. FilterChainProxy chọn SecurityFilterChain đầu tiên khớp request rồi chạy các security filter trong đó, và dọn SecurityContext sau request.

🇬🇧 English:

DelegatingFilterProxy is a standard Servlet filter registered with the container. It delegates to a Spring bean named springSecurityFilterChain, which is the FilterChainProxy. The FilterChainProxy picks the first SecurityFilterChain that matches the request, runs its security filters, and clears the security context afterwards.

Q3. Một ứng dụng có nhiều SecurityFilterChain thì chạy thế nào?

🇻🇳 Tiếng Việt:

Mỗi chain có securityMatcher và @Order. Với mỗi request chỉ chain đầu tiên khớp được dùng. Ví dụ /api/** stateless với JWT, phần còn lại dùng form login và session. Chain bắt tất cả phải đặt cuối.

🇬🇧 English:

Each chain has a securityMatcher and an @Order. For each request, only the first matching chain is applied. For example, /api/** can be stateless with JWT while the rest of the app uses form login and sessions. The catch-all chain must have the lowest priority.

Q4. Vì sao Spring Security 6 bỏ WebSecurityConfigurerAdapter?

🇻🇳 Tiếng Việt:

Để chuyển sang cấu hình dựa trên bean: khai báo SecurityFilterChain, UserDetailsService, PasswordEncoder là các @Bean độc lập. Cách này rõ ràng hơn, dễ có nhiều chain, không cần kế thừa và override.

🇬🇧 English:

It moved to component-based configuration: we declare SecurityFilterChain, UserDetailsService and PasswordEncoder as independent beans. This is more explicit, makes multiple chains easy, and avoids inheritance and overriding.

Cách nhớ ngắn gọn


Tự kiểm tra

Thành phần nào kiểm tra bảo mật trước Controller: DispatcherServlet hay SecurityFilterChain?

SecurityFilterChain. Nó chạy trong Servlet Filter, đứng trước DispatcherServlet.

Vì sao cần DelegatingFilterProxy thay vì đăng ký thẳng filter với Tomcat?

Tomcat khởi tạo filter trước khi Spring context sẵn sàng. Proxy cho phép filter thật là Spring bean, được lấy lười khi có request.

Có hai chain, chain không có securityMatcher đặt @Order(1). Chuyện gì xảy ra?

Nó khớp mọi request nên chain thứ hai không bao giờ được dùng.

Filter tự viết đánh @Component và addFilterBefore thì bị gì?

Chạy hai lần: Spring Boot đăng ký nó vào Tomcat, và nó còn nằm trong security chain.

Filter nào đổi AuthenticationException/AccessDeniedException thành 401/403?

ExceptionTranslationFilter.