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— currentAuthentication.principal—authentication.principalshortcut.#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:
6.1 Approach 1 — query filter (recommended)
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
@EnableMethodSecuritykích hoạt AOP proxy cho method-level annotation.@PreAuthorize: check before method. SpEL accessauthentication,#paramName,@beanName.method().@PostAuthorize: check after method vớireturnObject. Method runs first — careful side effect.@PreFilter/@PostFilter: filter collection per element. Performance bad cho large dataset.@Securedlegacy — use@PreAuthorizethay.- Ownership check pattern: query filter at DB (recommend) > custom security bean >
@PostAuthorize. - Test method security với
@WithMockUserhoặcwith(jwt().authorities(...)). - Self-call bypass AOP — split class hoặc inject self.
11. Tự kiểm tra
Q1So sánh `@PreAuthorize` vs `@PostAuthorize` vs `@PostFilter`. Khi nào pick cái nào?▸
| Annotation | When check | Access | Use case |
|---|---|---|---|
@PreAuthorize | Before method | Method args, authentication | Most cases — fail fast, no side effect |
@PostAuthorize | After method | returnObject, authentication, args | Check ownership after load (when args don't have ID) |
@PostFilter | After method (collection) | filterObject per element | Filter 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 DBExamples:
// @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:
- Query filter at DB: best — single SQL with WHERE. No load → check → reject pattern.
- @PreAuthorize: when can express rule with method args.
- @PostAuthorize: when need fetch first (rare).
- @PostFilter: small collection only.
Q2Implement "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:
| Approach | SQL count | Reveal existence | Code complexity |
|---|---|---|---|
| 1. Query filter | 1 | 404 (hide) | Low |
| 2. @PostAuthorize | 1 | 403 (reveal) | Low |
| 3. Custom bean | 2 | 403 (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).
Q3Test 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 | @WithMockUser | JWT mock |
|---|---|---|
| Test role-based authz | ✅ Simple | OK but verbose |
| Test với custom JWT claim (tenantId, ...) | ❌ Can't add custom claim | ✅ Add via .jwt() builder |
| App uses JWT auth | OK if just role test | ✅ Match production setup |
| App uses session auth | ✅ Match production | ❌ Mismatch |
Custom @AuthenticationPrincipal AppUser | Need @WithUserDetails | Custom 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 flowBà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...