Hasil Audit: Ketika "Kode Berjalan Lancar" Berhadapan dengan Realitas Produksi
Pada Part 1 dari series ini, kita telah membahas arsitektur makro membangun ulang aplikasi ujian sekolah monolitik menjadi platform modern terpisah menggunakan Java 21 LTS, Spring Boot 3.4, React 19, dan Tailwind CSS v4 (tersedia open-source di mfarim/spring-elearning-react).
Secara sekilas di fase awal, platform tersebut tampak sangat solid:
- Antarmuka web responsif di layar ponsel maupun desktop.
- Spring Data JPA, migrasi skema Flyway, dan caching Redis terpasang rapi.
- Manajemen ujian, kelas, tugas, serta telemetri WebSocket STOMP berjalan mulus di lingkungan pengujian lokal (development).
Namun, saat sebuah aplikasi web bersiap bertransformasi dari sekadar repositori open-source menjadi sistem ujian Computer Based Test (CBT) nyata yang digunakan oleh ribuan siswa di sekolah, sebuah pertanyaan kritis wajib dijawab:
"Apakah kode ini benar-benar siap produksi (production-ready), atau masih menyimpan celah keamanan laten dan bug runtime yang dapat merusak pelaksanaan ujian?"
Melalui audit arsitektur menyeluruh, ditemukan celah keamanan kritis, jebakan kadaluwarsa token yang melempar siswa keluar ruangan ujian, dan risiko kehilangan data container Docker. Artikel ini mengupas tuntas Pull Request #1: feat/production-readiness-and-security-hardening—menganalisis akar masalah (root causes), skenario eksploitasi, dan solusi rekayasa yang diterapkan demi ketahanan tingkat enterprise.
Konteks Arsitektur: Clean Architecture vs. Modular Monolith Pragmatis
Sebelum membedah celah keamanan, mari kita ulas pertanyaan arsitektural yang sering ditanyakan:
Apakah proyek ini menerapkan Clean Architecture (Uncle Bob)?
Jawabannya adalah TIDAK. Proyek ini tidak menggunakan Clean Architecture murni (Hexagonal / Ports & Adapters), melainkan Modular Monolith (Package-by-Feature) dengan pendekatan klasik Spring 3-Tier Layered Architecture (Controller $\rightarrow$ Service $\rightarrow$ Repository $\rightarrow$ Entity).
Pertimbangan Kompromi: Kesempurnaan Teori vs. Realitas Industri
Clean Architecture mensyaratkan domain entity berupa Java POJO murni yang sama sekali terisolasi dari framework atau anotasi database JPA. Di atas kertas, teori ini memungkinkan penggantian framework tanpa mengubah logika bisnis.
Namun pada kenyataannya, untuk aplikasi LMS sekolah di mana 75–85% fiturnya adalah operasi CRUD administratif, pengelolaan materi, dan mutasi transaksional database:
- Ledakan Boilerplate (Boilerplate Explosion): Anda terpaksa memelihara dua set model (Domain Entity dan JPA Entity) serta menulis mapper (MapStruct) bolak-balik untuk setiap query database.
- "Melawan Kodrat Framework": Anda kehilangan kekuatan utama ekosistem Spring Boot—seperti transaksi deklaratif (
@Transactional), dirty checking otomatis Hibernate, paginasi praktis (Pageable), dan keamanan anotasi (@PreAuthorize).
Untuk platform pendidikan, Modular Monolith (Package-by-Feature) adalah titik temu paling ideal dan teruji di industri. Namun, kebersihan modul tidak boleh menjadi pembenaran atas rapuhnya keamanan. Mari kita bedah temuan krusial pada PR #1.
🚨 Kerentanan 1: Path Traversal Kritis pada Penyimpanan Berkas (File Storage)
Celah Keamanan (The Flaw)
Aplikasi E-Learning secara konstan mengelola pengunggahan berkas: tugas siswa, silabus materi PDF guru, dan gambar ilustrasi soal CBT. Pada implementasi awal FileStorageService, pengunduhan berkas ditulis sebagai berikut:
JAVA
// KODE RENTAN (Sebelum PR #1)
public Resource loadFileAsResource(String filePath) {
try {
Path file = this.fileStorageLocation.resolve(filePath).normalize();
Resource resource = new UrlResource(file.toUri());
if (resource.exists() && resource.isReadable()) {
return resource;
}
...
Karena SecurityConfig membuka akses publik untuk /api/v1/files/** (agar browser siswa dapat menampilkan avatar guru atau materi publik tanpa header otentikasi), implementasi ini mengandung celah Arbitrary File Read / Path Traversal:
HTTP
GET /api/v1/files/..%2F..%2F..%2Fapplication.yml HTTP/1.1
Host: elearning.local
Meskipun method .normalize() menyelesaikan urutan titik relatif (..), tidak ada pengecekan apakah path hasil resolusi tersebut masih berada di dalam direktori penyimpanan yang diizinkan! Penyerang dapat melompat keluar dari folder storage/uploads menuju file konfigurasi server, kredensial database, atau berkas rahasia sistem operasi.
Selain itu, pada proses upload dan delete:
- Penyerang yang memanipulasi parameter
subDirectorymenjadi../../etcdapat memicu pembuatan direktori di luar sandbox (Files.createDirectories()). - Penyerang yang mengirimkan
../../secret.txtpada methoddeleteFile()dapat menghapus berkas arbitrer di server.
Solusi Perbaikan (The Remediation)
PR #1 menerapkan pertahanan berlapis (three-tier boundary validation) pada FileStorageService:
JAVA
// backend/src/main/java/com/elearning/common/service/FileStorageService.java
public String storeFile(MultipartFile file, String subDirectory) {
String originalFileName = StringUtils.cleanPath(Objects.requireNonNull(file.getOriginalFilename()));
// 1. Tolak karakter traversal '..' pada nama file asli
if (originalFileName.contains("..")) {
throw new IllegalArgumentException("Filename contains invalid path sequence " + originalFileName);
}
String extension = "";
int i = originalFileName.lastIndexOf('.');
if (i >= 0) {
extension = originalFileName.substring(i);
}
String fileName = UUID.randomUUID() + extension;
try {
Path targetDir = this.fileStorageLocation;
if (subDirectory != null && !subDirectory.trim().isEmpty()) {
targetDir = this.fileStorageLocation.resolve(subDirectory).normalize();
// 2. Kunci direktori target agar tidak melompat keluar sandbox
if (!targetDir.startsWith(this.fileStorageLocation)) {
throw new SecurityException("Target sub-directory is outside base upload directory: " + subDirectory);
}
Files.createDirectories(targetDir);
}
Path targetLocation = targetDir.resolve(fileName).normalize();
// 3. Pastikan lokasi file akhir tetap berada di dalam direktori root penyimpanan
if (!targetLocation.startsWith(this.fileStorageLocation)) {
throw new SecurityException("Target file location is outside base upload directory");
}
Files.copy(file.getInputStream(), targetLocation, StandardCopyOption.REPLACE_EXISTING);
return (subDirectory != null && !subDirectory.trim().isEmpty() ? subDirectory + "/" : "") + fileName;
} catch (IOException ex) {
throw new RuntimeException("Could not store file " + fileName + ". Please try again!", ex);
}
}
Hal serupa juga diterapkan secara ketat pada method loadFileAsResource dan deleteFile:
JAVA
public Resource loadFileAsResource(String filePath) {
try {
Path file = this.fileStorageLocation.resolve(filePath).normalize();
// Blokir akses jika path berada di luar direktori upload
if (!file.startsWith(this.fileStorageLocation)) {
throw new SecurityException("Access to path outside upload directory is forbidden: " + filePath);
}
Resource resource = new UrlResource(file.toUri());
if (resource.exists() && resource.isReadable()) {
return resource;
} else {
throw new RuntimeException("File not found: " + filePath);
}
} catch (MalformedURLException ex) {
throw new RuntimeException("File not found: " + filePath, ex);
}
}
Pengujian Otomatis JUnit 5 & @TempDir
Untuk menjamin celah path traversal tidak pernah kambuh lagi (regression-proof), PR #1 menambahkan unit test menggunakan direktori isolasi @TempDir di FileStorageServiceTest.java:
JAVA
// backend/src/test/java/com/elearning/common/service/FileStorageServiceTest.java
@Test
void testStoreFile_PathTraversalInFilename_ThrowsException() {
MockMultipartFile file = new MockMultipartFile(
"file",
"../../etc/passwd.txt",
"text/plain",
"malicious content".getBytes()
);
assertThrows(IllegalArgumentException.class, () ->
fileStorageService.storeFile(file, "materials")
);
}
@Test
void testLoadFile_PathTraversal_ThrowsSecurityException() {
assertThrows(SecurityException.class, () ->
fileStorageService.loadFileAsResource("../../application.yml")
);
}
🔒 Kerentanan 2: Bocornya Informasi Internal via Global Exception Handler
Celah Keamanan (The Flaw)
Pada API Spring Boot, seluruh error yang tidak tertangani bermuara di @RestControllerAdvice. Sebelum perbaikan, method penanganan error umum mengembalikan pesan exception mentah langsung ke klien HTTP:
JAVA
// KODE RENTAN (Sebelum PR #1)
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleGenericException(Exception ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("An unexpected error occurred: " + ex.getMessage()));
}
Jika terjadi kegagalan integritas PostgreSQL atau timeout koneksi, pesan exception akan membocorkan detail skema internal:
JSON
{
"success": false,
"message": "An unexpected error occurred: ERROR: relation \"public.users\" does not exist; SQL [n/a]; nested exception is org.hibernate.exception.SQLGrammarException"
}
Ini adalah celah Information Disclosure (CWE-209), yang memberikan informasi sensitif berupa versi database, dialek SQL, dan struktur tabel kepada pihak luar yang tidak berhak.
Solusi Perbaikan (The Remediation)
PR #1 memperbarui GlobalExceptionHandler agar mencatat detail lengkap error ke log internal server via SLF4J, namun hanya mengirimkan pesan error statis yang aman kepada klien:
JAVA
// backend/src/main/java/com/elearning/common/exception/GlobalExceptionHandler.java
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(SecurityException.class)
public ResponseEntity<ApiResponse<Void>> handleSecurityException(SecurityException ex) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(ex.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleGenericException(Exception ex) {
// Log stack trace lengkap hanya ke log server internal
log.error("Unhandled server exception: ", ex);
// Kirimkan pesan generik yang aman ke pengguna
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("An unexpected error occurred. Please contact the administrator."));
}
}
⏱️ Jebakan Fatal Ujian: Token Kedaluwarsa 15 Menit & Siswa Terlempar Keluar
Skenario Nyata yang Mengacaukan Ruang Ujian
Bayangkan situasi bencana di ruang ujian sekolah berikut:
- 500 siswa serentak memulai ujian matematika berdurasi 90 menit tepat pukul 08:00 WIB.
- Di konfigurasi
application.yml, masa aktif access token JWT diset 15 menit (900000ms) demi standar keamanan stateless. - Tepat pada pukul 08:15:01 WIB, siswa sedang menjawab soal nomor 15. Browser mengirim
POST /api/v1/exams/{id}/answer. - Spring Security mendeteksi bahwa access token telah habis masa berlakunya dan merespons dengan
HTTP 401 Unauthorized. - Interceptor Axios frontend menerima status 401 tersebut:
TYPESCRIPT
// KODE BUG (Sebelum PR #1) if (error.response?.status === 401) { localStorage.removeItem('token'); window.location.href = '/login'; // LEMPAR SISWA KELUAR! } - Akibatnya: Seluruh 500 siswa serempak otomatis logout dan terlempar ke halaman login, jawaban yang sedang diketik hilang, dan kepanikan massal melanda ruang ujian!
Mengapa Hal Ini Terjadi?
Meskipun endpoint login backend menghasilkan refreshToken, endpoint /api/v1/auth/refresh sebelumnya belum pernah diimplementasikan di AuthController! Refresh token tersimpan sia-sia di localStorage tanpa mekanisme pembaruan otomatis.
Solusi Rekayasa: Rotasi Refresh Token dengan Interceptor Antrean Axios
Untuk menuntaskan masalah ini tanpa harus memperpanjang masa aktif access token yang berisiko, PR #1 mengimplementasikan Engine Rotasi Refresh Token Transparan:
1. Endpoint & Validasi di Backend
Di AuthService, token refresh divalidasi dan sepasang access/refresh token baru diterbitkan secara transaksional:
JAVA
// backend/src/modules/auth/service/AuthService.java
@Transactional
public AuthResponse refreshToken(String refreshToken) {
if (!jwtProvider.validateToken(refreshToken)) {
throw new IllegalArgumentException("Invalid or expired refresh token");
}
Long userId = jwtProvider.getUserIdFromToken(refreshToken);
User user = userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + userId));
if (!user.isActive()) {
throw new IllegalArgumentException("User account is disabled");
}
UserPrincipal principal = UserPrincipal.create(user);
String newAccessToken = jwtProvider.generateAccessToken(principal);
String newRefreshToken = jwtProvider.generateRefreshToken(principal);
return AuthResponse.builder()
.accessToken(newAccessToken)
.refreshToken(newRefreshToken)
.tokenType("Bearer")
.userId(user.getId())
.name(user.getName())
.email(user.getEmail())
.roles(principal.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList()))
.active(user.isActive())
.build();
}
2. Interceptor Antrean Konkurensi Axios di Frontend
Pada aplikasi SPA modern React 19, beberapa request HTTP dapat terkirim serempak dalam milidetik yang sama (misal: simpan jawaban soal, pembaruan sisa waktu ujian, dan polling status pengawas). Jika 5 request tersebut sama-sama menerima error 401, jangan pernah menembakkan 5 request refresh bersamaan. Mekanisme antrean yang benar:
- Request pertama menandai status
isRefreshing = truedan menghubungi/api/v1/auth/refresh. - Keempat request lainnya ditahan di dalam antrean Promise (
failedQueue). - Setelah token baru berhasil diperoleh, seluruh request di antrean dieksekusi ulang (replay) dengan token baru:
TYPESCRIPT
// frontend/src/api/client.ts
let isRefreshing = false;
let failedQueue: Array<{
resolve: (token: string) => void;
reject: (err: any) => void;
}> = [];
const processQueue = (error: any, token: string | null = null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token!);
}
});
failedQueue = [];
};
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
if (
originalRequest.url?.includes('/auth/login') ||
originalRequest.url?.includes('/auth/register') ||
originalRequest.url?.includes('/auth/refresh')
) {
return Promise.reject(error);
}
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
if (window.location.pathname !== '/login') window.location.href = '/login';
return Promise.reject(error);
}
// Jika proses refresh sedang berlangsung, masukkan request ke antrean
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then((newToken) => {
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return api(originalRequest);
})
.catch((err) => Promise.reject(err));
}
originalRequest._retry = true;
isRefreshing = true;
try {
const response = await axios.post('/api/v1/auth/refresh', { refreshToken });
const newAccessToken = response.data?.data?.accessToken;
const newRefreshToken = response.data?.data?.refreshToken;
if (newAccessToken) {
localStorage.setItem('token', newAccessToken);
if (newRefreshToken) localStorage.setItem('refreshToken', newRefreshToken);
api.defaults.headers.common.Authorization = `Bearer ${newAccessToken}`;
processQueue(null, newAccessToken);
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return api(originalRequest);
} else {
throw new Error('Refresh response missing token');
}
} catch (refreshErr) {
processQueue(refreshErr, null);
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
if (window.location.pathname !== '/login') window.location.href = '/login';
return Promise.reject(refreshErr);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
Dengan mekanisme ini, siswa dapat mengerjakan ujian selama berjam-jam tanpa gangguan sedikit pun, sementara integritas keamanan access token 15 menit tetap terjaga sempurna!
🐳 Ketahanan DevOps & Infrastruktur Container Docker
1. Bencana Penyimpanan Berkas Efemeral (Ephemeral Storage)
Pada docker-compose.yml sebelumnya, container backend tidak memiliki mount volume untuk folder penyimpanan berkas. Jika seorang guru mengunggah 50 modul PDF dan container di-restart atau di-update (docker compose down && docker compose up), seluruh berkas yang diunggah akan musnah permanen!
PR #1 menambahkan Docker named volume khusus:
YAML
# docker-compose.yml
services:
backend:
volumes:
- backend_storage:/app/storage/uploads
volumes:
postgres_data:
redis_data:
minio_data:
backend_storage: # <--- Jaminan Persistensi Berkas Antar-Deployment
2. Spring Boot Actuator & Healthcheck Container
Pada sistem deployment modern (Docker Swarm, Kubernetes, atau AWS ECS), container bisa saja berada dalam status Running padahal aplikasi JVM di dalamnya masih booting atau gagal melakukan migrasi database. Mengarahkan trafik ke container yang belum siap memicu error HTTP 502 Bad Gateway.
PR #1 menambahkan dependensi spring-boot-starter-actuator:
YAML
# backend/src/main/resources/application.yml
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
show-details: when-authorized
probes:
enabled: true
Dan mengonfigurasikan health check bawaan di docker-compose.yml:
YAML
backend:
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/actuator/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
frontend:
depends_on:
backend:
condition: service_healthy # Nginx baru aktif setelah Spring Boot benar-benar siap!
Reverse proxy Nginx juga diperbarui untuk meneruskan path probe kesehatan:
NGINX
# frontend/nginx.conf
location /actuator/ {
proxy_pass http://backend:8080/actuator/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
📜 Tata Kelola Open Source & Seeding Data Uji Coba
Untuk memenuhi standar tata kelola proyek open-source, PR #1 melengkapi repositori dengan:
- Lisensi Resmi MIT (
LICENSE): Menjamin kepastian hukum bagi institusi yang ingin mengadopsi platform. - Panduan Kontribusi Komprehensif (
CONTRIBUTING.md): Menjelaskan prasyarat Java 21, perintah test, dan standar Git workflow. - Seeding Database Sekali Eksekusi: Memungkinkan kontributor langsung menguji alur ujian CBT tanpa repot mendaftarkan puluhan akun secara manual:
BASH
docker exec -i elearning-postgres psql -U postgres -d elearning < backend/src/main/resources/db/migration/seed_demo_data.sql
Checklist Kesiapan Produksi (Production Readiness)
Sebelum mendeploy aplikasi Spring Boot + React ke lingkungan produksi, periksa kesiapan sistem Anda berdasarkan checklist berikut:
| Kategori | Parameter Pemeriksaan | Status di PR #1 |
|---|---|---|
| Keamanan Storage | Validasi batas .startsWith(uploadRoot) saat baca, tulis, dan hapus | ✅ Tuntas |
| Persistensi Storage | Volume Docker persisten terhubung ke folder upload | ✅ Tuntas |
| Keamanan Exception | Handler error generik tidak membocorkan query SQL internal | ✅ Tuntas |
| Integritas Sesi | Rotasi refresh token dengan antrean asinkron di frontend | ✅ Tuntas |
| Observabilitas | Health check liveness dan readiness Spring Boot Actuator aktif | ✅ Tuntas |
| Orkestrasi Container | Docker healthcheck dengan aturan condition: service_healthy | ✅ Tuntas |
| Tata Kelola OSS | Lisensi resmi MIT & script otomatisasi seed demo data | ✅ Tuntas |
Kesimpulan & Langkah Selanjutnya
Penguatan keamanan dan kesiapan produksi bukanlah hal yang bisa ditunda di akhir proyek—keduanya merupakan pilar arsitektur fundamental. Melalui perbaikan celah path traversal, rotasi refresh token otomatis, dan penguatan infrastruktur Docker, Spring E-Learning kini siap menghadapi beban ujian sekolah berskala besar tanpa rasa cemas.
Pada Part 3, kita akan membedah Desain Skema Database Flyway PostgreSQL & Pemodelan Domain, menganalisis struktur relasional di balik bank soal, kartu peserta ujian resmi ber-barcode, dan kalkulasi skor otomatis.
- Kunjungi Pull Request #1: github.com/mfarim/spring-elearning-react/pull/1
- Repositori Lengkap: github.com/mfarim/spring-elearning-react

