Spring Boot/Method security — @PreAuthorize, @PostAuthorize, @Secured
~22 phútSpring Security cơ bảnMiễn phí

Method security — @PreAuthorize, @PostAuthorize, @Secured

Method-level security với @EnableMethodSecurity. Bài này bóc @PreAuthorize SpEL syntax, @PostAuthorize, @Secured, custom PermissionEvaluator, ownership check (project owner only), per-method authorization vs URL-based, testing với @WithMockUser.

URL-level rules (requestMatchers().hasRole(...)) đủ cho 70% case. Còn 30% cần fine-grained: "user chỉ access project họ own", "method này chỉ admin", "post-process kết quả lọc theo permission". Method security giải quyết qua annotation trên method.

1. Setup @EnableMethodSecurity

@Configuration
@EnableMethodSecurity(
    prePostEnabled = true,    // @PreAuthorize, @PostAuthorize (default true)
    securedEnabled = true,    // @Secured legacy
    jsr250Enabled = false     // @RolesAllowed JSR-250
)
public class SecurityConfig { ... }

@EnableMethodSecurity kích hoạt AOP proxy intercept method call. Default prePostEnabled = true@PreAuthorize + @PostAuthorize work.

2. @PreAuthorize — check before method

@Service
public class ProjectService {

    @PreAuthorize("hasRole('ADMIN')")
    public void deleteAll() { ... }

    @PreAuthorize("hasAnyRole('USER', 'ADMIN')")
    public Project create(...) { ... }

    @PreAuthorize("authenticated")
    public List<Project> list() { ... }

    @PreAuthorize("isAnonymous()")
    public void registerNewUser() { ... }
}

Method invoked → Spring AOP check expression. Fail → throw AccessDeniedException → 403.

2.1 SpEL trong @PreAuthorize

Powerful expression với access tới method args, principal:

@PreAuthorize("hasRole('ADMIN') or #project.owner.email == authentication.name")
public Project update(@P("project") Project project) { ... }

@PreAuthorize("#userId == authentication.principal.id")
public User getProfile(Long userId) { ... }

@PreAuthorize("@projectSecurity.canEdit(#projectId, authentication)")
public void edit(Long projectId) { ... }

SpEL variables:

  • authentication — current Authentication.
  • principalauthentication.principal shortcut.
  • #paramName — method argument by name.
  • @beanName.method(...) — call Spring bean.

2.2 Custom security bean

@Component
@RequiredArgsConstructor
public class ProjectSecurity {

    private final ProjectRepository projectRepo;

    public boolean canEdit(Long projectId, Authentication auth) {
        Project p = projectRepo.findById(projectId).orElse(null);
        if (p == null) return false;

        String username = auth.getName();
        boolean isOwner = p.getOwner().getEmail().equals(username);
        boolean isAdmin = auth.getAuthorities().stream()
            .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));

        return isOwner || isAdmin;
    }
}

@Service
public class ProjectService {

    @PreAuthorize("@projectSecurity.canEdit(#projectId, authentication)")
    public void edit(Long projectId, ...) { ... }
}

Pattern enterprise — encapsulate authz logic trong dedicated bean.

3. @PostAuthorize — check after method

@PostAuthorize("returnObject.owner.email == authentication.name")
public Project findById(Long id) {
    return repo.findById(id).orElseThrow();
}

Method runs → result available → check expression on result. returnObject = method return value.

Fail → throw AccessDeniedException. Method already executed, side effect persisted — careful.

Use case:

  • Hide result if not authorized (return-then-deny).
  • Verify ownership after fetch (cheaper than join in query).

Caveat: method runs first → may load data unnecessarily. Prefer @PreAuthorize + query filter when possible.

4. @PreFilter / @PostFilter — collection filtering

@PostFilter("filterObject.owner.email == authentication.name")
public List<Project> findAll() {
    return repo.findAll();        // load all → filter
}

filterObject = each element. SpEL evaluate per element — drop nếu false.

Performance: load all → filter in-memory. Bad cho large dataset.

