Spring Security annotations
Tung Bui Thanh (TungDaDev) (updated 6 months ago)9 mins read

Trong phần mềm doanh nghiệp, Bảo mật (Security) không phải thứ "chắp vá" vào cuối dự án. Nó phải có từ đầu. Mỗi request vào hệ thống đều phải trả lời hai câu hỏi:
- Authentication (Xác thực): "Bạn là ai?" (JWT Token, OAuth2 Resource Server, Session Cookie).
- Authorization (Phân quyền): "Bạn được phép làm những gì trên tài nguyên cụ thể này?" (RBAC, ABAC, Multi-Tenant Workspace Isolation).
Spring Security cho phép khai báo bảo mật (Declarative Security) qua một bộ Annotations: @PreAuthorize, @PostAuthorize, @PreFilter, @PostFilter, @Secured, @RolesAllowed.
Nhưng dùng sai hoặc hiểu chưa kỹ cách Spring Security hoạt động thì rất dễ tạo lỗ hổng nghiêm trọng:
- Rò rỉ dữ liệu giữa các khách hàng (Multi-tenant Data Leakage) vì chỉ kiểm tra quyền ở tầng URL mà không kiểm ở Method Level.
- Bẫy Side-Effect của
@PostAuthorize: dữ liệu đã ghi vào Database xong rồi Spring mới ném ngoại lệ chặn quyền. @PostFilterngốn CPU và RAM khi lọc hàng chục nghìn bản ghi trong bộ nhớ thay vì lọc ngay trong câu SQL.- Phải viết đi viết lại hàng chục role vì không biết Role Hierarchy.
Bài này đi qua kiến trúc Spring Security, từ Servlet Filter đến Spring AOP Method Interceptors.
# mô hình phòng thủ đa tầng (defense-in-depth): filterchain vs method security
Kiến trúc bảo mật cho production thường theo nguyên lý phòng thủ đa tầng (Defense-in-Depth):
# tại sao chỉ bảo vệ ở securityfilterchain là không đủ?
- URL-based Security chỉ lọc thô: bạn chỉ cấu hình được kiểu
/api/v1/orders/**yêu cầu authenticated hoặc có roleUSER. URL không cho biết User ID100có quyền sửa Đơn hàng ID999hay không. - Nhiều kênh truy cập (Multi-Channel Invocations): một Business Service không chỉ được gọi từ Web Controller mà còn từ Kafka Consumer, Scheduled Batch Job hay gRPC Service. Các kênh này không đi qua Servlet FilterChain.
- Giải pháp: đặt chốt chặn bằng Method Security Annotations ngay trên Interface của Service nghiệp vụ.
# SpEL và cặp @preauthorize vs @postauthorize
Muốn bật Method Security trong Spring Boot 3+, bạn phải khai báo @EnableMethodSecurity trên class cấu hình (annotation này thay cho @EnableGlobalMethodSecurity đã deprecated). prePostEnabled mặc định đã là true, chỉ @Secured và @RolesAllowed mới cần bật thêm:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class SecurityConfiguration {
// ...
}# @preauthorize: chặn trước khi method chạy
Spring chặn method trước khi gọi và tính biểu thức SpEL (Spring Expression Language). Nếu kết quả là false, nó ném AccessDeniedException luôn. ExceptionTranslationFilter đổi exception này thành HTTP 403 nếu user đã đăng nhập, còn request ẩn danh thì thành 401.
Cách annotation hoạt động cũng cần nhớ: Spring bọc bean trong một AOP proxy, nên chỉ lời gọi đi qua proxy mới bị kiểm tra. Method private, hay method gọi nội bộ kiểu this.deleteWorkspace(...) trong cùng class, sẽ lọt qua mà không có lỗi gì.
@RestController
@RequestMapping("/api/v1/workspaces")
public class WorkspaceController {
// 1. Phân quyền theo Role chuẩn
@PreAuthorize("hasRole('ADMIN')")
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteWorkspace(@PathVariable UUID id) { ... }
// 2. Phân giải tham số động (#id) và gọi Bean bảo mật riêng biệt (ABAC)
@PreAuthorize("@workspaceSecurityEvaluator.canAccess(#id, authentication)")
@GetMapping("/{id}")
public WorkspaceDto getWorkspace(@PathVariable UUID id) { ... }
}Biểu thức #id lấy giá trị theo tên tham số của method. Từ Spring Framework 6.1, tên tham số chỉ còn được đọc qua cờ biên dịch -parameters (Spring Boot plugin cho Maven/Gradle bật sẵn). Build bằng cấu hình riêng mà quên cờ này thì #id thành null và rule phân quyền chạy sai âm thầm; muốn chắc ăn thì gắn @P("id") lên tham số.
# @postauthorize và bẫy side-effect
@PostAuthorize cho method chạy xong hết, rồi mới kiểm tra quyền dựa trên giá trị trả về (returnObject):
// 🔴 BẪY BẢO MẬT KINH ĐIỂN:
@PostAuthorize("returnObject.ownerId == authentication.name")
@PutMapping("/documents/{id}")
public Document updateDocument(@PathVariable Long id, @RequestBody UpdateRequest request) {
// DÒNG NÀY ĐÃ GHI DỮ LIỆU MỚI VÀO DATABASE VÀ COMMIT THÀNH CÔNG!
return documentService.saveAndFlush(id, request);
// SAU ĐÓ, @PostAuthorize MỚI KIỂM TRA QUYỀN VÀ NÉM AccessDeniedException!
}Đặt @Transactional ngay trên chính method đó cũng không cứu được: interceptor của Method Security mặc định nằm bên ngoài interceptor transaction, nên lúc nó kiểm tra returnObject thì transaction bên trong đã commit xong.
CAUTION
Quy tắc cần nhớ: KHÔNG dùng @PostAuthorize cho thao tác ghi (INSERT, UPDATE, DELETE). Chỉ dùng cho thao tác đọc (GET/Query) khi quyền phụ thuộc vào nội dung dữ liệu trả về từ Database.
# @postfilter và @prefilter: bẫy hiệu năng
Spring Security có hai annotation để lọc collection:
@PreFilter: lọc danh sách đầu vào trước khi method chạy.@PostFilter: lọc danh sách trả về sau khi method chạy.
// 🔴 THẢM HỌA HIỆU NĂNG CPU & RAM:
@PostFilter("filterObject.ownerId == authentication.name")
@GetMapping("/all-orders")
public List<Order> getAllOrders() {
// Database query 1,000,000 bản ghi nạp vào RAM!
return orderRepository.findAll();
// Hibernate tải 1 triệu entity lên Heap -> Spring lặp qua 1 triệu lần để xóa phần tử!
}# tại sao @postfilter không hợp với hệ thống lớn?
- Tốn I/O và RAM: database phải quét cả bảng và gửi hàng chục Megabytes dữ liệu qua mạng xuống ứng dụng.
- CPU Churn: Spring Security dùng Iterator duyệt từng phần tử và tính biểu thức SpEL, ngốn CPU của server.
- Giải pháp: lọc dữ liệu ngay trong câu SQL (
WHERE owner_id = :userId) qua Spring Data JPA hoặc Spring Data Specifications.
# custom security expression Bean (ABAC)
Thay vì nhét biểu thức SpEL dài, khó bảo trì thẳng lên annotation, nên tách logic phân quyền ra một Dedicated Security Evaluator:
package com.company.security;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.stereotype.Component;
import java.util.UUID;
@Component("workspaceGuard")
public class WorkspaceSecurityGuard {
private final WorkspaceMembershipRepository membershipRepo;
public WorkspaceSecurityGuard(WorkspaceMembershipRepository membershipRepo) {
this.membershipRepo = membershipRepo;
}
public boolean hasPermission(UUID workspaceId, String requiredRole, Authentication authentication) {
if (authentication == null || !(authentication.getPrincipal() instanceof Jwt jwt)) {
return false;
}
// Trích xuất userId từ JWT Claim sub
String userId = jwt.getSubject();
// Kiểm tra quan hệ thành viên trong Database hoặc Redis Cache
return membershipRepo.findRole(workspaceId, userId)
.map(role -> role.hasHierarchyLevel(requiredRole))
.orElse(false);
}
}# dùng trên controller / service
@RestController
@RequestMapping("/api/v1/workspaces/{workspaceId}/projects")
public class ProjectController {
@PreAuthorize("@workspaceGuard.hasPermission(#workspaceId, 'EDITOR', authentication)")
@PostMapping
public ProjectDto createProject(
@PathVariable UUID workspaceId,
@RequestBody CreateProjectRequest request) {
return projectService.create(workspaceId, request);
}
}# thiết lập kế thừa quyền hạn (role hierarchy)
Chuyện hay gặp là phải liệt kê cả loạt Role: @PreAuthorize("hasAnyRole('SUPER_ADMIN', 'ORG_ADMIN', 'MANAGER', 'STAFF')")
Vừa dễ sót quyền, vừa làm code rối.
# rolehierarchy tự suy ra quyền
@Configuration
public class RoleHierarchyConfig {
@Bean
public RoleHierarchy roleHierarchy() {
return RoleHierarchyImpl.withDefaultRolePrefix()
.role("SUPER_ADMIN").implies("ORG_ADMIN")
.role("ORG_ADMIN").implies("MANAGER")
.role("MANAGER").implies("STAFF")
.role("STAFF").implies("USER")
.build();
}
@Bean // static: để bean này có trước khi config Method Security khởi tạo (theo docs Spring Security)
static MethodSecurityExpressionHandler methodSecurityExpressionHandler(RoleHierarchy roleHierarchy) {
DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler();
expressionHandler.setRoleHierarchy(roleHierarchy);
return expressionHandler;
}
}Có RoleHierarchy thì bạn chỉ cần viết:
@PreAuthorize("hasRole('STAFF')")User nào có role MANAGER, ORG_ADMIN hay SUPER_ADMIN cũng tự động được truy cập, không cần viết thêm dòng code nào. Builder RoleHierarchyImpl.withDefaultRolePrefix() có từ Spring Security 6.3; bản cũ hơn thì new RoleHierarchyImpl() rồi gọi setHierarchy("ROLE_SUPER_ADMIN > ROLE_ORG_ADMIN\nROLE_ORG_ADMIN > ROLE_MANAGER ...").
# so sánh các annotation bảo mật
| Annotation | Cơ chế hoạt động | Hỗ trợ SpEL? | Ưu điểm | Nhược điểm / Rủi ro |
|---|---|---|---|---|
@PreAuthorize | Chặn trước khi chạy hàm | CÓ | Linh hoạt, kiểm tra được sâu thuộc tính đối tượng. | Lỗi cú pháp SpEL chỉ phát hiện lúc runtime. |
@PostAuthorize | Chặn sau khi hàm trả về | CÓ | Kiểm tra được thuộc tính trả về từ DB. | 🔴 Có Side-Effect nếu dùng cho lệnh ghi! |
@PreFilter | Lọc collection đầu vào | CÓ | Tiện lợi khi xóa hàng loạt ID không hợp lệ. | Chỉ áp dụng được cho mảng/collection mutable. |
@PostFilter | Lọc collection trả về | CÓ | Lọc kết quả tự động. | 🔴 Tốn RAM & CPU khi dữ liệu lớn! |
@Secured | Chặn theo Role Spring | KHÔNG | Đơn giản, ngắn gọn, dễ hiểu. | Không hỗ trợ SpEL hay biểu thức logic phức tạp. |
@RolesAllowed | Chuẩn JSR-250 Java EE | KHÔNG | Độc lập framework, theo chuẩn Java. | Bị gò bó chỉ trong việc so sánh role tĩnh. |
# tổng kết & checklist
- Phòng thủ đa tầng: dùng
SecurityFilterChainđể xác thực và lọc URL thô, dùngMethod Securityđể bảo vệ tài nguyên chi tiết theo nghiệp vụ. - Không dùng
@PostAuthorizecho CUD: Create/Update/Delete luôn dùng@PreAuthorizeđể fail-fast trước khi đụng tới Database. - Đừng lọc bằng
@PostFilter: đẩy logic lọc phân quyền xuống SQL (WHERE tenant_id = ?) để tận dụng Index và đỡ tốn RAM. - Dùng Role Hierarchy: phân cấp quyền tự nhiên, bỏ được các biểu thức
hasAnyRole()dài. - Đóng gói logic vào Custom Guard Bean: SpEL gọn hơn, và dễ viết Unit Test riêng cho các luật phân quyền phức tạp.
Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!
Happy coding 😎 👍🏻 🚀 🔥.