Xác thực trả lời "bạn là ai". Phân quyền trả lời "bạn được làm gì". Bài này về vế thứ hai, và về một lỗ hổng đo được trong đó.
Phân quyền theo URL
.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".
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.
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
@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.
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?
@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.
Thử 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ó. Đă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à không có test nào trong dự án đang kiểm điều này.
Ngày mai: CSRF — và vì sao token đúng vẫn bị từ chối sau khi đăng nhập.