Hình dung phân quyền như một thư viện có phân khu. Xác thực là lúc bạn chìa thẻ ở quầy — nó trả lời "bạn là ai". Phân quyền là phần đến sau: thẻ của bạn mở được khu đọc nào, và trong mỗi khu còn những tủ khoá riêng. Bài này về vế thứ hai, và về hai lỗ hổng đo được mà cả hai đều im lặng hoàn toàn.
Phân quyền theo URL
Đây là các cửa quay ở lối vào từng khu — thư viện kiểm tập trung:
.authorizeHttpRequests(a -> a
.requestMatchers("/mo").permitAll()
.requestMatchers(HttpMethod.GET, "/chi-get").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
/mo /rieng /admin/x
không đăng nhập 200 401 401
an (USER) 200 200 403
bo (ADMIN) 200 200 200
khoa (bị khoá) 401 401 401
Ba điều đọc ra được:
401 khác 403. Chưa đăng nhập là 401 Unauthorized — "tôi không biết bạn là ai". Đã đăng nhập nhưng sai vai là 403 Forbidden — "tôi biết bạn là ai, và bạn không được vào". Quầy chưa nhận ra bạn, khác với quầy nhận ra bạn rồi nhưng khu này chỉ dành cho nhân viên.
Phân biệt đúng hai mã này quan trọng cho client: 401 thì hiện màn hình đăng nhập, 403 thì hiện "bạn không có quyền". Trả nhầm là người dùng bị đá về trang đăng nhập rồi lại bị từ chối, mãi mãi.
Tài khoản bị khoá trả 401 ngay cả với /mo. Dòng cuối bảng: permitAll vẫn 401. Vì thông tin xác thực được gửi kèm và sai, nên request bị từ chối trước khi tới luật phân quyền. permitAll nghĩa là "không cần đăng nhập", không phải "bỏ qua mọi kiểm tra".
Blog này xử lý riêng trường hợp này: đăng nhập khi chưa được duyệt thì loginFailureHandler bắt DisabledException và chuyển tới /login?error=disabled với thông báo riêng — thay vì để người dùng nghĩ mình gõ sai mật khẩu.
Luật theo HTTP method hoạt động:
GET /chi-get không đăng nhập -> 200
POST /chi-get không đăng nhập -> 401
Rất hữu ích: cho đọc công khai, bắt đăng nhập khi ghi.
hasRole và hasAuthority
Đây là chỗ nhầm nhiều nhất:
.hasRole("ADMIN") // kiểm quyền "ROLE_ADMIN"
.hasAuthority("ROLE_ADMIN") // tương đương
.hasAuthority("ADMIN") // KHÁC — kiểm quyền tên "ADMIN"
hasRole tự thêm tiền tố ROLE_. Nên nếu bạn lưu quyền trong CSDL là "ADMIN" mà dùng hasRole, nó tìm "ROLE_ADMIN" và không bao giờ khớp — im lặng, luôn 403. Y như cái cửa quay được lập trình cho nhóm thẻ "ROLE_ADMIN" trong khi thẻ của bạn mã là "ADMIN": quẹt mãi không mở, mà chẳng có đèn báo lỗi.
Cách tránh nhầm: chọn một quy ước và ghi vào tài liệu. Blog này dùng Role.authority() trả về ROLE_ADMIN / ROLE_AUTHOR để chỗ nào cũng thống nhất.
Về mặt khái niệm: vai trò là nhóm người (ADMIN, AUTHOR), quyền là hành động cụ thể (BAI_VIET_XOA). Hệ thống lớn nên gán quyền cho vai trò rồi kiểm quyền — thêm vai trò mới không phải sửa mã. Hệ thống nhỏ thì hai vai trò là đủ, và blog này cố ý dừng ở đó.
Phân quyền mức phương thức
Cửa quay ở lối vào chưa đủ; trong mỗi khu còn những tủ khoá riêng:
@EnableMethodSecurity // bắt buộc, không bật sẵn
Chạy thật với người dùng an (vai USER):
chiAdmin AuthorizationDeniedException
chiChinhMinh("an") ok-an
chiChinhMinh("nguoi-khac") AuthorizationDeniedException
PostAuthorize doc("an") TaiLieu[chuSoHuu=an]
PostAuthorize doc("nguoi-khac") AuthorizationDeniedException
khongChuThich ok-khong-chu-thich
@PreAuthorize("hasRole('ADMIN')")
@PreAuthorize("#ten == authentication.name") // dùng tham số
@PostAuthorize("returnObject.chuSoHuu == authentication.name") // dùng kết quả
@PreAuthorize chạy trước phương thức, @PostAuthorize chạy sau và xem được giá trị trả về.
@PostAuthorize giải quyết bài toán "chỉ chủ sở hữu được đọc" khi bạn phải tải bản ghi lên mới biết ai là chủ. Cái giá: phương thức đã chạy rồi. Với thao tác đọc thì không sao; với thao tác có tác dụng phụ — ghi CSDL, gửi email — thì tác dụng phụ đã xảy ra trước khi quyền bị từ chối. Đừng dùng @PostAuthorize cho phương thức ghi.
Lỗ hổng: tự gọi bỏ qua phân quyền
public String goiNoiBo() { return chiAdmin(); } // gọi thẳng trong cùng lớp
TỰ GỌI chiAdmin qua goiNoiBo -> ok-admin
USER vừa gọi được phương thức khai @PreAuthorize("hasRole('ADMIN')"). Đây không phải lỗi cấu hình — đó là hệ quả tất yếu của proxy, cùng cơ chế đã đo ở bài 26 với @Transactional và bài 35 với @Cacheable.
Cửa quay kiểm thẻ nằm ở lối vào khu. Một lời gọi nội bộ đi từ tủ này sang tủ kia qua lối đi riêng trong khu — nó không băng qua cửa quay, nên chẳng ai quẹt thẻ. Với @Transactional thì hậu quả là dữ liệu không được bảo vệ. Với @PreAuthorize thì hậu quả là lỗ hổng bảo mật — và nó im lặng hoàn toàn: không lỗi, không cảnh báo, không dòng log.
Ba cách phòng:
Đặt phân quyền ở ranh giới, không ở bên trong. Kiểm quyền ở controller hoặc ở lớp service ngoài cùng. Phương thức nội bộ không nên là chỗ dựa duy nhất.
Tách sang bean khác khi thật sự cần kiểm ở tầng sâu.
Đừng chỉ dựa vào một tầng. Phân quyền theo URL cộng phân quyền mức phương thức: bỏ sót một tầng vẫn còn tầng kia.
Bảo vệ dữ liệu, không chỉ bảo vệ endpoint
Đây là chỗ phân quyền hay bị làm nửa vời. Endpoint /bai/{id}/sua yêu cầu vai AUTHOR — nhưng tác giả nào? Thẻ của bạn mở được "khu tác giả", nhưng bên trong bạn lại với tay sang bản thảo của người khác.
@PreAuthorize("hasRole('ADMIN') or @baiService.laChuSoHuu(#id, authentication.name)")
Hoặc — cách tôi thấy đáng tin hơn — đưa điều kiện vào chính truy vấn:
Optional<Bai> findByIdAndTacGiaId(Long id, Long tacGiaId);
Không tìm thấy thì không có gì để rò rỉ, và không có đường nào quên kiểm. Blog này dùng cách đó qua tham số authorId với hằng ALL_AUTHORS = -1 để bỏ lọc khi là quản trị viên.
Lỗ hổng loại này — tham chiếu đối tượng trực tiếp không an toàn — đứng đầu danh sách OWASP nhiều năm liền, đơn giản vì nó không lộ ra trong bất kỳ phép thử nào trừ khi bạn thử bằng tài khoản của người khác.
Đọc thông tin người đang đăng nhập
@GetMapping("/toi")
String toi(@AuthenticationPrincipal UserDetails nguoi) { return nguoi.getUsername(); }
Gọn hơn SecurityContextHolder.getContext().getAuthentication() và test dễ hơn nhiều — chỉ cần truyền tham số.
Và nhớ: SecurityContextHolder chạy trên ThreadLocal. Nộp việc vào executor thì luồng con không có ngữ cảnh bảo mật. Cần thì dùng DelegatingSecurityContextExecutor.
Nếu chỉ thử một thứ sau bài này, hãy thử đúng cái bẫy IDOR trong ba mươi giây: đăng nhập bằng tài khoản A, mở một bản ghi của A, lấy id của nó. Rồi đăng nhập bằng tài khoản B và gọi cùng URL với id đó. Nhận về dữ liệu của A nghĩa là bạn đang bảo vệ endpoint mà không bảo vệ dữ liệu — và gần như chắc chắn không có test nào trong dự án đang kiểm điều này, vì test thường chỉ chạy bằng tài khoản của chính nó.
Mẫu số chung
Phân quyền có hai câu hỏi trông như một mà thật ra khác hẳn: "một người vai này có được làm hành động này không" (kiểm theo endpoint/vai) và "chính người này có được chạm vào đúng đối tượng này không" (kiểm theo sở hữu). Câu đầu dễ, và mọi framework lo sẵn; câu sau là nơi các vụ rò rỉ thật xảy ra — Broken Access Control đứng đầu OWASP nhiều năm — vì nó chỉ vỡ khi ai đó thử id của người khác, mà không đường-đi-hạnh-phúc nào làm thế. Thế nên mọi hệ sinh thái đều mọc công cụ cho câu sau: Pundit/CanCanCan của Rails giới hạn ngay truy vấn theo người dùng, Django có quyền ở mức đối tượng, và ai cũng học rằng middleware chặn route là chưa đủ. Sợi chỉ chung: đẩy điều kiện sở hữu vào thẳng truy vấn (findByIdAndTacGiaId) để không có đường nào quên kiểm, và để "không tìm thấy" với "không phải của bạn" trông giống hệt nhau — không rò rỉ cả sự tồn tại.
Điều thứ hai đáng khắc sâu: một cơ chế canh gác được một lớp bọc bên ngoài thực thi thì có điểm mù ngay tại chỗ lời gọi không băng qua lớp bọc đó. Proxy của Spring thực thi @PreAuthorize — và y hệt vậy với @Transactional, @Cacheable — nên một cú tự gọi trong cùng lớp lặng lẽ bỏ qua nó, không lỗi, không log. Điều này đúng cho mọi hệ dựa trên aspect/decorator/middleware: phải biết ranh giới thực thi của mình nằm ở đâu về mặt vật lý, đặt kiểm tra ở ranh giới ngoài cùng, và đừng bao giờ để nó là lớp duy nhất — nhiều lớp (URL + phương thức + dữ liệu) nghĩa là một khe hở không phải là dấu chấm hết. Cả hai lỗ hổng trong bài này đều im lặng; thứ duy nhất bắt được chúng là đặt mình vào vai kẻ tấn công và thử chạm cái lẽ ra không được chạm.
Ngày mai: CSRF — và vì sao token đúng vẫn bị từ chối sau khi đăng nhập.