Spring Security & Testing/Filter chain architecture — Spring Security đứng trước DispatcherServlet ra sao
2/20
Bài 2 / 20~12 phútSecurity Architecture & Filter ChainMiễn phí lượt xem

Filter chain architecture — Spring Security đứng trước DispatcherServlet ra sao

Spring Security không hook vào Spring MVC — nó là một chuỗi Servlet Filter chạy trước DispatcherServlet. Bài này giải thích tại sao thiết kế đó đúng, FilterChainProxy là gateway duy nhất, 15+ filter mỗi cái một trách nhiệm, và Security 6 lambda DSL thay thế WebSecurityConfigurerAdapter như thế nào.

TL;DR: Spring Security triển khai bảo mật như một chuỗi Servlet Filter chèn trước DispatcherServlet — request bị reject ngay ở tầng Servlet, controller không bao giờ nhận được. DelegatingFilterProxy giải quyết vấn đề thứ tự khởi động: Tomcat tạo filter trước khi Spring context sẵn sàng, nên proxy lazy-fetch FilterChainProxy bean vào lần request đầu. Bên trong, SecurityFilterChain là danh sách ~15 filter chuyên dụng — mỗi filter đúng 1 nhiệm vụ: load context, CORS, CSRF, xác thực JWT/form, phân quyền. Spring Security 6 (Boot 3+) bỏ WebSecurityConfigurerAdapter, thay bằng SecurityFilterChain bean với lambda DSL bắt buộc. Hiểu kiến trúc này là nền để đọc Authentication flowSecurityFilterChain DSL.

Bạn vừa thêm spring-boot-starter-security vào project — mọi endpoint lập tức trả 401. Điều gì ngăn request trước khi controller nhận được? Câu trả lời là kiến trúc filter chain. Bài này bóc đúng một thứ: Spring Security nằm ở đâu trong request lifecycle, và tại sao nó được thiết kế như vậy.

1. Tại sao filter-based — security là lớp ngoài business

Nguyên tắc thiết kế cốt lõi: security không phải business logic — nó là gating condition. Reject sớm, reject trước khi tốn tài nguyên.

Nếu Spring Security hook vào DispatcherServlet (như một HandlerInterceptor), request đã đi qua routing, deserialize body, khởi tạo controller bean — rồi mới bị chặn. Với Servlet Filter, request bị chặn ở tầng thấp nhất: trước khi Spring MVC thậm chí được gọi.

flowchart LR
    Req["HTTP Request"]
    Tomcat["Tomcat<br/>(Servlet Container)"]
    SS["Spring Security<br/>Filter Chain<br/>(15+ filters)"]
    DS["DispatcherServlet<br/>(Spring MVC)"]
    Ctrl["@RestController"]
    Rej["401 / 403<br/>Response"]

    Req --> Tomcat --> SS
    SS -->|"reject"| Rej
    SS -->|"pass"| DS --> Ctrl

Hệ quả: khi filter reject, controller không bao giờ được gọi — security trở thành lớp độc lập bên ngoài business code. Đây là lý do Spring Security được implement bằng Servlet Filter API thay vì Spring MVC Interceptor.

2. Servlet Filter — nền tảng

Servlet API (chuẩn jakarta.servlet) định nghĩa Filter interface. Mỗi filter có thể inspect/modify request + response, và quyết định có chuyển tiếp xuống chuỗi hay không:

public interface Filter {
    void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
        throws IOException, ServletException;
}

Filter chạy theo chuỗi tuyến tính. Mỗi filter gọi chain.doFilter() để chuyển request sang filter kế tiếp; nếu không gọi, request dừng tại đó:

public class LoggingFilter implements Filter {
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
        throws IOException, ServletException {
        // Before: intercept incoming request
        long start = System.currentTimeMillis();
        chain.doFilter(req, res);              // pass to next filter
        // After: request returned from downstream
        log.info("Took {}ms", System.currentTimeMillis() - start);
    }
}

Spring Security xây trên cơ sở này — cung cấp sẵn một chuỗi ~15 filter chuyên dụng cho authentication và authorization.

3. Ba tầng: DelegatingFilterProxy, FilterChainProxy, SecurityFilterChain

Thay vì đăng ký 15 filter riêng lẻ vào Tomcat, Spring Security dùng kiến trúc 3 tầng:

