Hình dung Spring Security như một bộ lọc nước nhiều tầng: nước chảy qua một dãy lõi cố định, mỗi lõi lọc đúng một thứ, và sai thứ tự là hỏng cả dãy. Nó đúng nghĩa đen là một chuỗi bộ lọc. Chặng bảo mật bắt đầu, và bài này về chuyện xảy ra ngay khi bạn thêm phụ thuộc.
Một dòng, và mọi thứ bị khoá
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
Using generated security password: 10d7bf9c-0aef-44dc-b060-372233f74670
GET /cong-khai (không đăng nhập) -> 401
với basic auth user:<mk sinh ra> -> 200
Không viết dòng cấu hình nào. Spring Boot khoá mọi endpoint, tạo một người dùng tên user, và in mật khẩu ngẫu nhiên ra console. Cái van cuối mặc định đóng — không gì chảy ra cho tới khi bạn mở đường.
Mặc định này đúng: an toàn trước, mở ra sau. Bạn phải khai tường minh cái gì công khai, thay vì vô tình để lộ vì quên.
Mật khẩu chỉ in khi bạn không khai UserDetailsService nào. Thấy nó trong log sản xuất nghĩa là bạn chưa cấu hình xác thực thật.
Mười lăm bộ lọc
1 DisableEncodeUrlFilter
2 WebAsyncManagerIntegrationFilter
3 SecurityContextHolderFilter
4 HeaderWriterFilter
5 CsrfFilter
6 LogoutFilter
7 UsernamePasswordAuthenticationFilter
8 DefaultLoginPageGeneratingFilter
9 DefaultLogoutPageGeneratingFilter
10 BasicAuthenticationFilter
11 RequestCacheAwareFilter
12 SecurityContextHolderAwareRequestFilter
13 AnonymousAuthenticationFilter
14 ExceptionTranslationFilter
15 AuthorizationFilter
Đây là toàn bộ Spring Security: một chuỗi Filter của Servlet, đúng loại đã nói ở bài 17 — một dãy lõi lọc, mỗi lõi một việc. Không có phép màu nào.
Sáu cái đáng hiểu:
SecurityContextHolderFilter (3) nạp SecurityContext từ session vào một ThreadLocal, và dọn nó ở cuối. Mọi cảnh báo ThreadLocal ở bài 78 sê-ri Java áp dụng ở đây — đó là lý do nó phải nằm gần đầu chuỗi.
HeaderWriterFilter (4) ghi các header bảo mật. Nói ở mục dưới.
CsrfFilter (5) kiểm token CSRF. Bài 42.
UsernamePasswordAuthenticationFilter (7) xử lý form đăng nhập. Nó chỉ nhìn POST /login; mọi request khác đi qua không làm gì.
ExceptionTranslationFilter (14) bắt AuthenticationException và AccessDeniedException từ phía sau nó, rồi đổi thành 401 hoặc 403, hoặc chuyển hướng tới trang đăng nhập.
AuthorizationFilter (15) — cuối cùng, và là chỗ quyết định cho qua hay không.
Thứ tự này quan trọng: ExceptionTranslationFilter đứng trước AuthorizationFilter để bắt được ngoại lệ của nó. Đảo lại là người dùng nhận 500 thay vì trang đăng nhập — đúng như lắp lõi lọc sai thứ tự thì hỏng cả dãy.
Năm header bảo mật tự bật
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
nosniff chặn trình duyệt tự đoán kiểu nội dung — quan trọng với tệp người dùng tải lên (bài 19).
X-XSS-Protection: 0 trông như tắt bảo vệ, và đúng là vậy — có chủ ý. Bộ lọc XSS cũ của trình duyệt tự nó tạo ra lỗ hổng, nên khuyến nghị hiện nay là tắt hẳn và dùng CSP.
X-Frame-Options: DENY chặn clickjacking. Cần nhúng trang vào iframe của chính mình thì đổi sang SAMEORIGIN.
Cache-Control: no-store cho mọi phản hồi. Đúng cho trang có dữ liệu người dùng, nhưng nó cũng áp lên CSS, JS và ảnh — nên trình duyệt không cache được gì.
Cách chữa là đưa tài nguyên tĩnh ra khỏi chuỗi lọc:
@Bean WebSecurityCustomizer boQuaTinh() {
return web -> web.ignoring().requestMatchers("/css/**", "/js/**", "/anh/**");
}
Blog này làm đúng vậy, kèm URL có mã băm nội dung nên cache một năm mà vẫn cập nhật ngay khi sửa file.
CSP không tự bật — phải khai tay. Bài 45.
Cấu hình
@Configuration
@EnableWebSecurity
class CauHinhBaoMat {
@Bean
SecurityFilterChain chuoi(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(a -> a
.requestMatchers("/", "/bai/**", "/css/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.POST, "/api/**").authenticated()
.anyRequest().authenticated()) // BẮT BUỘC, đặt cuối
.formLogin(f -> f.loginPage("/login").permitAll())
.logout(l -> l.logoutSuccessUrl("/"))
.build();
}
}
Thứ tự trong authorizeHttpRequests quyết định. Luật đầu tiên khớp sẽ thắng, nên đi từ cụ thể tới chung. Đặt anyRequest().permitAll() lên trước là mọi luật sau vô nghĩa.
Luôn kết bằng anyRequest(). Thiếu nó, request không khớp luật nào sẽ ném lỗi lúc chạy.
Từ Spring Security 6, WebSecurityConfigurerAdapter đã bị bỏ hẳn — khai SecurityFilterChain là cách duy nhất.
Nhiều chuỗi lọc
Ứng dụng vừa có API vừa có trang web cần hai chuỗi:
@Bean @Order(1)
SecurityFilterChain choApi(HttpSecurity http) throws Exception {
return http
.securityMatcher("/api/**") // CHỈ áp cho /api/**
.csrf(c -> c.disable()) // API không trạng thái
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
.httpBasic(Customizer.withDefaults())
.authorizeHttpRequests(a -> a.anyRequest().authenticated())
.build();
}
@Bean @Order(2)
SecurityFilterChain choWeb(HttpSecurity http) throws Exception {
return http.formLogin(Customizer.withDefaults())
.authorizeHttpRequests(a -> a.anyRequest().authenticated()).build();
}
securityMatcher là dòng quyết định: chuỗi đầu chỉ áp cho /api/**. Thiếu nó thì chuỗi @Order(1) nuốt hết mọi request và chuỗi thứ hai không bao giờ chạy.
Blog này dùng đúng mẫu này: /api/** xác thực bằng X-API-Key và miễn CSRF, phần còn lại dùng form đăng nhập.
Ba cái bẫy đã gặp
Trang /error/403 phải là @RequestMapping, không phải @GetMapping. Security forward sang đó và giữ nguyên method của request gốc, nên một POST bị từ chối sẽ trả 405 thay vì 403. MockMvc không bắt được — chỉ chạy thật mới lộ.
Chỉ khai một DaoAuthenticationProvider. Spring Boot đã tự dựng một cái từ bean UserDetailsService và PasswordEncoder; khai thêm thì Security bỏ qua UserDetailsService khi tự cấu hình và cảnh báo ở mỗi lần khởi động, trong khi hành vi đăng nhập không đổi.
Có Security thì cấu hình CORS phải nằm trong chuỗi lọc. Bài 18 đã nói: addCorsMappings một mình không đủ, vì Security chạy trước DispatcherServlet và chặn preflight OPTIONS trước khi cấu hình MVC kịp lên tiếng.
Nếu chỉ làm một thứ sau bài này, bắt chính dãy lọc của bạn tự khai ra, trong ba mươi giây:
@Autowired FilterChainProxy chuoi;
chuoi.getFilterChains().forEach(c -> c.getFilters()
.forEach(f -> System.out.println(f.getClass().getSimpleName())));
Hoặc bật logging.level.org.springframework.security: DEBUG và xem danh sách lúc khởi động. Nhìn thấy chuỗi lọc thật của mình là cách nhanh nhất để hiểu vì sao một request bị chặn — Spring Security không giấu nó, chỉ là không ai nghĩ tới việc in ra.
Mẫu số chung
An-toàn-mặc-định — khoá hết, bắt buộc mở từng đường một cách tường minh — là một nguyên tắc nền, không phải nét riêng của Spring: tường lửa mặc định chặn, IAM của AWS từ chối trừ khi được cấp, một cấu hình CORS/CSP lành mạnh là danh-sách-cho-phép. Lý do nằm ở chi phí bất đối xứng: quên một luật cho phép chỉ ra một lỗi 401 nhìn thấy ngay và sửa trong một phút, còn quên một luật từ chối là một vụ lộ dữ liệu âm thầm — nên mặc định phải hỏng theo kiểu đóng và ồn ào. Đây đúng là mặt ngược của những lỗi im lặng mà bài validation và bài đánh phiên bản cảnh báo, và cố ý như vậy.
Điều thứ hai: bảo mật nên được dựng thành một đường ống gồm các chặng một-việc, có thứ tự, và thứ tự là một phần của tính đúng. Mười lăm bộ lọc của Spring chỉ là mẫu servlet-filter/middleware (bài 17) áp cho xác thực — cùng hình dạng với middleware auth của Express/Django/Rails, Rack/WSGI, chuỗi lọc của một API gateway. Mỗi chặng làm đúng một việc (nạp ngữ cảnh, CSRF, ghi header, dịch ngoại lệ, phân quyền) và trình tự của chúng mã hoá các phụ thuộc: bộ dịch-ngoại-lệ phải đứng trước bộ ra-quyết-định, nếu không một cú từ chối biến thành 500; bộ nạp-ngữ-cảnh phải chạy đầu và dọn cuối. Dựng bảo mật thành các chặng có-thứ-tự, soi-được-từng-cái — thay vì một phương thức rối bời — chính là thứ khiến nó kiểm toán được; và khi một request bị chặn một cách khó hiểu, nước đi luôn là một: in cái chuỗi ra rồi đọc.
Ngày mai: mã hoá mật khẩu — và vì sao BCrypt bỏ qua mọi ký tự từ byte thứ 73.