Spring Security & Testing/SecurityFilterChain DSL — khai báo security bằng lambda Spring Security 6
4/20
Bài 4 / 20~12 phútSecurity Architecture & Filter ChainMiễn phí lượt xem

SecurityFilterChain DSL — khai báo security bằng lambda Spring Security 6

Spring Security 6 thay WebSecurityConfigurerAdapter bằng @Bean SecurityFilterChain + HttpSecurity lambda DSL. Bài này bóc cú pháp requestMatchers, authorizeHttpRequests, các DSL method phổ biến (csrf/cors/sessionManagement/oauth2), và lý do thiết kế: tại sao lambda DSL composable + type-safe hơn chained .and() cũ.

TL;DR: Spring Security 6 loại bỏ WebSecurityConfigurerAdapter — config security nay là một @Bean trả về SecurityFilterChain, khai báo bằng chuỗi lambda http.method(customizer -> ...) rồi return http.build(). requestMatchers() thay antMatchers() cũ, tự nhận diện Ant vs MVC pattern. Thứ tự rule quyết định: specific trước, .anyRequest() catch-all luôn đặt cuối. Các DSL method phổ biến — csrf, cors, sessionManagement, oauth2ResourceServer, exceptionHandling — mỗi thứ là một lambda block tách biệt, type-safe, dễ tái dùng.

Bài trước (Filter chain architecture) giải thích FilterChainProxy dispatch request đến đúng chain. Bài này bóc cú pháp DSL để khai báo chain đó từ scratch. Bài sau (Multiple chains & stateless) đào sâu @Order + securityMatcher cho nhiều chain song song.

1. Tại sao bỏ WebSecurityConfigurerAdapter

Trước Spring Security 6, cách duy nhất để config là kế thừa WebSecurityConfigurerAdapter rồi override method configure(HttpSecurity):

// Spring Security 5 — DEPRECATED, removed in 6
@Configuration
public class OldConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .and()
            .authorizeRequests()
                .antMatchers("/api/public/**").permitAll()
                .anyRequest().authenticated()
                .and()
            .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    }
}

Hai vấn đề cốt lõi của pattern này:

Vấn đề 1 — không composable. Toàn bộ config phải nằm trong một class và một method. Không thể tách "phần auth" và "phần session" vào các @Component riêng rồi ghép lại — override method là all-or-nothing.

Vấn đề 2 — .and() mơ hồ về scope. Mỗi lần gọi .csrf(), .authorizeRequests(), .sessionManagement() đều trả về một sub-configurer khác kiểu. .and() thoát sub-configurer để tiếp tục chain. IDE không thể kiểm tra compile-time bạn đang .and() từ đúng cấp độ — lỗi sai cấu trúc chỉ lộ ra lúc chạy.

Spring Security 6 giải quyết cả hai bằng lambda DSL: mỗi method trên HttpSecurity nhận một Customizer<T> (functional interface), lambda được typed chính xác về T, và config trả thành @Bean thuần — composable, test được, không inheritance.

2. Cấu trúc @Bean SecurityFilterChain

Skeleton tối thiểu của một security config Spring Security 6:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .anyRequest().authenticated()
            )
            .sessionManagement(s -> s
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .csrf(csrf -> csrf.disable());

        return http.build();  // always required
    }
}

Ba điểm cốt yếu:

  1. Method nhận HttpSecurity http qua Spring DI — framework inject sẵn.
  2. Mỗi tính năng security là một lambda block riêng: auth -> ..., s -> ..., csrf -> .... Lambda parameter là sub-configurer đúng kiểu — IDE autocomplete chính xác, compiler bắt lỗi sai method.
  3. return http.build() bắt buộc — trả về SecurityFilterChain bean. Quên dòng này sẽ compile được nhưng chain không được đăng ký, app không có security.
Pitfall — quên return http.build()

Method return type là SecurityFilterChain nhưng nếu bạn viết return null hoặc thiếu return, Spring không ném lỗi ngay — nó chỉ không đăng ký chain, toàn bộ request đi qua không bị filter. Luôn kết thúc bằng return http.build().

Chain khai báo bằng DSL này chạy đúng như đường đi đã vẽ ở bài 02: request đi qua các filter, tới AuthorizationFilter thì rule trong authorizeHttpRequests mới được đem ra so — cho qua, hoặc rẽ ra 401 nếu chưa xác thực, hoặc 403 nếu đã xác thực mà thiếu quyền.

3. requestMatchers + authorize rules

requestMatchers() thay thế antMatchers() cũ từ Spring Security 5. Phiên bản mới tự nhận diện URL pattern: nếu app dùng Spring MVC, matcher sẽ dùng PathPatternParser của MVC (chính xác hơn); nếu không có MVC, fallback sang Ant-style matching. Không cần khai báo riêng loại matcher.

