Backend

99 Ngày Spring — Ngày 41: Kiến trúc Spring Security

SSite Admin
6 tháng 09, 2026 6 phút đọc 1 lượt xem
99 Ngày Spring — Ngày 41: Kiến trúc Spring Security

Hôm nay mở một giai đoạn lớn và quan trọng bậc nhất của series: Spring Security. Trước khi viết bất kỳ dòng cấu hình nào, phải hiểu một điều quyết định toàn bộ phần còn lại — Security không nằm trong controller, nó là một chuỗi filter đứng chắn phía trước. Hiểu được mô hình đó thì hàng loạt chuyện khó hiểu tự sáng ra: vì sao @ExceptionHandler không bắt được lỗi 403, và vì sao breakpoint trong controller chẳng bao giờ dừng.

Sketchnote Ngày 41: Kiến trúc Spring Security — chuỗi servlet filter đứng trước DispatcherServlet, SecurityContext trong ThreadLocal, Authentication với authorities, phân biệt authentication 401 và authorization 403, bẫy hasRole và hasAuthority

Filter chain — Security đứng trước ứng dụng

Đây là mô hình tinh thần quan trọng nhất của cả giai đoạn. Spring Security cài một chuỗi servlet filter vào trước DispatcherServlet, nên mọi quyết định cho vào hay chặn lại đều xảy ra trước khi code của bạn chạy.

// Điều quan trọng nhất phải hiểu trước khi viết dòng cấu hình nào:
// Spring Security KHÔNG nằm trong controller. Nó là một CHUỖI SERVLET FILTER
// đứng TRƯỚC toàn bộ ứng dụng — request bị chặn từ lâu trước khi tới @RestController.

// Request → [Filter 1] → [Filter 2] → ... → [Filter n] → DispatcherServlet → Controller
//                ↓ chặn tại đây thì controller KHÔNG BAO GIỜ chạy

// Vài filter quan trọng, theo đúng thứ tự chúng chạy:
//   SecurityContextPersistenceFilter  khôi phục SecurityContext từ session
//   UsernamePasswordAuthenticationFilter  xử lý form login (POST /login)
//   BearerTokenAuthenticationFilter   đọc token JWT (Ngày 45)
//   ExceptionTranslationFilter        đổi exception bảo mật thành 401 / 403
//   AuthorizationFilter               chốt chặn cuối: đủ quyền chưa?

// Đây là lý do của rất nhiều chuyện khó hiểu với người mới:
//   - @ExceptionHandler KHÔNG bắt được lỗi bảo mật (lỗi xảy ra TRƯỚC controller)
//   - Breakpoint trong controller không bao giờ dừng khi bị 403
//   - CORS phải cấu hình trong Security, không phải chỉ ở @CrossOrigin

// Xem toàn bộ chuỗi filter thật của ứng dụng mình:
logging.level.org.springframework.security: DEBUG
  • Bị chặn ở filter thì controller không bao giờ được gọi — nên đừng tìm nguyên nhân 403 bằng cách đặt breakpoint trong controller.

  • @ExceptionHandler của bạn không bắt được lỗi bảo mật; muốn tùy biến phản hồi thì cấu hình AuthenticationEntryPointAccessDeniedHandler.

  • Bật log DEBUG cho org.springframework.security là cách nhanh nhất để nhìn thấy chuỗi filter thật và biết request chết ở đâu.

SecurityContext — ai đang gọi request này

Sau khi một filter xác thực xong, nó cất kết quả vào SecurityContext để mọi tầng phía sau dùng lại. Cơ chế lưu trữ là ThreadLocal — tiện, nhưng kéo theo một cái bẫy mà ai làm việc bất đồng bộ cũng gặp.

// Ba khái niệm gắn liền nhau, và tên gọi nói đúng vai trò của chúng:

// Authentication — "anh là ai, và đã chứng minh chưa?"
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
auth.getName();           // định danh (thường là username)
auth.getAuthorities();    // các quyền: ROLE_ADMIN, ROLE_USER, đọc:donhang...
auth.isAuthenticated();
auth.getPrincipal();      // đối tượng người dùng (thường là UserDetails — Ngày 43)

// SecurityContext — cái hộp chứa Authentication cho REQUEST HIỆN TẠI.
// SecurityContextHolder — giữ cái hộp đó trong ThreadLocal.

// ⚠️ ThreadLocal nghĩa là: nhảy sang luồng khác thì CONTEXT BIẾN MẤT.
@Async
public void chayNen() {
    SecurityContextHolder.getContext().getAuthentication();   // null! 💥
}
// Cách xử lý: DelegatingSecurityContextExecutor, hoặc truyền thông tin
// người dùng vào như một tham số bình thường.

// Cách lấy người dùng hiện tại trong controller — gọn hơn hẳn:
@GetMapping("/toi")
String toi(@AuthenticationPrincipal UserDetails u) { return u.getUsername(); }
  • Authentication mang hai thứ bạn cần nhất: danh tínhdanh sách quyền.

  • ThreadLocal nghĩa là qua luồng khác là mất: trong @Async hay thread pool tự tạo, context rỗng — hãy truyền thông tin người dùng như một tham số bình thường.

  • @AuthenticationPrincipal là cách gọn nhất để lấy người dùng hiện tại, thay vì gọi SecurityContextHolder rải rác khắp nơi.

