Tampilan
Kenapa penting
Autentikasi menjawab "kamu siapa?", otorisasi menjawab "kamu boleh apa?". Di bab-bab sebelumnya API pesananmu sudah bisa menerima JWT dari Keycloak — tetapi token yang valid belum berarti user boleh melakukan semua hal. Celah nomor satu di daftar OWASP API Security bukan token palsu, melainkan user sah yang mengakses data milik orang lain hanya dengan mengganti ID di URL. Di dunia kerja, bug otorisasi jarang terdeteksi oleh test fungsional biasa karena endpoint "berfungsi normal". Bab ini membekalimu tiga lapis pertahanan: aturan per URL, aturan per method, dan cek kepemilikan data — plus cara mengujinya.
Konsep inti
Role, authority, dan scope
Spring Security menyimpan hak akses user sebagai daftar GrantedAuthority — string bebas. Tiga istilah yang sering tercampur:
| Istilah | Arti | Bentuk di Spring Security |
|---|---|---|
| Authority | Hak akses apa pun, berupa string | orders:read, ROLE_ADMIN |
| Role | Authority berprefiks ROLE_, mewakili jabatan/kelompok | hasRole("ADMIN") → ROLE_ADMIN |
| Scope | Izin yang diberikan ke client OAuth2 lewat claim token | claim scope → SCOPE_orders:read |
Resource server bawaan memetakan claim scope (atau scp) di JWT menjadi authority berprefiks SCOPE_. Role dari Keycloak (misalnya di claim realm_access.roles) tidak dipetakan otomatis — kamu perlu converter sendiri:
java
// Potongan — konfigurasi resource server
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
var converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
if (realmAccess == null || !(realmAccess.get("roles") instanceof List<?> roles)) {
return List.of();
}
return roles.stream()
.map(role -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_" + role.toString().toUpperCase()))
.toList();
});
return converter;
}Aturan praktis: scope cocok untuk membatasi apa yang boleh dilakukan sebuah aplikasi client (misalnya aplikasi mobile hanya orders:read), sedangkan role cocok untuk membedakan jenis manusia (pelanggan, staf gudang, admin).
Aturan per URL dengan authorizeHttpRequests
Lapis pertama dan paling kasar: aturan berdasarkan path dan HTTP method di SecurityFilterChain. Aturan dievaluasi dari atas ke bawah, dan aturan pertama yang cocok menang — jadi yang spesifik ditulis lebih dulu.
java
// Potongan — Spring Security 7, lambda DSL
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.POST, "/api/products/**").hasAnyRole("ADMIN", "CATALOG_STAFF")
.requestMatchers(HttpMethod.GET, "/api/orders/**").hasAuthority("SCOPE_orders:read")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
return http.build();
}Selalu akhiri dengan anyRequest().authenticated() (atau denyAll()) agar endpoint baru yang lupa didaftarkan tidak otomatis terbuka. Ingat bedanya status: tanpa token/token tidak valid → 401, token valid tapi hak kurang → 403.
Method security dengan @PreAuthorize
Aturan URL tidak tahu apa-apa tentang data. Untuk aturan yang lebih halus, aktifkan method security lalu pasang anotasi di method service atau controller:
java
// Potongan
@Configuration
@EnableMethodSecurity
class MethodSecurityConfig {}
@Service
class RefundService {
@PreAuthorize("hasRole('ADMIN') or hasRole('FINANCE')")
public Refund approve(long refundId) {
// ...
}
// #customerId merujuk parameter method; authentication.name = subject JWT
@PreAuthorize("hasRole('ADMIN') or #customerId == authentication.name")
public List<OrderSummary> listOrders(String customerId) {
// ...
}
}@PreAuthorize dievaluasi sebelum method jalan; bila ekspresinya false, Spring melempar AuthorizationDeniedException yang diterjemahkan menjadi 403. Ada juga @PostAuthorize yang memeriksa nilai kembalian (returnObject), tetapi method sudah terlanjur dieksekusi — jangan dipakai untuk operasi yang mengubah data. Untuk logika yang panjang, panggil bean: @PreAuthorize("@orderAccess.canView(#orderId, authentication)").
Kenapa method security bekerja lewat proxy
Spring membungkus bean dengan proxy yang mengecek anotasi sebelum meneruskan panggilan. Konsekuensinya, method yang dipanggil dari method lain di class yang sama tidak melewati proxy, sehingga @PreAuthorize-nya tidak dievaluasi. Mekanismenya sama dengan @Transactional di M08.
Kepemilikan resource: mencegah IDOR/BOLA
IDOR (Insecure Direct Object Reference) — oleh OWASP API disebut BOLA (Broken Object Level Authorization) — terjadi saat API menerima ID objek dari client lalu mengembalikannya tanpa memeriksa apakah objek itu milik user saat ini. Role tidak menolong: semua pelanggan punya role CUSTOMER yang sama. Logikanya bisa diperagakan dengan Java murni:
java
import java.util.Map;
import java.util.Optional;
import java.util.Set;
public class OwnershipCheck {
record CurrentUser(String id, Set<String> authorities) {
boolean has(String authority) {
return authorities.contains(authority);
}
}
record Order(long id, String customerId, long total) {}
static final Map<Long, Order> ORDERS = Map.of(
1001L, new Order(1001L, "u-ani", 250_000),
1002L, new Order(1002L, "u-budi", 90_000));
// Pemilik boleh melihat pesanannya; admin boleh melihat semua.
// Bukan pemilik -> "tidak ditemukan", agar keberadaan ID tidak bocor.
static Optional<Order> findVisibleOrder(long orderId, CurrentUser user) {
return Optional.ofNullable(ORDERS.get(orderId))
.filter(o -> o.customerId().equals(user.id()) || user.has("ROLE_ADMIN"));
}
public static void main(String[] args) {
var ani = new CurrentUser("u-ani", Set.of("ROLE_CUSTOMER"));
var admin = new CurrentUser("u-ops", Set.of("ROLE_ADMIN"));
System.out.println("ani -> 1001: " + findVisibleOrder(1001L, ani).map(Order::total).orElse(-1L));
System.out.println("ani -> 1002: " + findVisibleOrder(1002L, ani).map(Order::total).orElse(-1L));
System.out.println("admin-> 1002: " + findVisibleOrder(1002L, admin).map(Order::total).orElse(-1L));
}
}text
ani -> 1001: 250000
ani -> 1002: -1
admin-> 1002: 90000Dua prinsip penting: ID user diambil dari token, bukan dari request (jangan percaya customerId di body atau query string), dan objek milik orang lain dijawab seperti objek yang tidak ada (404), sehingga penyerang tidak bisa memetakan ID mana yang valid. Di aplikasi nyata, cara paling efisien adalah memasukkan syarat pemilik langsung ke query: findByIdAndCustomerId(id, userId).
Mengambil user saat ini
Di controller, @AuthenticationPrincipal menyuntikkan principal dari SecurityContext. Untuk resource server JWT, principal-nya bertipe Jwt:
java
// Potongan
@GetMapping("/api/me")
MeResponse me(@AuthenticationPrincipal Jwt jwt) {
return new MeResponse(jwt.getSubject(), jwt.getClaimAsString("email"));
}jwt.getSubject() (claim sub) adalah ID user yang stabil di Keycloak — pakai itu sebagai kunci kepemilikan, jangan email atau username yang bisa berubah. Di luar controller, kamu bisa membaca SecurityContextHolder.getContext().getAuthentication(), tetapi meneruskan ID user sebagai parameter eksplisit ke service membuat kode lebih mudah dites.
Menguji aturan keamanan
Aturan otorisasi wajib punya test, termasuk kasus ditolak. Modul spring-security-test menyediakan dua alat utama:
java
// Potongan — test slice MVC dengan SecurityFilterChain aplikasi di-import
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
@WithMockUser(roles = "CUSTOMER")
void customerCannotOpenAdminReports() throws Exception {
mockMvc.perform(get("/api/admin/reports")).andExpect(status().isForbidden());
}
@Test
void anonymousGets401() throws Exception {
mockMvc.perform(get("/api/orders/1001")).andExpect(status().isUnauthorized());
}
@Test
void ownerCanReadOwnOrder() throws Exception {
mockMvc.perform(get("/api/orders/1001")
.with(jwt().jwt(j -> j.subject("u-ani"))
.authorities(new SimpleGrantedAuthority("SCOPE_orders:read"))))
.andExpect(status().isOk());
}@WithMockUser cocok untuk aturan berbasis role/URL; jwt() diperlukan bila kode membaca Jwt (subject, claim). Keduanya tidak memanggil authorization server sungguhan, sehingga test tetap cepat.
Contoh di dunia backend
API pesanan toko online: pelanggan hanya boleh melihat dan membatalkan pesanannya sendiri, staf SUPPORT boleh melihat semua pesanan, dan hanya ADMIN yang boleh mengembalikan dana.
java
// Potongan — controller + service pesanan
@RestController
@RequestMapping("/api/orders")
class OrderController {
private final OrderService orders;
OrderController(OrderService orders) {
this.orders = orders;
}
@GetMapping("/{id}")
OrderResponse get(@PathVariable long id, @AuthenticationPrincipal Jwt jwt) {
return orders.findForUser(id, jwt.getSubject(), isSupport(jwt));
}
@PostMapping("/{id}/cancel")
OrderResponse cancel(@PathVariable long id, @AuthenticationPrincipal Jwt jwt) {
return orders.cancelOwnOrder(id, jwt.getSubject());
}
@PostMapping("/{id}/refund")
@PreAuthorize("hasRole('ADMIN')")
OrderResponse refund(@PathVariable long id) {
return orders.refund(id);
}
private static boolean isSupport(Jwt jwt) {
var realmAccess = jwt.getClaimAsMap("realm_access");
return realmAccess != null
&& realmAccess.get("roles") instanceof List<?> roles
&& roles.contains("support");
}
}
@Service
class OrderService {
private final OrderRepository repository;
OrderService(OrderRepository repository) {
this.repository = repository;
}
@Transactional(readOnly = true)
public OrderResponse findForUser(long id, String userId, boolean support) {
var order = support
? repository.findById(id)
: repository.findByIdAndCustomerId(id, userId); // syarat pemilik di query
return order.map(OrderResponse::from)
.orElseThrow(() -> new OrderNotFoundException(id)); // 404 untuk "tidak ada" & "bukan milikmu"
}
@Transactional
public OrderResponse cancelOwnOrder(long id, String userId) {
var order = repository.findByIdAndCustomerId(id, userId)
.orElseThrow(() -> new OrderNotFoundException(id));
order.cancel(); // aturan bisnis: hanya status PENDING yang bisa dibatalkan
return OrderResponse.from(order);
}
@Transactional
public OrderResponse refund(long id) {
// ...
}
}Perhatikan bahwa cancelOwnOrder tidak menerima customerId dari request — hanya dari token. Bila kamu sudah memasang converter role dari bab ini, isSupport cukup diganti dengan memeriksa authority ROLE_SUPPORT di objek Authentication.
Pertanyaan interview
1. Apa beda hasRole("ADMIN") dan hasAuthority("ADMIN")?
hasRole("ADMIN") menambahkan prefiks dan sebenarnya mencari authority ROLE_ADMIN, sedangkan hasAuthority("ADMIN") mencari string ADMIN persis. Bila converter-mu menghasilkan ROLE_ADMIN, pakai hasRole; untuk scope OAuth2 (SCOPE_orders:read) pakai hasAuthority.
2. Apa itu BOLA/IDOR dan bagaimana mencegahnya di Spring?
User yang sah mengakses objek milik user lain dengan memanipulasi ID di request. Pencegahannya adalah cek kepemilikan di setiap akses objek: ambil ID user dari token (jwt.getSubject()), masukkan sebagai syarat query (findByIdAndCustomerId) atau evaluasi di @PreAuthorize lewat bean, lalu jawab 404 untuk objek milik orang lain. Lengkapi dengan test yang membuktikan user A tidak bisa membaca data user B.
3. Kapan memakai aturan URL dan kapan method security?
Aturan URL di SecurityFilterChain cocok untuk pembatasan kasar dan berlaku sebelum request menyentuh controller — misalnya seluruh /api/admin/** hanya untuk admin. Method security cocok untuk aturan yang bergantung pada parameter atau data, dan tetap melindungi service walaupun dipanggil dari jalur lain (misalnya consumer pesan). Keduanya saling melengkapi, bukan saling menggantikan.
Jebakan umum
- Hanya mengecek "sudah login".
.authenticated()tidak mencegah user A membaca data user B — selalu cek kepemilikan. - Memercayai
customerIddari body/query. ID user harus diambil dari token yang sudah diverifikasi. - Lupa
@EnableMethodSecurity.@PreAuthorizediabaikan tanpa error apa pun; buktikan lewat test yang mengharapkan 403. - Urutan
requestMatchersterbalik.anyRequest()atau pola lebar di atas membuat aturan spesifik di bawahnya tidak pernah dievaluasi. - Mencampur role dan scope.
hasRole("ADMIN")tidak cocok dengan authoritySCOPE_admin; periksa authority yang benar-benar dihasilkan converter. - Hanya menguji jalur sukses. Test otorisasi yang berharga justru yang membuktikan akses ditolak.
Ringkasan
- Authority adalah string hak akses; role = authority berprefiks
ROLE_; scope JWT dipetakan menjadiSCOPE_.... authorizeHttpRequestsmengatur akses per URL, dievaluasi berurutan, diakhirianyRequest().authenticated().@EnableMethodSecurity+@PreAuthorizememberi aturan per method yang bisa membaca parameter dan bean.- Cegah IDOR/BOLA: ID user dari token, syarat pemilik di query, objek milik orang lain dijawab 404.
@AuthenticationPrincipal Jwtmemberi akses ke subject dan claim user saat ini.- Uji aturan dengan
@WithMockUserdanjwt(), termasuk kasus 401 dan 403.