http.authorizeHttpRequests(auth -> auth
    // Path pattern — Ant-style, wildcard ** = zero or more segments
    .requestMatchers("/api/public/**").permitAll()
    .requestMatchers("/api/auth/**", "/actuator/health").permitAll()

    // Method-specific: POST /api/projects chi admin moi tao
    .requestMatchers(HttpMethod.POST, "/api/projects").hasRole("ADMIN")
    .requestMatchers(HttpMethod.GET, "/api/projects/**").authenticated()

    // Role-based rules
    .requestMatchers("/api/admin/**").hasRole("ADMIN")
    .requestMatchers("/api/manager/**").hasAnyRole("ADMIN", "MANAGER")

    // Custom authority (khong prefix ROLE_)
    .requestMatchers("/api/reports").hasAuthority("reports:read")

    // Catch-all -- LUON dat cuoi
    .anyRequest().authenticated()
);

Thứ tự rule là thứ tự kiểm tra — first match wins. Spring duyệt từ trên xuống, rule đầu tiên khớp URL được áp dụng, các rule sau bị bỏ qua. Hệ quả: .anyRequest() phải luôn là rule cuối cùng, nếu đặt trước nó sẽ nuốt mọi request và các rule cụ thể phía sau không bao giờ được kiểm tra.

Cùng một request GET /api/admin/users từ user đã đăng nhập nhưng không có ROLE_ADMIN, chạy qua hai thứ tự rule: khi anyRequest authenticated đứng trước thì nó khớp ngay dòng 1 nên trả 200 OK và user thường đọc được dữ liệu admin, dòng hasRole ADMIN không bao giờ tới lượt; khi rule cụ thể đứng trước thì dòng 1 khớp và trả 403 vì thiếu ROLE_ADMIN

Đây không phải quy ước phong cách mà là một lỗ hổng thật: cả hai config đều biên dịch được, đều khởi động được, và Spring không ghi log nào cảnh báo.

Bảng đầy đủ các authorization expression:

ExpressionÝ nghĩa
permitAll()Cho phép tất cả, kể cả anonymous
denyAll()Từ chối tất cả
authenticated()Bất kỳ user đã xác thực
anonymous()Chỉ anonymous (chưa login)
fullyAuthenticated()Đã xác thực, không phải remember-me
hasRole("ADMIN")Có authority ROLE_ADMIN
hasAnyRole("USER", "ADMIN")Có ít nhất một trong các role
hasAuthority("orders:write")Có đúng authority này (không prefix ROLE_)
access(AuthorizationManager)Logic tùy biến qua custom AuthorizationManager
hasRole vs hasAuthority

hasRole("ADMIN") tự động thêm prefix ROLE_ — nó kiểm tra authority ROLE_ADMIN. hasAuthority("ROLE_ADMIN")hasRole("ADMIN") tương đương. Nếu authority không có prefix ROLE_ (ví dụ orders:write), phải dùng hasAuthority() thay vì hasRole().

4. Các DSL method phổ biến

HttpSecurity là object trung tâm — mỗi tính năng security gắn vào nó qua một lambda block độc lập, và các block không phụ thuộc thứ tự viết:

  • authorizeHttpRequests — rule requestMatchers
  • csrf — disable, hoặc CookieCsrfTokenRepository
  • corsCorsConfigurationSource
  • sessionManagementSTATELESS / IF_REQUIRED / NEVER
  • exceptionHandlingauthenticationEntryPoint / accessDeniedHandler
  • oauth2ResourceServerjwt, jwtAuthenticationConverter
  • headers — CSP, HSTS, frame options

4.1 CSRF

CSRF (Cross-Site Request Forgery) là tấn công dùng browser tự động gửi cookie session — kẻ tấn công dụ người dùng click link, browser đính kèm session cookie, server không phân biệt được. Spring Security bật CSRF protection mặc định.

// REST API stateless dung JWT -> disable CSRF
// JWT trong Authorization header, browser khong tu dinh kem -> khong co CSRF surface
http.csrf(csrf -> csrf.disable());

// Web app form-based -> giu CSRF, tinh chinh token repo
http.csrf(csrf -> csrf
    .ignoringRequestMatchers("/api/webhooks/**")       // webhook extern khong co CSRF
    .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
);

Nguyên tắc chọn: stateless API dùng JWT trong Authorization header thì disable CSRF — browser không tự đính kèm header cross-site nên không có CSRF surface. Web app dùng session cookie thì giữ CSRF vì cookie tự đính kèm.

4.2 Session management

http.sessionManagement(s -> s
    .sessionCreationPolicy(SessionCreationPolicy.STATELESS)    // REST API + JWT
    // .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)  // default, web app
);

