TungDaDev's Blog

Spring Security annotations

Spring security annotations.webp
Published on
/9 mins read/

Trong kỹ nghệ phần mềm doanh nghiệp, Bảo mật (Security) không bao giờ là một tính năng phụ trợ được "chắp vá" vào giai đoạn cuối của dự án. Nó là nền tảng cốt tử. Mọi yêu cầu truy cập hệ thống đều phải trả lời hai câu hỏi sống còn:

  1. Authentication (Xác thực): "Bạn là ai?" (JWT Token, OAuth2 Resource Server, Session Cookie).
  2. 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 cung cấp cơ chế bảo mật khai báo (Declarative Security) cực kỳ mạnh mẽ thông qua tập hợp các Annotations: @PreAuthorize, @PostAuthorize, @PreFilter, @PostFilter, @Secured, @RolesAllowed.

Tuy nhiên, việc sử dụng sai lệch hoặc hiểu nông cạn về cơ chế hoạt động của Spring Security thường dẫn đến những lỗ hổng bảo mật nghiêm trọng:

  • Bị rò rỉ dữ liệu giữa các khách hàng (Multi-tenant Data Leakage) do kiểm tra quyền sơ sài ở tầng URL thay vì Method Level.
  • Bẫy Side-Effect chết người của @PostAuthorize: Dữ liệu đã bị ghi vào Database xong xuôi rồi Spring mới ném ngoại lệ chặn quyền!
  • Thảm họa hiệu năng và nghẽn CPU của @PostFilter khi cố gắng lọc hàng chục nghìn bản ghi trong RAM thay vì lọc tại câu lệnh SQL.
  • Phải viết hàng chục role lặp đi lặp lại vì không biết cách thiết lập Role Hierarchy.

Bài viết này sẽ phân tích toàn diện kiến trúc Spring Security từ tầng Servlet Filter đến Spring AOP Method Interceptors.


# mô hình phòng thủ đa tầng (defense-in-depth): filterchain vs method security

Một kiến trúc bảo mật chuẩn production luôn áp dụng nguyên lý Phòng thủ đa tầng (Defense-in-Depth):

