MUHAMMAD
FARI
MADYAN
[ Press ESC or Click to Skip ]

Rebuild Java Spring Boot + React E-Learning

1

Part 1: Pengenalan & Arsitektur: Dari Monolit ke Arsitektur Enterprise Terpisah

2

Part 2: Penguatan Keamanan & Kesiapan Produksi: Path Traversal, Rotasi Refresh Token & Ketahanan Docker

3

Part 3: Panduan Deployment Single VPS: Docker Compose, Host Nginx Reverse Proxy & SSL Let's Encrypt

Artikel ini tersedia dalam Bahasa Inggris

🇬🇧 Read in English
🇮🇩 Bahasa Indonesia📚 Rebuild Java Spring Boot + React E-Learning

Java Spring Boot + React E-Learning: Penguatan Keamanan, Kesiapan Produksi & Ketahanan Zero-Downtime (Bedah PR #1)

Bedah teknis mendalam pasca-audit penguatan platform open-source Java 21 Spring Boot 3.4 & React 19 E-Learning: mengatasi celah kritis path traversal pada penyimpanan berkas, implementasi rotasi refresh token dengan interceptor antrean Axios, volume persisten Docker, dan probe kesehatan Actuator.

Muhammad Fari MadyanPenulis

12 menit baca

·

13 Oktober 2026


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).

modules/ ├── academic/ (controller, dto, entity, repository, service) ├── announcement/ (controller, dto, entity, repository, service) ├── assignment/ (controller, dto, entity, repository, service) ├── auth/ (controller, dto, service) ├── exam/ (controller, dto, entity, repository, service) ├── grade/ (controller, dto, service) ├── material/ (controller, dto, entity, repository, service) └── user/ (controller, dto, entity, repository, service)

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:

  1. 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.
  2. "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 subDirectory menjadi ../../etc dapat memicu pembuatan direktori di luar sandbox (Files.createDirectories()).
  • Penyerang yang mengirimkan ../../secret.txt pada method deleteFile() 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:

  1. 500 siswa serentak memulai ujian matematika berdurasi 90 menit tepat pukul 08:00 WIB.
  2. Di konfigurasi application.yml, masa aktif access token JWT diset 15 menit (900000ms) demi standar keamanan stateless.
  3. Tepat pada pukul 08:15:01 WIB, siswa sedang menjawab soal nomor 15. Browser mengirim POST /api/v1/exams/{id}/answer.
  4. Spring Security mendeteksi bahwa access token telah habis masa berlakunya dan merespons dengan HTTP 401 Unauthorized.
  5. 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!
    }
    
  6. 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 = true dan 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:

  1. Lisensi Resmi MIT (LICENSE): Menjamin kepastian hukum bagi institusi yang ingin mengadopsi platform.
  2. Panduan Kontribusi Komprehensif (CONTRIBUTING.md): Menjelaskan prasyarat Java 21, perintah test, dan standar Git workflow.
  3. 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:

KategoriParameter PemeriksaanStatus di PR #1
Keamanan StorageValidasi batas .startsWith(uploadRoot) saat baca, tulis, dan hapus✅ Tuntas
Persistensi StorageVolume Docker persisten terhubung ke folder upload✅ Tuntas
Keamanan ExceptionHandler error generik tidak membocorkan query SQL internal✅ Tuntas
Integritas SesiRotasi refresh token dengan antrean asinkron di frontend✅ Tuntas
ObservabilitasHealth check liveness dan readiness Spring Boot Actuator aktif✅ Tuntas
Orkestrasi ContainerDocker healthcheck dengan aturan condition: service_healthy✅ Tuntas
Tata Kelola OSSLisensi 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.

Lanjut Membaca

Previous article thumbnail

← Artikel Sebelumnya

Rebuilding Laravel E-Learning to Java Spring Boot & React: Enterprise Architecture & Cloud-Ready Migration

Artikel Selanjutnya →

Java Spring Boot + React E-Learning: Single VPS Production Deployment with Docker Compose, Host Nginx Reverse Proxy & SSL Let's Encrypt

Next article thumbnail