Authentication so với authorization

Hai từ nghe giống nhau nhưng là hai câu hỏi khác hẳn, và lẫn lộn chúng là nguồn gốc của rất nhiều lỗ hổng: anh là ai khác với anh được làm gì.

// AUTHENTICATION và AUTHORIZATION là hai chuyện khác nhau, và lẫn lộn chúng
// là nguồn gốc của rất nhiều lỗ hổng:
//   Authentication = XÁC THỰC — "anh là ai?"        → sai thì 401 Unauthorized
//   Authorization  = PHÂN QUYỀN — "anh được làm gì?" → sai thì 403 Forbidden
// Nhớ nghịch lý đặt tên của HTTP: 401 thật ra nghĩa là "chưa xác thực",
// còn 403 mới là "đã biết anh là ai, nhưng không cho".

// Phân quyền làm được ở hai tầng:
// 1. Tầng URL — khai trong SecurityFilterChain (Ngày 42):
http.authorizeHttpRequests(a -> a
        .requestMatchers("/admin/**").hasRole("ADMIN")
        .requestMatchers("/api/**").authenticated()
        .anyRequest().permitAll());

// 2. Tầng METHOD — sát nghiệp vụ hơn, chặn được cả khi ai đó gọi từ chỗ khác:
@EnableMethodSecurity
@PreAuthorize("hasRole('ADMIN')")
void xoaDonHang(Long id) { }
@PreAuthorize("#id == authentication.principal.id")   // chỉ được sửa CHÍNH MÌNH
void capNhat(Long id) { }

// BẪY TÊN GỌI làm mất thời gian của rất nhiều người:
//   hasRole("ADMIN")      → tự động thêm tiền tố, tìm quyền "ROLE_ADMIN"
//   hasAuthority("ADMIN") → tìm ĐÚNG chuỗi "ADMIN", KHÔNG thêm gì
// Trong database lưu "ROLE_ADMIN" mà code viết hasRole("ROLE_ADMIN") là
// tìm "ROLE_ROLE_ADMIN" — và bạn ngồi tự hỏi vì sao admin bị 403.
  • Nhớ nghịch lý của HTTP: 401 thực chất là chưa xác thực, còn 403 mới là đã biết anh là ai nhưng không cho.

  • Phân quyền ở tầng URL thì bao quát, ở tầng method thì sát nghiệp vụ và vẫn có hiệu lực khi method được gọi từ chỗ khác — dùng cả hai.

  • Bẫy tên gọi: hasRole tự thêm tiền tố ROLE_ còn hasAuthority thì không. Đây là nguyên nhân số một của những cái 403 khó hiểu.

Bài tập nhỏ

  • Thêm spring-boot-starter-security vào một dự án trống rồi mở bất kỳ trang nào — quan sát form login tự hiện ra và mật khẩu ngẫu nhiên in trong log.

  • Bật log DEBUG cho Security và đếm xem có bao nhiêu filter đang chạy trước controller của bạn.

  • Đặt breakpoint trong controller rồi gọi một URL bị cấm — xác nhận nó không bao giờ dừng, và giải thích vì sao.

  • Viết một method @Async rồi in SecurityContextHolder.getContext().getAuthentication() bên trong để thấy nó null.

  • Lưu quyền ROLE_ADMIN trong database rồi viết hasRole("ROLE_ADMIN") — nhận 403, và tự tìm ra lý do.

Kết lại

Bốn ý gói lại hôm nay: Spring Security là một chuỗi servlet filter đứng trước ứng dụng, nên bị chặn nghĩa là controller không bao giờ chạy và @ExceptionHandler không bắt được; SecurityContext giữ Authentication của request hiện tại trong ThreadLocal — qua luồng khác là mất; authentication trả 401, authorization trả 403, và nên phân quyền ở cả tầng URL lẫn tầng method; còn hasRole tự thêm ROLE_ trong khi hasAuthority thì không — nhớ điều này là tiết kiệm được một buổi chiều. Ngày 42 ta bắt tay viết cấu hình thật: SecurityFilterChain — permitAll và authenticated, form login và httpBasic, cùng PasswordEncoder. Hẹn gặp lại!

S

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.

Bài viết liên quan

99 Ngày Java — Ngày 41: Exception tổng quan

Cây thừa kế Throwable và vì sao nó quyết định cách hành xử, ranh giới checked với unchecked cùng câu hỏi duy nhất để phân loại, cách đọc stack trace từ Caused by — và bốn kiểu bắt exception khiến người khác khổ.

6 thg 9, 20267 phút0
99 Ngày Spring — Ngày 40: Tổng kết tối ưu JPA

Checklist bảy vấn đề theo thứ tự đáng sửa trước: N+1 query và ba cách chữa, projection, keyset pagination, index khóa ngoại, transaction gọn, batch ghi — và nguyên tắc đo trước, cache cuối cùng.

5 thg 9, 20267 phút5
99 Ngày Java — Ngày 40: Wildcard & PECS

Vì sao List<Integer> không truyền được vào List<Number>, ? extends chỉ đọc và ? super chỉ ghi, quy tắc PECS giải thích luôn chữ ký của nửa bộ Collections — cùng giới hạn và lúc không nên dùng wildcard.

5 thg 9, 20268 phút5