flowchart TB
    Tomcat["Tomcat<br/>(Servlet Container)"]
    DFP["DelegatingFilterProxy<br/>registered at Tomcat level<br/>lazy-fetch Spring bean"]
    FCP["FilterChainProxy<br/>Spring bean -- match URL<br/>chon SecurityFilterChain"]
    SFC1["SecurityFilterChain #1<br/>/api/admin/** -- admin rules"]
    SFC2["SecurityFilterChain #2<br/>/** -- default rules"]
    Filters["CorsFilter -> CsrfFilter<br/>BearerTokenAuthFilter<br/>AuthorizationFilter<br/>..."]

    Tomcat --> DFP --> FCP
    FCP --> SFC1
    FCP --> SFC2
    SFC2 --> Filters
TầngClassTrách nhiệm
Servlet containerDelegatingFilterProxyBridge từ Tomcat sang Spring bean
Spring infrastructureFilterChainProxyMatch URL → chọn SecurityFilterChain
Application configSecurityFilterChainDanh sách filter cho URL pattern

Tại sao cần DelegatingFilterProxy

Tomcat khởi tạo servlet filter trong quá trình ServletContext initialization — xảy ra trước khi Spring ApplicationContext được tạo. Nếu đăng ký FilterChainProxy (một Spring bean) trực tiếp như Tomcat filter, Tomcat sẽ cố lấy bean khi context chưa sẵn sàng — lỗi.

DelegatingFilterProxy giải quyết bằng cách lazy-fetch: khi Tomcat hỏi về filter trong init(), proxy chỉ lưu tên bean. Khi request đầu tiên đến, Spring context đã sẵn sàng, proxy mới tìm bean springSecurityFilterChain qua WebApplicationContext và uỷ quyền từ đó về sau.

FilterChainProxy là gateway duy nhất

FilterChainProxy nhận request, match URL, và chọn SecurityFilterChain phù hợp. Một app có thể có nhiều SecurityFilterChain bean với URL pattern khác nhau — ví dụ chain riêng cho admin endpoints với rule chặt hơn. FilterChainProxy dùng thứ tự @Order để ưu tiên.

4. Cơ chế bên dưới — 15+ filter, mỗi filter một nhiệm vụ

SecurityFilterChain bên trong là một danh sách filter chạy theo thứ tự cố định. Spring Security 6 default chain (đơn giản hoá):

flowchart TB
    F1["DisableEncodeUrlFilter"]
    F2["WebAsyncManagerIntegrationFilter"]
    F3["SecurityContextHolderFilter"]
    F4["HeaderWriterFilter"]
    F5["CorsFilter"]
    F6["CsrfFilter"]
    F7["LogoutFilter"]
    F8["UsernamePasswordAuthenticationFilter"]
    F9["BearerTokenAuthenticationFilter"]
    F10["BasicAuthenticationFilter"]
    F11["RequestCacheAwareFilter"]
    F12["AnonymousAuthenticationFilter"]
    F13["ExceptionTranslationFilter"]
    F14["AuthorizationFilter"]

    F1 --> F2 --> F3 --> F4 --> F5 --> F6 --> F7 --> F8 --> F9 --> F10 --> F11 --> F12 --> F13 --> F14

Các filter quan trọng nhất bạn sẽ chạm:

FilterVị tríNhiệm vụ
SecurityContextHolderFilterĐầu chainLoad SecurityContext từ session/repo vào ThreadLocal per-thread
CorsFilterTrước authXử lý CORS preflight + đính headers
CsrfFilterTrước authValidate CSRF token (form-based request)
UsernamePasswordAuthenticationFilterAuth zoneXử lý form login (POST /login)
BearerTokenAuthenticationFilterAuth zoneValidate JWT từ Authorization: Bearer ...
AnonymousAuthenticationFilterSau auth filtersNếu chưa có auth, set AnonymousAuthenticationToken
ExceptionTranslationFilterCuối trước authzCatch exception — map sang 401/403 response
AuthorizationFilterCuối chainCheck GrantedAuthority match URL rule

Thứ tự có ý nghĩa: SecurityContextHolderFilter phải chạy đầu tiên để tạo SecurityContext cho thread hiện tại; mọi filter sau mới có context để đọc. ExceptionTranslationFilter chạy trước AuthorizationFilter để bắt AccessDeniedException từ filter ngay sau.

