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.

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: DEBUGBị 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.
@ExceptionHandlercủ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ìnhAuthenticationEntryPointvàAccessDeniedHandler.Bật log
DEBUGchoorg.springframework.securitylà 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(); }Authenticationmang hai thứ bạn cần nhất: danh tính và danh sách quyền.ThreadLocal nghĩa là qua luồng khác là mất: trong
@Asynchay 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.@AuthenticationPrincipallà cách gọn nhất để lấy người dùng hiện tại, thay vì gọiSecurityContextHolderrả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:
401thực chất là chưa xác thực, còn403mớ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:
hasRoletự thêm tiền tốROLE_cònhasAuthoritythì 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-securityvà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
DEBUGcho 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
@Asyncrồi inSecurityContextHolder.getContext().getAuthentication()bên trong để thấy nó null.Lưu quyền
ROLE_ADMINtrong database rồi viếthasRole("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!
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.