STATELESS là cấu hình bắt buộc cho REST API dùng JWT — Spring Security không tạo và không đọc HTTP session. Auth được rebuild từ token mỗi request. Bài Multiple chains & stateless đào sâu trade-off của từng policy.

4.3 OAuth2 Resource Server (JWT)

Khi app nhận Bearer token từ client và cần validate:

http.oauth2ResourceServer(oauth2 -> oauth2
    .jwt(jwt -> jwt
        .jwtAuthenticationConverter(jwtAuthenticationConverter())
    )
);

@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
    JwtGrantedAuthoritiesConverter grantedConverter = new JwtGrantedAuthoritiesConverter();
    grantedConverter.setAuthoritiesClaimName("roles");   // claim chua roles trong token
    grantedConverter.setAuthorityPrefix("ROLE_");

    JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(grantedConverter);
    return converter;
}

BearerTokenAuthenticationFilter parse JWT mỗi request, verify signature + expiry, rồi build JwtAuthenticationToken đặt vào SecurityContext. Không cần session.

4.4 Exception handling

Mặc định Spring Security redirect 401 tới /login (trả HTML). REST API cần JSON:

http.exceptionHandling(eh -> eh
    .authenticationEntryPoint((req, res, ex) -> {
        // 401 -- unauthenticated
        res.setStatus(HttpStatus.UNAUTHORIZED.value());
        res.setContentType("application/problem+json");
        res.getWriter().write("""
            {"type":"about:blank","title":"Unauthorized","status":401}
            """);
    })
    .accessDeniedHandler((req, res, ex) -> {
        // 403 -- authenticated but wrong role
        res.setStatus(HttpStatus.FORBIDDEN.value());
        res.setContentType("application/problem+json");
        res.getWriter().write("""
            {"type":"about:blank","title":"Forbidden","status":403}
            """);
    })
);

authenticationEntryPoint xử lý 401 (chưa xác thực). accessDeniedHandler xử lý 403 (đã xác thực nhưng thiếu quyền). Cả hai cần custom cho REST API để frontend SPA nhận JSON thay vì redirect HTML.

4.5 CORS