Better: query filter at DB:

public List<Project> findMyProjects(@AuthenticationPrincipal AppUser user) {
    return repo.findByOwnerEmail(user.getEmail());    // SQL filter
}

Use @PostFilter chỉ cho small dataset.

5. @Secured — legacy

@Secured("ROLE_ADMIN")
public void deleteAll() { ... }

@Secured({"ROLE_USER", "ROLE_ADMIN"})
public Project create(...) { ... }

Spring 1-2 era. No SpEL — just role list. Limited.

Recommend: @PreAuthorize thay @Secured. SpEL more flexible.

6. Pattern: ownership check

Common scenario — user only access their own resources:

public interface ProjectRepository extends JpaRepository<Project, Long> {
    Optional<Project> findByIdAndOwnerEmail(Long id, String ownerEmail);
}

@Service
public class ProjectService {

    public Project findById(Long id, @AuthenticationPrincipal AppUser user) {
        return repo.findByIdAndOwnerEmail(id, user.getEmail())
            .orElseThrow(() -> new ProjectNotFoundException(id));
    }
}

Query filter at DB. No load → check → reject. SQL natural authz.

Advantage:

  • Performance: 1 query.
  • 404 vs 403: hide existence (more secure — don't leak resource exists).

6.2 Approach 2 — @PostAuthorize

@Service
public class ProjectService {

    @PostAuthorize("returnObject.owner.email == authentication.name or hasRole('ADMIN')")
    public Project findById(Long id) {
        return repo.findById(id).orElseThrow();
    }
}

Load → check ownership. Easier code, but reveal "exists" via 403 vs 404.

6.3 Approach 3 — custom security bean

@Service
public class ProjectService {

    @PreAuthorize("@projectSecurity.isOwner(#id, authentication) or hasRole('ADMIN')")
    public Project findById(Long id) {
        return repo.findById(id).orElseThrow();
    }
}

Encapsulate logic. Reusable across multiple methods.

Recommend: approach 1 (query filter) cho query. Approach 3 cho complex authz logic.

7. Test method security

@SpringBootTest
@AutoConfigureMockMvc
class ProjectControllerSecurityTest {

    @Autowired MockMvc mockMvc;

    @Test
    void create_withUserRole_returns201() throws Exception {
        mockMvc.perform(post("/api/projects")
                .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_USER")))
                .contentType(MediaType.APPLICATION_JSON)
                .content("{...}"))
            .andExpect(status().isCreated());
    }

    @Test
    void deleteAll_withUserRole_returns403() throws Exception {
        mockMvc.perform(delete("/api/projects/all")
                .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_USER"))))
            .andExpect(status().isForbidden());
    }

    @Test
    void deleteAll_withAdminRole_returns204() throws Exception {
        mockMvc.perform(delete("/api/projects/all")
                .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))))
            .andExpect(status().isNoContent());
    }
}

with(jwt().authorities(...)) — Spring Security Test helper inject authenticated user.

Hoặc dùng @WithMockUser:

@Test
@WithMockUser(roles = "USER")
void method_withUser() { ... }

@Test
@WithMockUser(roles = "ADMIN")
void method_withAdmin() { ... }

@Test
@WithMockUser(username = "[email protected]", roles = {"USER", "MANAGER"})
void method_withSpecificUser() { ... }

8. Pitfall tổng hợp

Nhầm 1: @PreAuthorize trên controller method (Spring 5). ✅ Spring 6 work cả trên controller và service. Recommend service layer cho consistency.

Nhầm 2: hasRole("ROLE_ADMIN") — double prefix.

@PreAuthorize("hasRole('ROLE_ADMIN')")    // looks for "ROLE_ROLE_ADMIN"

hasRole("ADMIN") (Spring auto-prefix) hoặc hasAuthority("ROLE_ADMIN").

Nhầm 3: @PostFilter cho large dataset. ✅ Query filter at DB. PostFilter cho small in-memory.

Nhầm 4: Self-call method @PreAuthorize.

