99 Ngày Spring — Ngày 47: Phân quyền — role & authority
Bốn ngày vừa rồi ta trả lời câu "bạn là ai". Hôm nay là câu thứ hai, và là câu quyết định phần lớn lỗi bảo mật trong đời thực: "bạn được làm gì". Điều đáng ngạc nhiên là Spring Security chỉ có một khái niệm duy nhất cho việc này — GrantedAuthority, và nó chỉ là một chuỗi. Role không phải một kiểu riêng: nó chỉ là authority có tiền tố ROLE_, và hiểu đúng điều đó gỡ được cái lỗi 403 khó chịu nhất của người mới.

Role chỉ là authority có tiền tố
Nhiều người mất cả buổi vì cái 403 không lý do, và nguyên nhân gần như luôn là hasRole("ROLE_ADMIN"). hasRole tự thêm tiền tố ROLE_, nên viết như trên là bạn đang yêu cầu quyền tên ROLE_ROLE_ADMIN — thứ không ai có. Spring không cảnh báo gì cả.
// XÁC THỰC ≠ PHÂN QUYỀN — hai câu hỏi khác nhau, đừng trộn.
// Authentication: BẠN LÀ AI? (ngày 43–46 lo việc này)
// Authorization: BẠN ĐƯỢC LÀM GÌ? (hôm nay)
// Spring Security chỉ có MỘT khái niệm cho vế thứ hai: GrantedAuthority.
// Nó là một CHUỖI. Chỉ vậy thôi. Không có kiểu "Role" riêng nào cả.
public interface GrantedAuthority {
String getAuthority(); // "ROLE_ADMIN" · "ORDER_WRITE" · "user:delete"
}
// ⭐ "Role" và "authority" trong Spring Security KHÔNG phải hai thứ khác nhau.
// Role CHỈ LÀ một authority có tiền tố "ROLE_". Toàn bộ khác biệt nằm ở đó.
// Vì thế hai dòng này TƯƠNG ĐƯƠNG HOÀN TOÀN:
.requestMatchers("/admin/**").hasRole("ADMIN") // tự thêm "ROLE_"
.requestMatchers("/admin/**").hasAuthority("ROLE_ADMIN") // viết đủ bằng tay
// ❗ ĐÂY LÀ LỖI PHỔ BIẾN NHẤT của người mới:
.hasRole("ROLE_ADMIN") // ❌ Spring biến nó thành "ROLE_ROLE_ADMIN" → luôn 403
// Không có cảnh báo nào. Bạn chỉ thấy 403 và ngồi đoán.
// Khi nạp user, tự bạn quyết định chuỗi đó:
new SimpleGrantedAuthority("ROLE_" + vaiTro.getTen()); // → dùng với hasRole
new SimpleGrantedAuthority("ORDER_WRITE"); // → dùng với hasAuthorityGrantedAuthoritychỉ là một chuỗi — không có kiểuRolenào trong Spring Security cả.hasRole("ADMIN")vàhasAuthority("ROLE_ADMIN")tương đương hoàn toàn; chọn một lối viết rồi giữ nhất quán trong cả dự án.hasRole("ROLE_ADMIN")là lỗi phổ biến nhất — nó thànhROLE_ROLE_ADMINvà luôn trả 403, im lặng.
Thiết kế: role gom quyền, và quyền theo dữ liệu
Đây mới là phần đáng giá. Câu hỏi thật không phải hasRole hay hasAuthority, mà là: khi sếp bảo "thêm vai trò Trưởng ca, được xem đơn và xuất báo cáo", bạn phải sửa code hay chỉ thêm một dòng trong bảng phân quyền? Cách bạn thiết kế hôm nay quyết định câu trả lời đó.
// ROLE hay AUTHORITY — không phải chuyện cú pháp, mà là chuyện THIẾT KẾ.
// ① CHỈ DÙNG ROLE — hệ nhỏ thì ổn, lớn lên thì đau:
if (hasRole("ADMIN") || hasRole("MANAGER") || hasRole("SUPERVISOR")) ...
// Thêm một vai trò mới = đi sửa mọi chỗ kiểm tra. Rất dễ sót.
// ② ROLE GOM QUYỀN (permission-based) — cách nên dùng khi hệ lớn dần:
// Role là cái NGƯỜI DÙNG có: ADMIN, MANAGER, STAFF
// Authority là VIỆC CỤ THỂ: ORDER_READ, ORDER_WRITE, REPORT_EXPORT
// ADMIN → mọi authority
// MANAGER → ORDER_READ, ORDER_WRITE, REPORT_EXPORT
// STAFF → ORDER_READ
@PreAuthorize("hasAuthority('ORDER_WRITE')") // ⭐ code kiểm tra VIỆC, không kiểm tra AI
public void capNhatDonHang(...) { }
// Thêm vai trò mới chỉ là thêm dòng trong bảng phân quyền — KHÔNG đụng vào code.
// ③ PHÂN QUYỀN THEO DỮ LIỆU — thứ role không bao giờ trả lời được:
// "Được sửa đơn hàng CỦA CHÍNH MÌNH" không phải một vai trò.
@PreAuthorize("#donHang.chuSoHuu == authentication.name")
public void sua(@P("donHang") DonHang donHang) { }
@PostAuthorize("returnObject.chuSoHuu == authentication.name") // kiểm tra SAU khi chạy
public DonHang xem(Long id) { }
// ❗ @PostAuthorize chạy XONG method rồi mới chặn — nếu method đã ghi vào DB
// thì việc ghi ĐÃ XẢY RA. Chỉ dùng cho method chỉ đọc.Code nên kiểm tra việc, không kiểm tra người:
hasAuthority('ORDER_WRITE')chứ không phải liệt kê ba vai trò bằng||.Role là cái người dùng có, authority là việc cụ thể — role gom authority lại, và việc gom đó nằm trong dữ liệu chứ không trong code.
"Sửa đơn hàng của chính mình" không phải một vai trò — đó là phân quyền theo dữ liệu, và
@PreAuthorizevới biểu thức SpEL làm được.@PostAuthorizechạy sau method — nếu method đã ghi vào DB thì việc ghi đã xảy ra rồi. Chỉ dùng cho method chỉ đọc.
Method security và cái bẫy proxy
Hai điều làm hỏng method security nhiều nhất đều im lặng: quên @EnableMethodSecurity thì mọi @PreAuthorize bị bỏ qua không một lời cảnh báo; và gọi nội bộ trong cùng một bean thì không qua proxy, nên luật cũng không chạy. Cái thứ hai chính là bẫy self-invocation bạn đã gặp ở @Transactional Ngày 28 — nhưng hậu quả nặng hơn nhiều.
// METHOD SECURITY — bật bằng một annotation, và có một cái bẫy lớn.
@Configuration
@EnableMethodSecurity // ⭐ KHÔNG có dòng này thì @PreAuthorize BỊ BỎ QUA
class CauHinh { } // Im lặng. Không lỗi. Endpoint mở toang.
// ❗ BẪY: @PreAuthorize chạy bằng PROXY (AOP) — y hệt @Transactional (Ngày 28).
// Proxy chỉ chặn được lời gọi ĐI TỪ NGOÀI VÀO bean.
@Service
class DichVuDonHang {
public void xuLyHangLoat() {
for (var d : ds) xoa(d); // ❌ gọi NỘI BỘ → KHÔNG qua proxy
} // → @PreAuthorize KHÔNG chạy!
@PreAuthorize("hasAuthority('ORDER_DELETE')")
public void xoa(DonHang d) { }
}
// Cùng một lỗi self-invocation của @Transactional, nhưng hậu quả nặng hơn nhiều:
// ở đó bạn mất transaction, ở đây bạn mất KIỂM TRA QUYỀN.
// ĐẶT Ở ĐÂU: tầng SERVICE, không phải controller.
// Vì logic có thể được gọi từ controller, từ scheduled job, từ message listener —
// đặt ở service thì mọi lối vào đều đi qua cùng một luật.
// ❗ 401 khác 403 (Ngày 45): 401 = "bạn là ai?" · 403 = "biết bạn là ai, nhưng KHÔNG được".
// AccessDeniedException của method security → 403.Thiếu
@EnableMethodSecuritythì endpoint mở toang mà không có lỗi nào — hãy viết một test khẳng định người không có quyền nhận 403.Self-invocation: ở
@Transactionalbạn mất transaction, ở đây bạn mất luôn kiểm tra quyền.Đặt luật ở tầng service chứ không phải controller — vì cùng một logic còn được gọi từ scheduled job và message listener.
401là "bạn là ai",403là "biết rồi, nhưng không được" — đừng trộn hai mã đó.
Bài tập nhỏ
Viết
hasRole("ROLE_ADMIN")rồi đăng nhập bằng admin — gặp 403, sau đó bỏ tiền tố để thấy nó chạy.Xóa
@EnableMethodSecurityvà gọi một endpoint đáng lẽ bị chặn — thấy nó mở toang, không lỗi nào.Gọi method có
@PreAuthorizetừ một method khác trong cùng bean để tận mắt thấy luật không chạy.Dựng ba authority
ORDER_READ/WRITE/DELETEvà gán cho hai role, rồi thêm role thứ ba mà không sửa dòng code nào.Dùng
@PreAuthorize("#donHang.chuSoHuu == authentication.name")và thử sửa đơn của người khác.
Kết lại
Bốn ý gói lại hôm nay: trong Spring Security role chỉ là authority có tiền tố ROLE_, nên hasRole("ROLE_ADMIN") luôn cho 403 và không hề báo lỗi; code nên kiểm tra việc chứ không kiểm tra người, để thêm một vai trò mới chỉ là thêm dữ liệu chứ không phải sửa code; quyền theo dữ liệu như "đơn hàng của chính mình" thì role không diễn đạt nổi, phải dùng biểu thức trong @PreAuthorize; và hai thứ im lặng làm hỏng tất cả là quên @EnableMethodSecurity cùng bẫy gọi nội bộ qua proxy. Ngày 48 ta bỏ hẳn việc tự quản mật khẩu: OAuth2 login với Google và GitHub. 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.