# tại sao chỉ bảo vệ ở securityfilterchain là không đủ?

  1. URL-based Security chỉ lọc thô: Bạn chỉ có thể cấu hình /api/v1/orders/** yêu cầu authenticated hoặc có role USER. Nhưng URL không thể biết được liệu User ID 100 có quyền sửa Đơn hàng ID 999 hay không!
  2. Đa kênh truy cập (Multi-Channel Invocations): Một Business Service không chỉ được gọi từ Web Controller. Nó có thể được gọi từ một Kafka Consumer, một Scheduled Batch Job, hoặc một gRPC Service. Các kênh này hoàn toàn không đi qua Servlet FilterChain!
  3. Giải pháp: Đặt chốt chặn bảo mật bằng Method Security Annotations ngay trên Interface của Service nghiệp vụ.

# giải mã SpEL & bộ đôi @preauthorize vs @postauthorize

Để kích hoạt Method Security trong Spring Boot 3+, bạn bắt buộc phải khai báo @EnableMethodSecurity trên class cấu hình:

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class SecurityConfiguration {
    // ...
}

# @preauthorize: chặn trước khi phương thức chạy

Spring chặn phương thức trước khi gọi, phân giải biểu thức SpEL (Spring Expression Language). Nếu trả về false, ném ra AccessDeniedException (HTTP 403) ngay lập tức:

@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) { ... }
}

# tử huyệt của @postauthorize: bẫy side-effect nguy hiểm!

@PostAuthorize cho phép phương thức chạy xong xuôi, sau đó 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!
}

CAUTION

Quy Tắc Sống Còn: TUYỆT ĐỐI KHÔNG DÙNG @PostAuthorize CHO CÁC THAO TÁC GHI (INSERT, UPDATE, DELETE)! Chỉ sử dụng nó cho các thao tác đọc (GET/Query) khi quyền truy cập phụ thuộc trực tiếp vào nội dung dữ liệu trả về từ Database.


# cạm bẫy hiệu năng của @postfilter & @prefilter

Spring Security cung cấp hai annotation hỗ trợ lọc collection:

  • @PreFilter: Lọc danh sách đầu vào trước khi method thực thi.
  • @PostFilter: Lọc danh sách trả về sau khi method thực thi.
// 🔴 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 là một giải pháp tồi trong môi trường enterprise?

  1. Lãng phí I/O và RAM: Database phải quét toàn bộ bảng và gửi hàng chục Megabytes dữ liệu qua mạng xuống ứng dụng.
  2. CPU Churn: Spring Security phải sử dụng Iterator để duyệt qua từng phần tử và đánh giá biểu thức SpEL, ngốn toàn bộ CPU của server.
  3. Giải pháp chuẩn: Luôn luôn lọc dữ liệu ngay tại câu lệnh SQL (WHERE owner_id = :userId) thông qua Spring Data JPA hoặc Spring Data Specifications!

# xây dựng custom security expression Bean (ABAC chuẩn enterprise)

Thay vì viết những biểu thức SpEL dài dòng, khó bảo trì trực tiếp trên annotation, kiến trúc sư phần mềm luôn tách logic phân quyền thành 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);
    }
}

# sử dụng thanh lịch 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)

Một vấn đề phổ biến là lập trình viên phải liệt kê hàng loạt Role: @PreAuthorize("hasAnyRole('SUPER_ADMIN', 'ORG_ADMIN', 'MANAGER', 'STAFF')")

Điều này vừa dễ sót quyền, vừa biến code thành một mớ hỗn độn.

# thiết lập rolehierarchy tự động suy diễn 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
    public MethodSecurityExpressionHandler methodSecurityExpressionHandler(RoleHierarchy roleHierarchy) {
        DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler();
        expressionHandler.setRoleHierarchy(roleHierarchy);
        return expressionHandler;
    }
}

Nhờ có RoleHierarchy, bạn chỉ cần viết:

@PreAuthorize("hasRole('STAFF')")

Và bất kỳ User nào có role MANAGER, ORG_ADMIN, hay SUPER_ADMIN đều tự động được cấp quyền truy cập mà không cần viết thêm một dòng code nào!


# ma trận đánh giá so sánh các annotation bảo mật

AnnotationCơ chế hoạt độngHỗ trợ SpEL?Ưu điểmNhược điểm / Rủi ro
@PreAuthorizeChặn trước khi chạy hàmCÓ (Toàn diện)Linh hoạt, kiểm tra sâu thuộc tính đối tượng.Lỗi cú pháp SpEL chỉ phát hiện lúc runtime.
@PostAuthorizeChặn sau khi hàm trả vềCÓKiểm tra được thuộc tính trả về từ DB.🔴 Nguy cơ Side-Effect nếu áp dụng cho lệnh Ghi!
@PreFilterLọc collection đầu vàoCÓ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.
@PostFilterLọc collection trả vềCÓLọc kết quả tự động.🔴 Ác mộng hiệu năng RAM & CPU khi dữ liệu lớn!
@SecuredChặn theo Role SpringKHÔ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.
@RolesAllowedChuẩn JSR-250 Java EEKHÔNGĐộc lập framework, theo chuẩn Java quốc tế.Bị gò bó chỉ trong việc so sánh role tĩnh.

# tổng kết & checklist thực chiến

  1. Phòng thủ đa tầng: Sử dụng SecurityFilterChain để xác thực danh tính và lọc URL thô; sử dụng Method Security để bảo vệ tài nguyên chi tiết theo nghiệp vụ.
  2. Khai tử @PostAuthorize trên CUD: Không bao giờ dùng @PostAuthorize cho Create/Update/Delete; luôn dùng @PreAuthorize để fail-fast trước khi can thiệp vào Database.
  3. Đừng lọc bằng @PostFilter: Hãy đẩy toàn bộ logic lọc phân quyền xuống tầng SQL query (WHERE tenant_id = ?) để tối ưu hóa Index và giảm tải RAM.
  4. Áp dụng Role Hierarchy: Giúp phân cấp quyền hạn tự nhiên, loại bỏ các biểu thức hasAnyRole() dài dòng.
  5. Đóng gói Logic vào Custom Guard Bean: Giúp SpEL ngắn gọn, sạch sẽ, và dễ dàng viết Unit Test độc lập 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 😎 👍🏻 🚀 🔥.