public void method1() {
    method2();    // SAME class — bypass AOP proxy → @PreAuthorize skip
}

@PreAuthorize("hasRole('ADMIN')")
public void method2() { ... }

✅ Inject self bean hoặc tách class.

Nhầm 5: Forget @EnableMethodSecurity. ✅ Add to config class. Without, annotation no-op.

9. 📚 Deep Dive Spring Reference

10. Tóm tắt

  • @EnableMethodSecurity kích hoạt AOP proxy cho method-level annotation.
  • @PreAuthorize: check before method. SpEL access authentication, #paramName, @beanName.method().
  • @PostAuthorize: check after method với returnObject. Method runs first — careful side effect.
  • @PreFilter/@PostFilter: filter collection per element. Performance bad cho large dataset.
  • @Secured legacy — use @PreAuthorize thay.
  • Ownership check pattern: query filter at DB (recommend) > custom security bean > @PostAuthorize.
  • Test method security với @WithMockUser hoặc with(jwt().authorities(...)).
  • Self-call bypass AOP — split class hoặc inject self.

11. Tự kiểm tra

Tự kiểm tra
Q1
So sánh `@PreAuthorize` vs `@PostAuthorize` vs `@PostFilter`. Khi nào pick cái nào?
AnnotationWhen checkAccessUse case
@PreAuthorizeBefore methodMethod args, authenticationMost cases — fail fast, no side effect
@PostAuthorizeAfter methodreturnObject, authentication, argsCheck ownership after load (when args don't have ID)
@PostFilterAfter method (collection)filterObject per elementFilter collection result by per-element rule

Decision:

Need check args before method?
Yes → @PreAuthorize

Need check return value (e.g. ownership of fetched object)?
Can re-check by ID → query filter (preferred — no load if not authorized)
No, must load first → @PostAuthorize

Filter list/collection per element?
Small list (< 100) → @PostFilter
Large list → query filter at DB

Examples:

// @PreAuthorize — most common
@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(Long id) { ... }

// @PostAuthorize — check after fetch
@PostAuthorize("returnObject.owner.email == authentication.name")
public Project findById(Long id) {
  return repo.findById(id).orElseThrow();
  // Side effect: query DB even if denied
}

// @PostFilter — small collection
@PostFilter("filterObject.public or filterObject.owner.email == authentication.name")
public List<Project> findRecent() {
  return repo.findTop10ByOrderByCreatedAtDesc();
}

Recommend hierarchy:

  1. Query filter at DB: best — single SQL with WHERE. No load → check → reject pattern.
  2. @PreAuthorize: when can express rule with method args.
  3. @PostAuthorize: when need fetch first (rare).
  4. @PostFilter: small collection only.
Q2
Implement "user only access project they own" với 3 approaches. Trade-off?

Approach 1 — Query filter at DB (recommend):

public interface ProjectRepository extends JpaRepository<Project, Long> {
  Optional<Project> findByIdAndOwnerEmail(Long id, String ownerEmail);
}

@Service
public class ProjectService {

  public Project findById(Long id, AppUser user) {
      return repo.findByIdAndOwnerEmail(id, user.getEmail())
          .orElseThrow(() -> new ProjectNotFoundException(id));
  }
}

@RestController
public class ProjectController {

  @GetMapping("/{id}")
  public ProjectDto get(@PathVariable Long id, @AuthenticationPrincipal AppUser user) {
      return ProjectDto.from(service.findById(id, user));
  }
}

Pros:

  • Performance: 1 SQL with WHERE clause. No unnecessary fetch.
  • Security: 404 hide existence (don't reveal "project exists but you don't own").
  • Simple code: no annotation magic.

Approach 2 — @PostAuthorize:

@Service
public class ProjectService {

  @PostAuthorize("returnObject.owner.email == authentication.name or hasRole('ADMIN')")
  public Project findById(Long id) {
      return repo.findById(id).orElseThrow();
  }
}

Pros: declarative, less code in service.

Cons:

  • Always fetch: load even if not authorized — DB load wasted.
  • 403 vs 404: reveal "project exists, you don't own". Less secure.

Approach 3 — Custom security bean + @PreAuthorize:

@Component
@RequiredArgsConstructor
public class ProjectSecurity {

  private final ProjectRepository repo;

  public boolean isOwner(Long projectId, Authentication auth) {
      return repo.findById(projectId)
          .map(p -> p.getOwner().getEmail().equals(auth.getName()))
          .orElse(false);
  }
}

@Service
public class ProjectService {

  @PreAuthorize("@projectSecurity.isOwner(#id, authentication) or hasRole('ADMIN')")
  public Project findById(Long id) {
      return repo.findById(id).orElseThrow();
  }
}

Pros: reusable security logic, encapsulate authz.

Cons: 2 query (security check + actual fetch). Performance issue cho hot path.

Comparison:

ApproachSQL countReveal existenceCode complexity
1. Query filter1404 (hide)Low
2. @PostAuthorize1403 (reveal)Low
3. Custom bean2403 (reveal)Medium

Recommend cho TaskFlow:

  • Read endpoint: approach 1 (query filter).
  • Write endpoint: approach 1 + verify ownership before write.
  • Complex authz (multi-role, hierarchy): approach 3 (custom bean).
Q3
Test pattern security với @WithMockUser vs JWT mock. Khi nào pick cái nào?

@WithMockUser — simple, generic:

@Test
@WithMockUser(roles = "USER")
void create_withUserRole() throws Exception {
  mockMvc.perform(post("/api/projects").content("..."))
      .andExpect(status().isCreated());
}

@Test
@WithMockUser(username = "[email protected]", roles = {"USER", "MANAGER"})
void update_withSpecificUser() throws Exception {
  // Authentication.name = "[email protected]"
  // Authorities = [ROLE_USER, ROLE_MANAGER]
}

Default — sets Authentication with username + authorities. Principal type: org.springframework.security.core.userdetails.User.

JWT mock — JWT-specific:

@Test
void create_withJwt() throws Exception {
  mockMvc.perform(post("/api/projects")
          .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_USER")))
          .content("..."))
      .andExpect(status().isCreated());
}

@Test
void create_withCustomJwtClaims() throws Exception {
  mockMvc.perform(post("/api/projects")
          .with(jwt()
              .jwt(builder -> builder
                  .subject("[email protected]")
                  .claim("tenantId", "acme")
                  .claim("roles", List.of("USER", "MANAGER"))
              )
              .authorities(
                  new SimpleGrantedAuthority("ROLE_USER"),
                  new SimpleGrantedAuthority("ROLE_MANAGER")
              )))
      .andExpect(status().isCreated());
}

Sets Authentication with JwtAuthenticationToken. Principal type: Jwt.

Khi nào pick cái nào:

Scenario@WithMockUserJWT mock
Test role-based authz✅ SimpleOK but verbose
Test với custom JWT claim (tenantId, ...)❌ Can't add custom claim✅ Add via .jwt() builder
App uses JWT authOK if just role test✅ Match production setup
App uses session auth✅ Match production❌ Mismatch
Custom @AuthenticationPrincipal AppUserNeed @WithUserDetailsCustom converter test

Recommend cho TaskFlow JWT-based:

  • Pure role tests: @WithMockUser — simpler.
  • Custom claim tests: JWT mock với jwt().jwt(builder -> builder.claim(...)).
  • Integration test: use real JWT issuer + decoder for end-to-end check.

@WithUserDetails — load real user from UserDetailsService:

@Test
@WithUserDetails(value = "[email protected]", userDetailsServiceBeanName = "appUserDetailsService")
void test() { ... }

Use real user from DB (with seeded test data). More realistic but slower.

Pattern:

// Layered test
- Unit test: @WithMockUser (no Spring context)
- Integration test: @WithUserDetails (real DB, real UserDetailsService)
- E2E: real JWT issued by real auth flow

Bài tiếp theo: CORS & CSRF — config + best practices

Bài này có giúp bạn hiểu bản chất không?

Bình luận (0)

Đang tải...