Mỗi filter một trách nhiệm — tại sao không gộp

Tách filter theo trách nhiệm (Single Responsibility) cho phép bật/tắt từng tính năng độc lập. Ví dụ: REST API stateless không cần CSRF protection — tắt CsrfFilter trong DSL mà không ảnh hưởng filter khác. CORS config tách biệt với auth logic. Không cần viết lại toàn bộ pipeline khi thay đổi một tính năng.

5. SecurityFilterChain DSL — Spring Security 6

Spring Security 6 (Spring Boot 3+) bỏ hoàn toàn WebSecurityConfigurerAdapter. Config được khai báo qua SecurityFilterChain bean với lambda DSL bắt buộc:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())                        // REST API -- no CSRF
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .cors(cors -> cors.configurationSource(corsSource()))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/actuator/health").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 ->
                oauth2.jwt(Customizer.withDefaults())            // validate Bearer token
            );

        return http.build();
    }
}

Các thay đổi cốt lõi so với Spring Security 5:

AspectSpring Security 5Spring Security 6
ConfigurationExtend WebSecurityConfigurerAdapter@Bean SecurityFilterChain
DSL styleMethod chain với .and()Lambda DSL bắt buộc
URL matchersantMatchers()requestMatchers() (smarter)
AuthorizationauthorizeRequests()authorizeHttpRequests()
Java baseline817

Lambda DSL không chỉ là thay đổi cú pháp — mỗi http.csrf(...) trả về chính HttpSecurity (không phải sub-configurer), nên không cần .and() để quay lại context. Code gọn hơn và dễ hơn khi extract một đoạn config vào method riêng.

Cơ chế bên dưới — request chạy qua chain như thế nào

Nhìn vào FilterChainProxy.doFilter() (source Spring Security), luồng thực tế khi request đến:

sequenceDiagram
    participant Req as HTTP Request
    participant DFP as DelegatingFilterProxy
    participant FCP as FilterChainProxy
    participant SFC as SecurityFilterChain
    participant F as Filter[i]

    Req->>DFP: doFilter()
    DFP->>FCP: doFilter() (lazy bean lookup first time)
    FCP->>FCP: getFilters(request) -- match URL -> find chain
    FCP->>SFC: getFilters()
    loop each filter in chain
        FCP->>F: doFilter(req, res, virtualChain)
        F->>F: execute security logic
        F->>FCP: chain.doFilter() -- proceed to next
    end
    FCP-->>Req: response

FilterChainProxy tạo VirtualFilterChain — wrapper FilterChain nội bộ iterate qua danh sách filter của SecurityFilterChain. Từng filter nhận chain.doFilter() trỏ tới VirtualFilterChain, không phải Tomcat chain gốc. Đây là lý do Spring Security hoàn toàn kiểm soát thứ tự và thành phần filter — Tomcat chỉ thấy 1 filter duy nhất.

Boot autoconfig — khởi động tự động

Chỉ cần thêm dependency:

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

Spring Boot autoconfig kích hoạt 3 thứ:

  • SecurityAutoConfiguration — đăng ký FilterChainProxy, tạo default SecurityFilterChain protect tất cả endpoint.
  • UserDetailsServiceAutoConfiguration — tạo in-memory UserDetailsService với user user + random password log ra console.
  • WebSecurityEnablerConfiguration — apply @EnableWebSecurity implicit.

Default behavior: tất cả endpoint yêu cầu auth, form login tại /login, HTTP Basic auth bật, CSRF bật. Khi bạn khai báo SecurityFilterChain bean của riêng mình, UserDetailsServiceAutoConfiguration và default chain lùi lại — config của bạn thay thế.

Pitfall phổ biến

Nhầm 1 — Extend WebSecurityConfigurerAdapter trong Spring Security 6:

// SAI -- class bi remove, compile error Spring Security 6
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception { ... }
}
// DUNG -- SecurityFilterChain bean
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        // ...
        return http.build();
    }
}

Nhầm 2 — Dùng antMatchers() trong Spring Security 6:

