Method security và test: @PreAuthorize, ownership, @WithMockUser, jwt()
Bài cuối rồi, tiếp đi: method security và test.
Bài cuối gom hai thứ: phân quyền ở tầng method (chặn lỗi phổ biến nhất trong API là user A xem được dữ liệu user B) và cách viết test để chứng minh security đúng.
1. Bài toán: John xem order của Alice
| orders.id | user_id | total |
|---|---|---|
| 100 | 1 (john) | 500 |
| 101 | 2 (alice) | 800 |
GET /api/orders/100 Authorization: Bearer <john-token> -> 200, đúng
GET /api/orders/101 Authorization: Bearer <john-token> -> 200 ??? sai
2. Vấn đề
authorizeHttpRequestschỉ nhìn URL./api/orders/**+authenticated()thì John qua được.hasAuthority('ORDER_READ')cũng qua, vì John có quyền đọc order nói chung.- Đây là lỗi BOLA (Broken Object Level Authorization), đứng đầu OWASP API Security Top 10. Role/permission chưa đủ, phải kiểm tra ownership.
3. Giải pháp: method security
@EnableMethodSecurity: bật@PreAuthorize,@PostAuthorize,@PreFilter,@PostFilter(Spring Security 6 thay@EnableGlobalMethodSecurity).@PreAuthorize("SpEL"): kiểm tra trước khi chạy method, dùng được tham số (#orderId),authentication, và bean (@orderSecurity).@PostAuthorize: kiểm tra sau khi chạy, trênreturnObject.- Cơ chế: Spring AOP proxy bọc bean, gọi
AuthorizationManagertrước/sau method.
4. Code từng bước
Bước 1: bật method security
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
// SecurityFilterChain như bài 4.4.8 (oauth2ResourceServer jwt)
}
Bước 2: role/permission trên method
@Service
public class OrderService {
@PreAuthorize("hasAuthority('ORDER_READ')")
public List<OrderDto> getOrders() { ... }
@PreAuthorize("hasRole('ADMIN')")
public void deleteAnyOrder(Long orderId) { ... }
}
Bước 3: ownership, ba cách
Cách 1, lọc ngay trong query (đơn giản, an toàn nhất):
public interface OrderRepository extends JpaRepository<Order, Long> {
Optional<Order> findByIdAndUserId(Long id, Long userId);
boolean existsByIdAndUserId(Long id, Long userId);
}
@PreAuthorize("hasAuthority('ORDER_READ')")
@Transactional(readOnly = true)
public OrderDto getMyOrder(Long orderId, Long currentUserId) { // currentUserId lấy từ token, KHÔNG từ query param
return orderRepository.findByIdAndUserId(orderId, currentUserId)
.map(OrderDto::from)
.orElseThrow(() -> new EntityNotFoundException("Order not found")); // 404, không lộ order người khác
}
Cách 2, bean phân quyền gọi từ SpEL (ADMIN xem tất cả, USER chỉ xem của mình):
@Component("orderSecurity")
public class OrderSecurity {
private final OrderRepository orderRepository;
public OrderSecurity(OrderRepository orderRepository) { this.orderRepository = orderRepository; }
public boolean isOwner(Long orderId, Authentication authentication) {
Long userId = Long.valueOf(authentication.getName()); // giả định JWT sub = user id
return orderRepository.existsByIdAndUserId(orderId, userId);
}
}
@PreAuthorize("hasRole('ADMIN') or @orderSecurity.isOwner(#orderId, authentication)")
public OrderDto getOrder(Long orderId) { ... }
Cách 3, @PostAuthorize kiểm tra kết quả trả về:
@PostAuthorize("hasRole('ADMIN') or returnObject.ownerId().toString() == authentication.name")
public OrderDto getOrder(Long orderId) { ... }
Lưu ý: @PostAuthorize chạy sau method, nên không dùng cho method có side effect (update, delete).
Bước 4: test
@WebMvcTest(OrderController.class)
@Import(SecurityConfig.class)
class OrderControllerSecurityTest {
@Autowired MockMvc mvc;
@MockBean OrderService orderService; // Boot 3.4+: @MockitoBean
@MockBean JwtDecoder jwtDecoder; // khỏi gọi Keycloak khi test
@Test
void noToken_returns401() throws Exception {
mvc.perform(get("/api/orders")).andExpect(status().isUnauthorized());
}
@Test
void userWithReadAuthority_returns200() throws Exception {
mvc.perform(get("/api/orders")
.with(jwt().jwt(j -> j.subject("1"))
.authorities(new SimpleGrantedAuthority("ORDER_READ"))))
.andExpect(status().isOk());
}
@Test
void userCallsAdminApi_returns403() throws Exception {
mvc.perform(delete("/api/admin/orders/101")
.with(jwt().authorities(new SimpleGrantedAuthority("ROLE_USER"))))
.andExpect(status().isForbidden());
}
}
Test method security trực tiếp ở service, không qua HTTP:
@SpringBootTest
class OrderServiceSecurityTest {
@Autowired OrderService orderService;
@Test
@WithMockUser(username = "1", roles = "USER")
void userCannotDeleteAnyOrder() {
assertThrows(AccessDeniedException.class, () -> orderService.deleteAnyOrder(101L));
}
@Test
@WithMockUser(username = "1", authorities = "ORDER_READ")
void johnCannotReadAliceOrder() {
assertThrows(AccessDeniedException.class, () -> orderService.getOrder(101L));
}
}
# kiểm tra tay bằng curl
curl -H "Authorization: Bearer $JOHN_TOKEN" localhost:8080/api/orders/100 # 200
curl -H "Authorization: Bearer $JOHN_TOKEN" localhost:8080/api/orders/101 # 403 (hoặc 404 với cách 1)
5. Cách hoạt động
HTTP request + JWT
|
v
Security filter chain
| BearerTokenAuthenticationFilter -> 401 nếu token sai/hết hạn
| AuthorizationFilter (URL rules) -> 403 nếu sai role theo URL
v
Controller
|
v
OrderService (CGLIB proxy)
| AuthorizationManagerBeforeMethodInterceptor
| evaluate "hasRole('ADMIN') or @orderSecurity.isOwner(#orderId, authentication)"
| false -> AccessDeniedException -> 403
v
OrderService.getOrder() thật
| (@PostAuthorize: kiểm tra returnObject ở đây)
v
Repository: findByIdAndUserId (lớp cuối)
6. So sánh
| Tầng | Ví dụ | Bảo vệ |
|---|---|---|
| Filter chain | /api/admin/** → hasRole('ADMIN') | Thô, theo URL |
| Method security | @PreAuthorize("hasAuthority('ORDER_READ')") | Theo hành động, cả khi gọi từ job/consumer |
| Business logic | Order phải thuộc user hiện tại | Ownership |
| Database query | findByIdAndUserId() | Không bao giờ trả dữ liệu người khác |
| Công cụ test | Dùng khi |
|---|---|
@WithMockUser | Test service / method security, user giả đơn giản |
jwt() (MockMvc post-processor) | Test controller của resource server, giả claim và authority |
user(), httpBasic(), csrf() | Test session login, form, endpoint có CSRF |
@WithUserDetails | Cần load user thật từ UserDetailsService |
7. Khi nào nên dùng / không nên dùng
- Nên:
@PreAuthorizeở service cho quy tắc nghiệp vụ; URL rule ở filter chain cho quy tắc thô. - Nên: mỗi endpoint có ít nhất ba test: không token (401), sai quyền (403), đúng quyền (200).
- Không nên: nhét logic phức tạp vào chuỗi SpEL; tách ra bean như
@orderSecurity. - Không nên: tin
userIddo client gửi lên; luôn lấy từAuthentication.
@PreAuthorize rồi mà gọi method vẫn không bị chặn, vì sao?Mình gắn @PreAuthorize trên method của service rồi mà gọi vẫn không bị chặn. Có những lý do nào?
Thường do một trong mấy lý do sau, cái đầu tiên hay gặp nhất khi phỏng vấn:
- Self-invocation: method A trong cùng class gọi
this.deleteAnyOrder(). Lời gọi không đi qua proxy nên annotation bị bỏ qua.
Sửa: chuyển method sang bean khác, hoặc kiểm tra quyền ở method public được gọi từ ngoài.public void cleanup() { deleteAnyOrder(101L); // this.deleteAnyOrder -> KHÔNG qua proxy -> không check } - Quên
@EnableMethodSecurity. - Method
privatehoặc object tựnew, không phải Spring bean. hasRole('ADMIN')nhưng authority thật làADMINthiếu prefixROLE_(nhớ converter ở bài 4.4.8).
Phỏng vấn hỏi method security và test security thì trả lời sao?
Q1. Làm sao chặn user A truy cập tài nguyên của user B?
🇻🇳 Tiếng Việt:
Đăng nhập và role là chưa đủ, đó là lỗi BOLA. Em kiểm tra ownership ở service và ở query, ví dụ
findByIdAndUserIdvới userId lấy từ token, hoặc@PreAuthorize("hasRole('ADMIN') or @orderSecurity.isOwner(#id, authentication)"). Với API chỉ cho chủ sở hữu, trả 404 để không lộ tài nguyên tồn tại.
🇬🇧 English:
Authentication and roles are not enough; that is the BOLA vulnerability. I enforce ownership in the service and data layers, for example
findByIdAndUserIdwith the user ID taken from the token, or@PreAuthorizecalling an authorization bean. For owner-only APIs I return 404 so we do not reveal that the resource exists.
Q2. @PreAuthorize hoạt động thế nào, có giới hạn gì?
🇻🇳 Tiếng Việt:
@EnableMethodSecuritytạo AOP proxy quanh bean. Trước khi gọi method, interceptor đánh giá biểu thức SpEL vớiauthenticationvà tham số method, sai thì némAccessDeniedExceptionthành 403. Giới hạn: gọi nội bộ trong cùng class không qua proxy nên không được kiểm tra.
🇬🇧 English:
@EnableMethodSecuritywraps beans in an AOP proxy. Before the method runs, an interceptor evaluates the SpEL expression against theauthenticationand method arguments, and throwsAccessDeniedException, which becomes a 403. The limitation is self-invocation: calls inside the same class bypass the proxy.
Q3. Test Spring Security thế nào?
🇻🇳 Tiếng Việt:
Với controller resource server em dùng
@WebMvcTest+ MockMvc +jwt()để giả token có claim và authority, kiểm 401, 403, 200. Với method security em dùng@WithMockUservà assertAccessDeniedException. MockJwtDecoderđể test không phụ thuộc Keycloak.
🇬🇧 English:
For resource-server controllers I use
@WebMvcTestwith MockMvc and thejwt()post-processor to fake claims and authorities, and assert 401, 403 and 200. For method security I use@WithMockUserand assertAccessDeniedException. I mock theJwtDecoderso tests do not depend on Keycloak.
Cách nhớ ngắn gọn
- URL rule thô,
@PreAuthorizenghiệp vụ, query lọc theo owner. - Logged in ≠ được phép: luôn kiểm tra ownership (BOLA).
- Proxy: self-invocation không bị kiểm tra.
- Test: không token 401, sai quyền 403, đúng quyền 200;
jwt()cho controller,@WithMockUsercho service.
Tổng kết chương Spring Security (4.4.1 – 4.4.10)
| Bài | Chủ đề | Ý chính cần nhớ |
|---|---|---|
| 4.4.1 | Filter chain | DelegatingFilterProxy → FilterChainProxy → nhiều SecurityFilterChain; cấu hình bằng bean, không còn WebSecurityConfigurerAdapter |
| 4.4.2 | Authentication flow | Filter → AuthenticationManager → AuthenticationProvider → UserDetailsService + PasswordEncoder → SecurityContextHolder |
| 4.4.3 | Authorization | 401 vs 403, hasRole thêm ROLE_, hasAuthority so chính xác, RBAC |
| 4.4.4 | Session login | JSESSIONID, session fixation, cần CSRF |
| 4.4.5 | JWT | Header.Payload.Signature, stateless, verify chữ ký + exp mỗi request |
| 4.4.6 | Access / refresh token | Access ngắn, refresh dài lưu hash, rotation + reuse detection, denylist jti |
| 4.4.7 | OAuth2 / OIDC | 4 vai trò, Authorization Code + PKCE, Client Credentials, ID Token vs Access Token, SSO |
| 4.4.8 | Resource Server / Keycloak | issuer-uri → JWKS, verify cục bộ RS256, map realm_access, kiểm aud, Token Relay |
| 4.4.9 | Password / CORS / CSRF | BCrypt/Argon2 qua DelegatingPasswordEncoder; CORS trong Security vì preflight; CSRF khi có cookie auth |
| 4.4.10 | Method security / test | @PreAuthorize + ownership chống BOLA; MockMvc jwt(), @WithMockUser |
Tự kiểm tra
John có ORDER_READ, gọi GET /api/orders/101 của Alice. Chỉ có @PreAuthorize("hasAuthority('ORDER_READ')") thì sao?
Qua được, lộ dữ liệu: lỗi BOLA. Cần thêm kiểm tra ownership.
Vì sao không dùng @PostAuthorize cho method delete?
Nó kiểm tra sau khi method chạy, lúc đó dữ liệu đã bị xóa rồi.
cleanup() gọi this.deleteAnyOrder() có @PreAuthorize. Có bị kiểm tra không?
Không, self-invocation không đi qua proxy.
Khi test controller resource server, làm sao không phải chạy Keycloak?
Dùng MockMvc jwt() để tạo authentication giả và mock bean JwtDecoder.
@WithMockUser(roles = "ADMIN") tạo authority gì?
ROLE_ADMIN. Muốn authority chính xác thì dùng authorities = "ORDER_READ".