CORS (Cross-Origin Resource Sharing) là cơ chế browser kiểm tra xem origin của frontend (https://app.example.com) có được phép gọi API (https://api.example.com) không. Spring Security tích hợp với Spring MVC CORS config qua CorsConfigurationSource:

http.cors(cors -> cors.configurationSource(corsConfigurationSource()));

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedOrigins(List.of("https://olhub.org"));
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
    config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
    config.setAllowCredentials(true);
    config.setMaxAge(3600L);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/api/**", config);
    return source;
}

5. Tại sao lambda DSL — cơ chế composable

Lambda DSL không chỉ là syntax mới. Nó thay đổi cơ bản cách config được tổ chức.

Composable qua Customizer bean. Mỗi lambda block có thể trích thành Customizer<T> bean riêng — inject, test, tái dùng qua nhiều chain:

// Trich thanh bean rieng -- test duoc, tai dung duoc
@Component
public class StatelessSessionCustomizer
        implements Customizer<SessionManagementConfigurer<HttpSecurity>> {

    @Override
    public void customize(SessionManagementConfigurer<HttpSecurity> s) {
        s.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    }
}

// Inject va dung trong nhieu chain
@Bean
public SecurityFilterChain apiChain(HttpSecurity http,
        StatelessSessionCustomizer sessionCustomizer) throws Exception {
    http
        .sessionManagement(sessionCustomizer)
        .authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
    return http.build();
}

WebSecurityConfigurerAdapter không cho phép điều này — config bị lock trong một class override duy nhất.

Type-safe. Lambda parameter csrf -> ... có kiểu CsrfConfigurer<HttpSecurity>. IDE biết chính xác method nào hợp lệ. .and() style cũ trả về HttpSecurity sau mỗi sub-configurer — compiler không phân biệt được bạn đang gọi method của HttpSecurity hay đang trong một sub-configurer chưa thoát.

Customizer.withDefaults() — default config không cần lambda:

http.httpBasic(Customizer.withDefaults());   // apply Spring default Basic auth config
http.formLogin(Customizer.withDefaults());   // apply Spring default form login

Thay vì http.httpBasic() rỗng (deprecated), withDefaults() rõ ràng về ý định: "dùng config mặc định của Spring cho feature này".

6. Pitfall thường gặp

Pitfall 1 — .anyRequest() không phải rule cuối:

// SAI -- anyRequest nuot tat ca request truoc khi rule cu the chay
http.authorizeHttpRequests(auth -> auth
    .anyRequest().authenticated()
    .requestMatchers("/api/admin/**").hasRole("ADMIN")   // never reached
);
// DUNG -- cu the truoc, catch-all sau
http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/public/**").permitAll()
    .requestMatchers("/api/admin/**").hasRole("ADMIN")
    .anyRequest().authenticated()
);

Pitfall 2 — WebSecurityConfigurerAdapter vẫn còn trong classpath:

Spring Security 6 đã xóa class này. Nếu bạn thấy lỗi Cannot resolve symbol 'WebSecurityConfigurerAdapter' sau khi upgrade, đây là tín hiệu cần migrate sang @Bean SecurityFilterChain. OpenRewrite có recipe tự động hóa phần lớn việc này: org.openrewrite.java.spring.security6.RemoveFilterSecurityInterceptorOncePerRequest.

Pitfall 3 — nhiều chain không có securityMatcher, chain đầu nuốt tất cả:

// SAI -- chain 1 khong co securityMatcher -> match moi request -> chain 2 khong chay
@Bean @Order(1)
public SecurityFilterChain adminChain(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests(auth -> auth.anyRequest().hasRole("ADMIN"));
    return http.build();
}

@Bean @Order(2)
public SecurityFilterChain publicChain(HttpSecurity http) throws Exception { // never used
    http.authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
    return http.build();
}
// DUNG -- chain 1 co securityMatcher de gioi han pham vi
@Bean @Order(1)
public SecurityFilterChain adminChain(HttpSecurity http) throws Exception {
    http
        .securityMatcher("/api/admin/**")    // chi xu ly URL nay
        .authorizeHttpRequests(auth -> auth.anyRequest().hasRole("ADMIN"));
    return http.build();
}

Bài Multiple chains & stateless đào sâu pattern nhiều chain với @Order + securityMatcher.

Pitfall 4 — hasRole nhầm prefix:

// SAI -- authority trong DB la "ROLE_ADMIN", hasRole tu them prefix -> kiem tra "ROLE_ROLE_ADMIN"
.requestMatchers("/api/admin/**").hasRole("ROLE_ADMIN")

// DUNG -- hasRole tu them ROLE_ prefix, chi viet ten role
.requestMatchers("/api/admin/**").hasRole("ADMIN")
// Tuong duong voi:
.requestMatchers("/api/admin/**").hasAuthority("ROLE_ADMIN")

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

  • Bài 01 — Filter chain architecture: FilterChainProxy dispatch request đến đúng SecurityFilterChain theo thứ tự — bài này giải thích runtime bên dưới; DSL ở bài hiện tại khai báo chain đó ra sao.
  • Bài 04 — Multiple chains & stateless: đào sâu @Order + securityMatcher khi app cần nhiều chain song song (admin JWT vs public permitAll vs web session), và cơ chế SessionCreationPolicy.STATELESS với JWT.
  • WebSecurityConfigurerAdapter bị xóa là một phần của Spring Security 6 migration — xem Spring Security Migration Guide để hiểu toàn bộ breaking changes, không chỉ riêng DSL.

Tóm tắt

  • Spring Security 6 xóa WebSecurityConfigurerAdapter — config bằng @Bean SecurityFilterChain, nhận HttpSecurity, return http.build().
  • Lambda DSL: mỗi tính năng là một lambda block http.feature(x -> x.setting(...)) — type-safe, composable thành Customizer bean riêng.
  • requestMatchers() thay antMatchers(): tự nhận diện Ant vs MVC pattern, hỗ trợ HttpMethod-specific.
  • Thứ tự rule — first match wins: specific trước, .anyRequest() catch-all luôn cuối.
  • Các DSL method thường dùng: csrf, cors, sessionManagement, oauth2ResourceServer, exceptionHandling, headers.
  • Stateless REST API: csrf.disable() + sessionCreationPolicy(STATELESS) + oauth2ResourceServer.jwt(...) + custom exceptionHandling trả JSON.

Tự kiểm tra

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Spring Security 6 bỏ WebSecurityConfigurerAdapter vì lý do gì? Nêu 2 vấn đề của pattern extends-override cũ.
  2. Q2
    Config sau có lỗi gì? Endpoint nào bị broken và tại sao?

    http.authorizeHttpRequests(auth -> auth
    .anyRequest().authenticated()
    .requestMatchers("/api/public/**").permitAll()
    .requestMatchers("/api/admin/**").hasRole("ADMIN")
    )
  3. Q3
    hasRole("ADMIN")hasAuthority("ROLE_ADMIN") có tương đương không? Khi nào dùng hasAuthority() thay vì hasRole()?
  4. Q4
    Vì sao REST API stateless dùng JWT nên gọi csrf(csrf -> csrf.disable())? Giải thích theo cơ chế tấn công CSRF.
  5. Q5
    Viết skeleton config cho REST API stateless với JWT: cho phép /api/auth/** public, /api/admin/** chỉ ADMIN, còn lại cần xác thực; trả JSON cho 401/403.

Bài tiếp theo: Multiple chains & stateless config

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

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