// SAI -- method bi remove
http.authorizeHttpRequests(auth -> auth
    .antMatchers("/admin/**").hasRole("ADMIN")
);
// DUNG
http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/admin/**").hasRole("ADMIN")
);

requestMatchers() thông minh hơn: tự detect AntPathMatcher vs MvcRequestMatcher dựa vào DispatcherServlet có trong context không.

Nhầm 3 — Tưởng HandlerInterceptor và Security Filter tương đương:

HandlerInterceptor chạy trong DispatcherServlet — sau khi Spring MVC đã routing. Security filter chạy trước DispatcherServlet. Nếu implement access control bằng interceptor, request đã deserialize rồi mới bị chặn — tốn tài nguyên không cần thiết. Dùng SecurityFilterChain cho authorization.

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

  • Bài 02 — Authentication flow: khi filter xác thực chạy, AuthenticationManagerAuthenticationProviderSecurityContextHolder hoạt động ra sao — đây là phần bên trong "auth zone" của chain.
  • Bài 03 — SecurityFilterChain DSL: đào sâu lambda DSL — authorizeHttpRequests, oauth2ResourceServer, sessionManagement, exceptionHandling — và cách multiple SecurityFilterChain chia URL pattern.
  • Tổng quan module: bản đồ toàn module kiến trúc filter chain — Authentication object, SecurityContextHolder ThreadLocal, AuthorizationFilter, exception mapping 401/403.

Tóm tắt

  • Spring Security là chuỗi Servlet Filter chạy trước DispatcherServlet — security là lớp ngoài business, reject trước khi controller nhận.
  • 3 tầng: DelegatingFilterProxy (bridge Tomcat-Spring) → FilterChainProxy (match URL, chọn chain) → SecurityFilterChain (danh sách filter cho app).
  • DelegatingFilterProxy tồn tại vì Tomcat khởi tạo filter trước Spring context — lazy-fetch bean lần request đầu.
  • ~15 filter, mỗi filter 1 nhiệm vụ: SecurityContextHolderFilter load context, CorsFilter / CsrfFilter bảo vệ web, auth filters xác thực, AuthorizationFilter phân quyền, ExceptionTranslationFilter map exception sang 401/403.
  • Spring Security 6: WebSecurityConfigurerAdapter bị xoá — dùng SecurityFilterChain bean + lambda DSL + requestMatchers().
  • Boot autoconfig kích hoạt khi có spring-boot-starter-security — override bằng SecurityFilterChain bean của riêng.

Tự kiểm tra

Tự kiểm tra
Q1
Tại sao Spring Security implement bảo mật bằng Servlet Filter thay vì Spring MVC HandlerInterceptor? Đâu là hệ quả nếu chọn ngược lại?

Servlet Filter chạy trước DispatcherServlet — request bị reject trước khi Spring MVC thực hiện routing, deserialize body, hay khởi tạo controller bean. Đây là điểm quan trọng: bảo mật không phải business logic, nên xử lý sớm nhất có thể, tiết kiệm tài nguyên.

Analogy toà nhà: Servlet Filter là vệ sĩ đứng ở cửa toà nhà — người không có thẻ bị chặn ngay sảnh, chưa tốn điện thang máy, chưa làm phiền ai. HandlerInterceptor là vệ sĩ đứng trước từng cửa phòng — kẻ lạ đã đi thang máy lên tầng, đã đứng giữa hành lang (routing + deserialize xong) rồi mới bị hỏi thẻ. Cùng chặn được, nhưng chi phí và phạm vi bảo vệ khác hẳn: vệ sĩ cửa phòng không bảo vệ được kho, phòng kỹ thuật — những nơi không có cửa phòng (static resource, WebSocket).

Nếu dùng HandlerInterceptor: request đã đi qua routing, DispatcherServlet đã chọn handler, Spring MVC đã chuẩn bị context — rồi mới bị chặn. Tài nguyên đã bị tiêu tốn cho request không hợp lệ. Hơn nữa, interceptor không bảo vệ được tài nguyên nằm ngoài Spring MVC (như static resource server, WebSocket, actuator endpoint dùng servlet riêng).

Servlet Filter chạy độc lập với framework phía sau — Tomcat chỉ biết đến filter, không biết Spring MVC hay không. Security trở thành lớp bên ngoài hoàn toàn tách với business code.

Q2
Tại sao cần DelegatingFilterProxy thay vì đăng ký FilterChainProxy trực tiếp vào Tomcat?

Tomcat khởi tạo servlet filter trong quá trình ServletContext initialization — xảy ra trước khi Spring ApplicationContext được tạo và refresh xong. Nếu đăng ký FilterChainProxy (một Spring bean) trực tiếp như Tomcat filter, Tomcat cố tìm bean khi context chưa tồn tại — lỗi khởi động.

DelegatingFilterProxy giải quyết bằng cách tách hai pha: trong init() chỉ lưu tên bean (springSecurityFilterChain); đến khi request đầu tiên đến, Spring context đã sẵn sàng, proxy mới tìm bean qua WebApplicationContext và uỷ quyền từ đó về sau.

Đây là pattern "lazy-fetch từ Spring context" — không phải vấn đề đặc thù Spring Security mà là giải pháp cho mọi Spring bean cần tồn tại như Servlet component.

Q3
Trong default filter chain, SecurityContextHolderFilter phải chạy đầu tiên và AuthorizationFilter cuối cùng. Tại sao thứ tự này không thể đảo?

SecurityContextHolderFilter load SecurityContext (chứa thông tin user đã xác thực) từ session hoặc repository vào ThreadLocal của thread hiện tại. Tất cả filter sau đó đều phụ thuộc vào SecurityContext này — auth filters ghi vào nó, AuthorizationFilter đọc từ nó để kiểm tra quyền.

Nếu AuthorizationFilter chạy trước SecurityContextHolderFilter: SecurityContextHolder.getContext().getAuthentication() trả về null — mọi request đều bị coi là anonymous và bị reject (hoặc được permit tùy config). Cả pipeline sẽ sai.

Tương tự: authentication filters (BearerTokenAuthenticationFilter, UsernamePasswordAuthenticationFilter) phải chạy trước AuthorizationFilter để kịp đặt Authentication vào context trước khi phân quyền được kiểm tra.

Q4
Spring Security 6 bắt buộc lambda DSL. So sánh cú pháp cũ (Spring Security 5 chain với .and()) và cú pháp mới. Tại sao lambda DSL là cải tiến?

Spring Security 5 (chain với .and()):

http.csrf().disable()
  .and()
  .sessionManagement()
      .sessionCreationPolicy(STATELESS)
  .and()
  .authorizeRequests()
      .antMatchers("/public/**").permitAll()
      .anyRequest().authenticated();

Spring Security 6 (lambda DSL):

http
  .csrf(csrf -> csrf.disable())
  .sessionManagement(s ->
      s.sessionCreationPolicy(STATELESS))
  .authorizeHttpRequests(auth -> auth
      .requestMatchers("/public/**").permitAll()
      .anyRequest().authenticated());

Lambda DSL cải tiến ở 3 điểm: (1) mỗi http.csrf(...) trả về HttpSecurity (không phải sub-configurer) nên không cần .and() để quay lại context; (2) mỗi khối config độc lập — dễ extract thành method riêng hoặc test từng phần; (3) khi một block lambda gặp lỗi, stack trace rõ hơn vì scope rõ ràng hơn chain dài.

Q5
App có 2 SecurityFilterChain bean: một cho /api/admin/** (@Order(1)), một cho /** (@Order(2)). Request tới /api/admin/users chạy qua chain nào? Nếu chain 1 không match, điều gì xảy ra?

FilterChainProxy iterate qua danh sách SecurityFilterChain theo thứ tự @Order tăng dần, kiểm tra matches(request) trên từng chain. Chain đầu tiên match sẽ được dùng — các chain sau bị bỏ qua.

Request tới /api/admin/users: chain 1 (@Order(1), pattern /api/admin/**) match → toàn bộ 15+ filter của chain 1 xử lý request. Chain 2 không được gọi.

Nếu chain 1 không match (ví dụ request tới /api/users): FilterChainProxy kiểm tra chain 2 (/**) — match → dùng chain 2. Nếu không có chain nào match, request bị để đi qua không qua security filter nào (không bị bảo vệ — đây là pitfall cần chú ý khi khai báo URL pattern thiếu).

Pattern phổ biến: chain chuyên dụng cho admin đặt @Order thấp (ưu tiên cao) với rule chặt hơn; catch-all chain /** đặt cuối. Quan trọng là chain catch-all phải tồn tại để bảo vệ endpoint không được liệt kê rõ ràng.

Bài tiếp theo: Authentication flow & SecurityContext

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