Method security và test: @PreAuthorize, ownership, @WithMockUser, jwt()

3 câu hỏi~30 phútTừ khóa: @EnableMethodSecurity, @PreAuthorize, SpEL, @PostAuthorize, BOLA, ownership, self-invocation, @WithMockUser, MockMvc, 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.iduser_idtotal
1001 (john)500
1012 (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 đề

3. Giải pháp: method security

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ầngVí 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 logicOrder phải thuộc user hiện tạiOwnership
Database queryfindByIdAndUserId()Không bao giờ trả dữ liệu người khác
Công cụ testDùng khi
@WithMockUserTest 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
@WithUserDetailsCần load user thật từ UserDetailsService

7. Khi nào nên dùng / không nên dùng

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:

  1. 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.
    public void cleanup() {
        deleteAnyOrder(101L);   // this.deleteAnyOrder -> KHÔNG qua proxy -> không check
    }
    Sửa: chuyển method sang bean khác, hoặc kiểm tra quyền ở method public được gọi từ ngoài.
  2. Quên @EnableMethodSecurity.
  3. Method private hoặc object tự new, không phải Spring bean.
  4. hasRole('ADMIN') nhưng authority thật là ADMIN thiếu prefix ROLE_ (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ụ findByIdAndUserId vớ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 findByIdAndUserId with the user ID taken from the token, or @PreAuthorize calling 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:

@EnableMethodSecurity tạo AOP proxy quanh bean. Trước khi gọi method, interceptor đánh giá biểu thức SpEL với authentication và tham số method, sai thì ném AccessDeniedException thà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:

@EnableMethodSecurity wraps beans in an AOP proxy. Before the method runs, an interceptor evaluates the SpEL expression against the authentication and method arguments, and throws AccessDeniedException, 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 @WithMockUser và assert AccessDeniedException. Mock JwtDecoder để test không phụ thuộc Keycloak.

🇬🇧 English:

For resource-server controllers I use @WebMvcTest with MockMvc and the jwt() post-processor to fake claims and authorities, and assert 401, 403 and 200. For method security I use @WithMockUser and assert AccessDeniedException. I mock the JwtDecoder so tests do not depend on Keycloak.

Cách nhớ ngắn gọn

Tổng kết chương Spring Security (4.4.1 – 4.4.10)

BàiChủ đềÝ chính cần nhớ
4.4.1Filter chainDelegatingFilterProxy → FilterChainProxy → nhiều SecurityFilterChain; cấu hình bằng bean, không còn WebSecurityConfigurerAdapter
4.4.2Authentication flowFilter → AuthenticationManager → AuthenticationProvider → UserDetailsService + PasswordEncoder → SecurityContextHolder
4.4.3Authorization401 vs 403, hasRole thêm ROLE_, hasAuthority so chính xác, RBAC
4.4.4Session loginJSESSIONID, session fixation, cần CSRF
4.4.5JWTHeader.Payload.Signature, stateless, verify chữ ký + exp mỗi request
4.4.6Access / refresh tokenAccess ngắn, refresh dài lưu hash, rotation + reuse detection, denylist jti
4.4.7OAuth2 / OIDC4 vai trò, Authorization Code + PKCE, Client Credentials, ID Token vs Access Token, SSO
4.4.8Resource Server / Keycloakissuer-uri → JWKS, verify cục bộ RS256, map realm_access, kiểm aud, Token Relay
4.4.9Password / CORS / CSRFBCrypt/Argon2 qua DelegatingPasswordEncoder; CORS trong Security vì preflight; CSRF khi có cookie auth
4.4.10Method 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".