99 Ngày Spring — Ngày 42: Cấu hình SecurityFilterChain
Ngày 41 ta hiểu mô hình; hôm nay viết cấu hình thật. Từ Spring Security 6, cách cấu hình đã đổi hẳn sang bean và lambda — WebSecurityConfigurerAdapter bị xóa hẳn, nên phần lớn code cũ tìm được trên mạng không còn chạy. Ba thứ cần nắm: luật khớp theo thứ tự khai báo, PasswordEncoder và vì sao tuyệt đối không tự nghĩ cách băm mật khẩu, cùng hai chỗ hay bị làm sai nhất là CSRF và CORS.

SecurityFilterChain và luật thứ tự
Cấu hình ngày nay là một bean trả về SecurityFilterChain, khai bằng chuỗi lambda. Điều quan trọng nhất không nằm ở cú pháp mà ở một luật: các rule được duyệt theo đúng thứ tự bạn viết, và dừng lại ở cái đầu tiên khớp.
// Ngày 41 hiểu mô hình, hôm nay viết cấu hình thật.
// Từ Spring Security 6, mọi thứ là bean + lambda — WebSecurityConfigurerAdapter
// đã bị XÓA HẲN, nên code cũ trên mạng phần lớn không còn dùng được.
@Configuration
@EnableWebSecurity
class SecurityConfig {
@Bean
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(a -> a
.requestMatchers("/", "/dang-ky", "/css/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.GET, "/api/sanpham/**").permitAll()
.anyRequest().authenticated()) // ❗ LUÔN đặt CUỐI CÙNG
.formLogin(f -> f
.loginPage("/dang-nhap").permitAll()
.defaultSuccessUrl("/trang-chu"))
.logout(l -> l.logoutSuccessUrl("/"))
.build();
}
}
// LUẬT QUAN TRỌNG NHẤT: rule khớp theo THỨ TỰ KHAI BÁO, dừng ở cái ĐẦU TIÊN khớp.
// Đặt anyRequest().authenticated() lên trên là mọi rule dưới nó thành vô nghĩa —
// may là Spring 6 phát hiện và ném lỗi lúc khởi động thay vì im lặng.
// Nguyên tắc: đi từ CỤ THỂ đến TỔNG QUÁT, và luôn kết bằng anyRequest().Đi từ cụ thể đến tổng quát, và luôn kết thúc bằng
anyRequest()— đặt nó lên trên là mọi rule bên dưới thành vô nghĩa.May mắn là Spring 6 phát hiện rule không thể khớp và ném lỗi ngay lúc khởi động, thay vì để bạn phát hiện trên production.
Gặp code dùng
WebSecurityConfigurerAdapterthì biết ngay là hướng dẫn cũ — đã bị xóa khỏi Security 6.
PasswordEncoder — nơi không được sáng tạo
Đây là bean bắt buộc, và cũng là chỗ mà mọi ý tưởng tự chế đều sai. Băm mật khẩu có một yêu cầu ngược đời so với mọi thứ khác trong lập trình: nó phải chậm một cách có chủ đích.
// PasswordEncoder — bean BẮT BUỘC, và là chỗ không được phép sáng tạo.
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); // mặc định strength 10 là đủ tốt
}
// Vì sao KHÔNG bao giờ tự nghĩ cách băm mật khẩu:
// - MD5 / SHA-1: vỡ từ lâu, GPU thử hàng tỷ tổ hợp mỗi giây
// - SHA-256 trần: quá NHANH — mà băm mật khẩu cần CHẬM một cách có chủ đích
// - BCrypt: chậm có kiểm soát, và TỰ SINH salt cho từng mật khẩu
// Băm cùng một mật khẩu hai lần bằng BCrypt cho hai chuỗi KHÁC nhau —
// nhưng matches() vẫn nhận đúng, vì salt nằm ngay trong chuỗi kết quả.
// So sánh mật khẩu thì DÙNG encoder, đừng bao giờ so bằng equals:
encoder.matches(matKhauThoNguoiDungGo, matKhauDaBamTrongDb); // ✅
matKhauThoNguoiDungGo.equals(matKhauDaBamTrongDb); // ❌ luôn sai
// Nếu hệ thống cũ dùng thuật toán khác, DelegatingPasswordEncoder cho phép
// chuyển dần: mật khẩu trong DB mang tiền tố {bcrypt}, {noop}, {pbkdf2}...
@Bean
PasswordEncoder encoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}Nhanh là điểm yếu:
SHA-256trần quá nhanh nên GPU thử được hàng tỷ tổ hợp mỗi giây. BCrypt cố tình chậm.BCrypt tự sinh salt cho từng mật khẩu, nên cùng một mật khẩu băm hai lần ra hai chuỗi khác nhau — và
matchesvẫn nhận đúng.Đừng bao giờ so mật khẩu bằng
equals— luôn đi quaencoder.matches.DelegatingPasswordEncodercho phép chuyển dần từ hệ thống cũ nhờ tiền tố{bcrypt}ngay trong chuỗi lưu trong database.
Chọn kiểu đăng nhập, và CSRF với CORS
Cách xác thực phải chọn theo loại client, không theo thói quen. Và hai thứ hay bị làm sai nhất — cũng là hai thứ hay bị khuyên sai nhất trên mạng — là CSRF và CORS.
// FORM LOGIN hay HTTP BASIC hay STATELESS? Chọn theo loại client.
// Web truyền thống, có trình duyệt và session → formLogin
.formLogin(f -> f.loginPage("/dang-nhap"))
// API cho máy gọi máy, hoặc script nội bộ → httpBasic (đơn giản, phải có HTTPS)
.httpBasic(Customizer.withDefaults())
// API cho ứng dụng di động / SPA → stateless, không session, dùng JWT (Ngày 44–45)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// ═══ CSRF — chỗ gây bối rối nhiều nhất cho người mới ═══
// CSRF bảo vệ chống việc trình duyệt TỰ ĐỘNG gửi cookie sang site khác.
// - Có session + cookie (web truyền thống) → BẬT CSRF (mặc định đã bật)
// - Stateless + token trong header Authorization → tắt được, vì trình duyệt
// không tự gửi header đó đi đâu cả
.csrf(c -> c.disable()) // CHỈ khi thật sự stateless — đừng tắt cho tiện
// Tắt CSRF trên một web dùng session là mở toang cửa. Rất nhiều hướng dẫn
// trên mạng bảo "tắt đi cho đỡ vướng" — đó là lời khuyên nguy hiểm.
// ═══ CORS — phải khai TRONG Security ═══
.cors(Customizer.withDefaults()) // rồi khai CorsConfigurationSource bean
// Chỉ dùng @CrossOrigin trên controller là KHÔNG đủ: request bị chặn ở tầng
// filter từ trước khi tới controller (đúng mô hình của Ngày 41).Web có session dùng
formLogin; API máy gọi máy dùnghttpBasickèm HTTPS; ứng dụng di động và SPA thì stateless với JWT (Ngày 44–45).Đừng tắt CSRF cho tiện: tắt nó trên một web dùng session là mở toang cửa. Chỉ tắt khi thật sự không có cookie phiên.
CORS phải khai trong Security — chỉ đặt
@CrossOrigintrên controller là không đủ, vì request bị chặn ở tầng filter từ trước.
Bài tập nhỏ
Viết một
SecurityFilterChainvớianyRequest().authenticated()đặt đầu tiên — đọc lỗi khởi động mà Spring 6 ném ra.Băm cùng một mật khẩu hai lần bằng
BCryptPasswordEncodervà in ra: hai chuỗi khác nhau, nhưngmatchesđều đúng.Thử so mật khẩu bằng
equalsđể xác nhận nó luôn sai, rồi sửa lại bằngmatches.Bật
httpBasicrồi gọi API bằngcurl -u user:pass— và xem chuyện gì xảy ra khi bỏ phần xác thực đi.Gọi một endpoint POST từ trang HTML khác khi CSRF đang bật, quan sát bị chặn, rồi hiểu vì sao lời khuyên "tắt CSRF" lại nguy hiểm.
Kết lại
Bốn ý gói lại hôm nay: cấu hình Security 6 là bean cộng lambda, và luật quan trọng nhất là rule khớp theo thứ tự khai báo nên phải đi từ cụ thể đến tổng quát, kết bằng anyRequest(); PasswordEncoder là bean bắt buộc và BCrypt cố tình chậm cùng tự sinh salt — so mật khẩu luôn qua matches; chọn formLogin, httpBasic hay stateless là chọn theo loại client; và đừng tắt CSRF cho tiện, còn CORS thì phải khai trong Security chứ không chỉ ở controller. Ngày 43 ta thay người dùng cứng trong bộ nhớ bằng người dùng thật: UserDetailsService, DaoAuthenticationProvider và BCrypt trên dữ liệu thật. Hẹn gặp lại!
Site Admin
Engineer and writer. Building things with TypeScript and distributed systems.
Bình luận (0)
Bạn cần đăng nhập bằng Google để bình luận.
Hãy là người bình luận đầu tiên.


