Tampilan
Kenapa penting
Di M08 kamu membangun API pesanan dengan PostgreSQL dan Flyway — tetapi siapa pun bisa membaca dan membuat pesanan atas nama siapa saja. Proyek mini ini menggabungkan seluruh modul M09 menjadi satu alur kerja nyata: memverifikasi JWT, memetakan role, mencegah customer mengintip pesanan orang lain, membatasi request berlebihan, mendokumentasikan kontrak, dan membuktikan semuanya lewat test. Pola ini persis yang akan kamu temui saat mengerjakan tiket "amankan endpoint X" di tim backend: perubahannya kecil di kode, tetapi harus lengkap dan teruji karena satu celah saja cukup untuk membocorkan data pelanggan.
Konsep inti
Target dan aturan akses
| Endpoint | Siapa | Aturan |
|---|---|---|
POST /api/orders | CUSTOMER | Pemilik = sub dari JWT; harga dihitung server |
GET /api/orders | CUSTOMER | Hanya pesanan milik sendiri, berpaginasi |
GET /api/orders/{id} | CUSTOMER | Pesanan orang lain → 404 |
GET /api/admin/orders | ADMIN | Semua pesanan |
PATCH /api/admin/orders/{id} | ADMIN | Ubah status (mis. SHIPPED) |
/v3/api-docs/**, Swagger UI | Publik (non-produksi) | Dimatikan di profil prod |
Token diterbitkan Keycloak (bab OAuth2); role ada di claim realm_access.roles dan dipetakan ke ROLE_CUSTOMER/ROLE_ADMIN dengan JwtAuthenticationConverter seperti di bab otorisasi.
Langkah 1 — Dependency dan konfigurasi resource server
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>3.1.1</version>
</dependency>yaml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: ${JWT_ISSUER_URI:http://localhost:8180/realms/shop}issuer-uri membuat Spring mengambil kunci publik (JWKS) dari authorization server dan memvalidasi tanda tangan, exp, dan iss. Nilainya dari environment variable — jangan ditulis permanen untuk produksi.
Langkah 2 — Security filter chain
java
// Potongan
@Configuration
@EnableMethodSecurity
class SecurityConfig {
@Bean
SecurityFilterChain api(HttpSecurity http, RateLimitFilter rateLimitFilter,
JwtAuthenticationConverter jwtConverter) throws Exception {
http
.csrf(csrf -> csrf.disable()) // API stateless dengan bearer token, tanpa cookie sesi
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/orders/**").hasRole("CUSTOMER")
.anyRequest().denyAll())
.oauth2ResourceServer(rs -> rs.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter)))
.addFilterAfter(rateLimitFilter, BearerTokenAuthenticationFilter.class);
return http.build();
}
}anyRequest().denyAll() adalah default yang aman: endpoint baru yang lupa didaftarkan otomatis tertutup, bukan terbuka.
Langkah 3 — Cek kepemilikan di query
Cara paling sulit dilupakan adalah memasukkan pemilik ke query itu sendiri:
java
// Potongan
interface OrderRepository extends JpaRepository<Order, Long> {
Optional<Order> findByIdAndCustomerId(Long id, String customerId);
Page<Order> findByCustomerId(String customerId, Pageable pageable);
}
@Service
class OrderService {
private final OrderRepository orders;
OrderService(OrderRepository orders) {
this.orders = orders;
}
@Transactional(readOnly = true)
OrderResponse getOwn(long id, String customerId) {
return orders.findByIdAndCustomerId(id, customerId)
.map(OrderResponse::from)
.orElseThrow(() -> new OrderNotFoundException(id)); // dipetakan ke ProblemDetail 404
}
}Controller hanya meneruskan jwt.getSubject(); DTO request tidak punya field customerId.
Langkah 4 — Rate limit sederhana
Untuk belajar, kita pakai algoritma fixed window: hitung request per user per menit, tolak bila lewat batas. Logika intinya bisa diuji tanpa Spring:
java
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class FixedWindowRateLimiter {
private record Window(long windowStart, int count) {}
private final int limit;
private final long windowMillis;
private final Map<String, Window> windows = new ConcurrentHashMap<>();
public FixedWindowRateLimiter(int limit, Duration window) {
this.limit = limit;
this.windowMillis = window.toMillis();
}
public boolean tryAcquire(String key, Instant now) {
long start = now.toEpochMilli() / windowMillis * windowMillis;
Window updated = windows.compute(key, (k, w) ->
(w == null || w.windowStart() != start) ? new Window(start, 1)
: new Window(start, w.count() + 1));
return updated.count() <= limit;
}
public static void main(String[] args) {
var limiter = new FixedWindowRateLimiter(3, Duration.ofMinutes(1));
Instant t0 = Instant.parse("2026-10-23T07:00:00Z");
for (int i = 1; i <= 4; i++) {
System.out.println("budi request " + i + ": " + limiter.tryAcquire("budi", t0.plusSeconds(i)));
}
System.out.println("sari request 1: " + limiter.tryAcquire("sari", t0.plusSeconds(5)));
System.out.println("budi menit berikutnya: " + limiter.tryAcquire("budi", t0.plusSeconds(61)));
}
}text
budi request 1: true
budi request 2: true
budi request 3: true
budi request 4: false
sari request 1: true
budi menit berikutnya: truecompute di ConcurrentHashMap atomik per kunci, jadi aman dipanggil banyak thread. Waktu dijadikan parameter (Instant now) agar test tidak bergantung jam sistem. Lalu bungkus sebagai filter:
java
// Potongan
@Component
class RateLimitFilter extends OncePerRequestFilter {
private final FixedWindowRateLimiter limiter = new FixedWindowRateLimiter(60, Duration.ofMinutes(1));
private final Clock clock;
RateLimitFilter(Clock clock) {
this.clock = clock;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String key = (auth != null && auth.isAuthenticated()) ? "user:" + auth.getName()
: "ip:" + request.getRemoteAddr();
if (!limiter.tryAcquire(key, clock.instant())) {
response.setStatus(429);
response.setHeader("Retry-After", "60");
response.setContentType("application/problem+json");
response.getWriter().write("{\"title\":\"Too Many Requests\",\"status\":429}");
return;
}
chain.doFilter(request, response);
}
}Batas versi sederhana
Map di memori tidak berbagi state antar instance dan tidak pernah dibersihkan. Di produksi, rate limit biasanya ditaruh di API gateway atau memakai store bersama (Redis) dengan library khusus. Kalau aplikasi berada di balik reverse proxy, getRemoteAddr() berisi IP proxy — konfigurasikan forwarded headers dengan benar.
Langkah 5 — OpenAPI
Pasang OpenApiConfig dari bab sebelumnya (@SecurityScheme bearer JWT), tambahkan @Tag dan @ApiResponse untuk 401, 403, 404, dan 429, lalu matikan springdoc di application-prod.yaml. Buka Swagger UI, klik Authorize, tempel access token dari Keycloak, dan coba endpoint.
Langkah 6 — Test keamanan
Post-processor jwt() dari SecurityMockMvcRequestPostProcessors membuat token palsu tanpa Keycloak, sehingga test cepat:
java
// Potongan
@WebMvcTest(OrderController.class)
@Import(SecurityConfig.class)
class OrderSecurityTest {
@Autowired MockMvc mvc;
@MockitoBean OrderService orderService;
@Test
void tanpaToken_401() throws Exception {
mvc.perform(get("/api/orders/1")).andExpect(status().isUnauthorized());
}
@Test
void customerKeEndpointAdmin_403() throws Exception {
mvc.perform(get("/api/admin/orders")
.with(jwt().authorities(new SimpleGrantedAuthority("ROLE_CUSTOMER"))))
.andExpect(status().isForbidden());
}
@Test
void pesananOrangLain_404() throws Exception {
given(orderService.getOwn(42L, "u-ani")).willThrow(new OrderNotFoundException(42L));
mvc.perform(get("/api/orders/42")
.with(jwt().jwt(j -> j.subject("u-ani"))
.authorities(new SimpleGrantedAuthority("ROLE_CUSTOMER"))))
.andExpect(status().isNotFound());
}
@Test
void pesananSendiri_200() throws Exception {
given(orderService.getOwn(7L, "u-ani")).willReturn(sampleOrder(7L));
mvc.perform(get("/api/orders/7")
.with(jwt().jwt(j -> j.subject("u-ani"))
.authorities(new SimpleGrantedAuthority("ROLE_CUSTOMER"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(7));
}
}Bean pendukung (RateLimitFilter, Clock, JwtAuthenticationConverter) perlu ikut di-@Import atau disediakan di test; JwtDecoder bisa diganti @MockitoBean agar test tidak menghubungi Keycloak. Tambahkan juga test kepemilikan di level repository dengan Testcontainers PostgreSQL: findByIdAndCustomerId milik orang lain harus kosong.
Contoh di dunia backend
Sebelum merge, reviewer di tim biasanya menilai perubahan dengan checklist berbasis OWASP API Security Top 10 (2023). Versi ringkas untuk proyek ini:
| Risiko OWASP | Bukti di proyek |
|---|---|
| API1 Broken Object Level Authorization | findByIdAndCustomerId + test 404 pesanan orang lain |
| API2 Broken Authentication | Resource server memvalidasi tanda tangan, exp, iss; test 401 |
| API3 Broken Object Property Level Authorization | DTO request tanpa customerId/status; response tanpa field internal |
| API4 Unrestricted Resource Consumption | Rate limit 429, size paginasi dibatasi maksimum |
| API5 Broken Function Level Authorization | /api/admin/** butuh ROLE_ADMIN; test 403 |
| API8 Security Misconfiguration | denyAll() default, Swagger UI mati di prod, error tanpa stack trace |
| API9 Improper Inventory Management | Kontrak OpenAPI terbaru di repo, endpoint lama dicatat & dimatikan |
Latihan kode untuk proyek mini ini menyusul di folder exercises/ dan akan memakai Testcontainers sesuai standar latihan Spring Boot.
Pertanyaan interview
1. Bagaimana kamu mencegah user membaca data milik user lain di REST API?
Ambil identitas dari token yang sudah diverifikasi, bukan dari parameter request, lalu sertakan pemilik di query (findByIdAndCustomerId) atau cek di @PreAuthorize. Data milik orang lain diperlakukan sebagai tidak ada (404) agar ID tidak bisa dienumerasi. Aturan ini dikunci dengan test yang memakai dua user berbeda.
2. Apa beda 401 dan 403, dan bagaimana mengujinya di Spring?
401 berarti request tidak terautentikasi: token tidak ada, kedaluwarsa, atau tanda tangannya salah. 403 berarti terautentikasi tetapi tidak berhak. Di MockMvc, request tanpa token menguji 401, sedangkan with(jwt().authorities(...)) dengan authority yang kurang menguji 403.
3. Di mana sebaiknya rate limiting diterapkan?
Lapis pertama biasanya di API gateway atau load balancer karena menahan beban sebelum sampai aplikasi. Di aplikasi, rate limit per user berguna untuk aturan bisnis (mis. batas checkout per menit) dan harus memakai store bersama bila ada banyak instance. Respons yang benar adalah 429 dengan header Retry-After.
Jebakan umum
- Mengambil
customerIddari body atau query string, bukan darijwt.getSubject(). - Memakai
anyRequest().permitAll()sebagai default sehingga endpoint baru otomatis terbuka. - Mendaftarkan filter rate limit sebelum autentikasi, sehingga semua user dihitung per IP.
- Hanya menguji jalur sukses (
200); celah keamanan justru ada di skenario401/403/404yang tidak dites. - Membiarkan Swagger UI dan
/v3/api-docsterbuka di produksi. - Mematikan CSRF padahal aplikasi juga memakai login berbasis cookie sesi untuk halaman web.
Ringkasan
- API pesanan menjadi JWT resource server; role Keycloak dipetakan ke
ROLE_CUSTOMERdanROLE_ADMIN. - Kepemilikan dicek di query dengan identitas dari token; pesanan orang lain menghasilkan
404. - Rate limit fixed window per user mengembalikan
429+Retry-After; versi produksi butuh store bersama atau gateway. - Kontrak didokumentasikan dengan springdoc dan bearer JWT, dimatikan di profil produksi.
- Setiap aturan akses dibuktikan dengan test
401,403,404, dan200, lalu dinilai dengan checklist OWASP API Top 10.