Chặng bảo mật bắt đầu. 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.

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. 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 AuthenticationExceptionAccessDeniedException 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.

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 UserDetailsServicePasswordEncoder; 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.

Thử 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 — và Spring Security không giấu nó, chỉ là không ai nghĩ tới việc in ra.

Ngày mai: mã hoá mật khẩu — và vì sao BCrypt bỏ qua mọi ký tự từ byte thứ 73.