Backend

99 Ngày Spring — Ngày 47: Phân quyền — role & authority

SSite Admin
12 tháng 09, 2026 7 phút đọc 0 lượt xem
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.

Sketchnote Ngày 47: phân quyền role và authority — GrantedAuthority chỉ là chuỗi, hasRole tự thêm tiền tố ROLE_, thiết kế role gom quyền, PreAuthorize và bẫy self-invocation của proxy

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 hasAuthority
  • GrantedAuthority chỉ là một chuỗi — không có kiểu Role nào trong Spring Security cả.

  • hasRole("ADMIN")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ành ROLE_ROLE_ADMIN và 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à @PreAuthorize với biểu thức SpEL làm được.

  • @PostAuthorize chạ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 @EnableMethodSecurity thì 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: ở @Transactional bạ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.

  • 401 là "bạn là ai", 403 là "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 @EnableMethodSecurity và 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ó @PreAuthorize từ 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/DELETE và 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!

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 47: Serialization & JSON

Vì sao Serializable của Java bị cả ngành rời bỏ, ObjectMapper tạo một lần dùng mãi, bẫy TypeReference khi đọc List do xóa kiểu — và vì sao record hợp với JSON tới mức không cần annotation nào.

12 thg 9, 20267 phút0
99 Ngày Spring — Ngày 46: Refresh token & logout

Access token 15 phút cộng refresh token 7 ngày lưu database, vì sao refresh token không nên là JWT, rotation biến việc dùng lại token thành chuông báo động — và sự thật rằng đăng xuất của JWT là "không cấp thêm".

11 thg 9, 20268 phút11
99 Ngày Java — Ngày 46: NIO.2 — Path & Files

Path là một cái tên chứ không phải một file, resolve và normalize cùng bẫy path traversal, Files ném ngoại lệ có tên thay vì trả false, ATOMIC_MOVE để ghi file an toàn — và walk, find, glob thay hẳn đệ quy tự viết.

11 thg 9, 20267 phút6