MUHAMMAD
FARI
MADYAN
[ Press ESC or Click to Skip ]

Java Spring Boot + React E-Learning Rebuild

1

Part 1: Introduction & Architecture: Monolith to Decoupled Enterprise

2

Part 2: Production Readiness & Security Hardening: Path Traversal, Refresh Tokens & Docker Resilience

3

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

This article is available in Indonesian

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

Java Spring Boot + React E-Learning: Production Readiness, Security Hardening & Zero-Downtime Resilience (PR #1 Breakdown)

A deep-dive technical post-mortem on hardening the open-source Java 21 Spring Boot 3.4 & React 19 E-Learning platform: eliminating path traversal in file storage, implementing refresh token rotation with Axios queue interceptors, Docker persistent volumes, and Actuator health probes.

Muhammad Fari MadyanAuthor

12 min read

·

Oct 13, 2026


The Audit: When "Working Code" Meets Production Reality

In Part 1 of this series, we explored the macro-architecture of rebuilding our legacy examination monolith into a decoupled, high-concurrency platform using Java 21 LTS, Spring Boot 3.4, React 19, and Tailwind CSS v4 (open-sourced at mfarim/spring-elearning-react).

On the surface, the initial release was a great milestone:

  • The UI was sleek, responsive, and responsive across mobile and desktop devices.
  • Spring Data JPA, Flyway migrations, and Redis caching were configured and operational.
  • Examinations, classrooms, assignments, and real-time STOMP WebSockets were working in development.

However, when software transitions from an attractive open-source repository to a mission-critical examination platform deployed in actual school districts, a crucial question arises:

"Is this codebase truly production-ready, or does it harbor subtle security holes and runtime traps that will bring exam day crashing down?"

A thorough architectural review and security audit revealed several critical vulnerabilities, an exam-breaking session timeout bug, and infrastructure risks. In this article, we break down Pull Request #1: feat/production-readiness-and-security-hardening—analyzing the exact root causes, security exploits, and engineering solutions implemented to achieve enterprise-grade resilience.


Architectural Context: Clean Architecture vs. Pragmatic Modular Monolith

Before diving into the security vulnerabilities, let's address an architectural question frequently asked by developers:

Does this project adhere to Uncle Bob's Clean Architecture?

The short answer is No. This project does not employ strict Clean Architecture (Hexagonal / Ports & Adapters). Instead, it implements a Modular Monolith (Package-by-Feature) utilizing the classic 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)

The Trade-Off: Academic Purity vs. Engineering Pragmatism

Textbook Clean Architecture mandates that domain entities must be pure POJOs with zero dependencies on database ORMs or frameworks. In theory, this allows swapping Spring or Hibernate for another framework without modifying business logic.

However, in practice for web applications where 75–85% of capabilities involve administrative CRUD, learning material uploads, and transactional database updates:

  1. The Boilerplate Explosion: You must maintain duplicate domain models and JPA entities, along with bidirectional MapStruct mappers for every database operation.
  2. Fighting the Framework: You discard Spring Boot's greatest strengths—declarative transactions (@Transactional), JPA dirty checking, pagination (Pageable), and Spring Security annotations (@PreAuthorize).

For academic platforms, Modular Monolith (Package-by-Feature) is the battle-tested sweet spot. However, architectural cleanliness does not excuse sloppy security or fragile infrastructure. Let's inspect the critical bugs uncovered in PR #1.


🚨 Vulnerability 1: Critical Path Traversal in File Storage

The Flaw

Learning Management Systems handle continuous file uploads: student homework assignments, teacher syllabus PDFs, and CBT question diagrams. In the original FileStorageService, file downloading was implemented as follows:

JAVA

// VULNERABLE CODE (Before 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;
        }
        ...

Because SecurityConfig permitted public access to /api/v1/files/** (to allow browser display of public attachments and teacher avatars), this implementation contained a classic Arbitrary File Read / Path Traversal vulnerability:

HTTP

GET /api/v1/files/..%2F..%2F..%2Fapplication.yml HTTP/1.1
Host: elearning.local

While .normalize() resolved relative dot-segments (..), there was no check verifying that the resolved path stayed within the designated upload directory! An attacker could traverse out of storage/uploads into server configuration files, database credentials, or private system keys.

Furthermore, during file uploads and file deletions:

  • An attacker manipulating subDirectory to ../../etc could force Files.createDirectories() outside the sandbox.
  • An attacker passing ../../secret.txt to deleteFile() could delete arbitrary files on the server.

The Remediation

PR #1 introduces a strict, three-tier defense-in-depth boundary validation in 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. Reject explicit path traversal sequences in original filename
    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. Enforce sandbox boundary on target directory
            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. Enforce sandbox boundary on target file
        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);
    }
}

Similarly, in loadFileAsResource and deleteFile, strict boundary assertions were added:

JAVA

public Resource loadFileAsResource(String filePath) {
    try {
        Path file = this.fileStorageLocation.resolve(filePath).normalize();
        
        // Block any path escaping the storage root
        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);
    }
}

Automated Unit Testing with JUnit 5 & @TempDir

To guarantee that path traversal attacks can never regress, PR #1 added unit tests leveraging JUnit 5's isolated @TempDir in 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")
    );
}

🔒 Vulnerability 2: Information Disclosure via Global Exception Handler

The Flaw

In Spring Boot APIs, unhandled exceptions typically hit a @RestControllerAdvice. In the original implementation, unexpected exceptions returned the raw message directly to the caller:

JAVA

// VULNERABLE CODE (Before 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()));
}

If a PostgreSQL constraint failed, or a query timed out, ex.getMessage() would return internal database schema details:

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"
}

This represents an Information Disclosure vulnerability (CWE-209), handing potential attackers database engine versions, SQL dialect details, and table structures on a silver platter.

The Remediation

In PR #1, GlobalExceptionHandler was updated to log full stack traces internally using SLF4J while returning a sanitized, static error message to clients:

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 sensitive diagnostics to server logs only
        log.error("Unhandled server exception: ", ex);
        
        // Return safe, opaque message to client
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(ApiResponse.error("An unexpected error occurred. Please contact the administrator."));
    }
}

⏱️ The Exam-Breaking Trap: 15-Minute Token Expiry & Student Disconnections

The Catastrophic Scenario

Imagine this real-world exam hall disaster:

  1. 500 junior high school students begin a 90-minute national mathematics exam at 08:00 AM.
  2. In application.yml, the JWT access token lifetime is set to 15 minutes (900000ms) for security compliance.
  3. At exactly 08:15:01 AM, students answer question #15. The browser sends POST /api/v1/exams/{id}/answer.
  4. Spring Security detects the expired access token and returns HTTP 401 Unauthorized.
  5. The frontend Axios interceptor intercepts the 401 error:

    TYPESCRIPT

    // BUGGY CODE (Before PR #1)
    if (error.response?.status === 401) {
      localStorage.removeItem('token');
      window.location.href = '/login'; // KICK STUDENT OUT!
    }
    
  6. Result: All 500 students are abruptly logged out, redirected to /login, losing unsaved exam answers and triggering panic in the examination hall!

Why Did This Happen?

While the backend previously generated a refreshToken upon login, the /api/v1/auth/refresh endpoint was never implemented in AuthController! The refresh token sat unused in localStorage while students were kicked out every quarter of an hour.

The Engineering Solution: Refresh Token Rotation with Axios Queue Interceptor

To resolve this without increasing access token vulnerability windows, PR #1 implemented an end-to-end Transparent Refresh Token Rotation engine:

1. Backend Endpoint & Validation

In AuthService, the incoming refresh token is verified, and a fresh access/refresh pair is generated:

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. Frontend Axios Concurrency Queue Interceptor

In a real SPA like React 19, multiple parallel requests can fire at the same second (e.g., auto-saving an answer, polling exam remaining time, and fetching question telemetry). If five requests fail simultaneously with 401, we must NOT fire five refresh requests. Instead:

  • The first request sets isRefreshing = true and calls /api/v1/auth/refresh.
  • The remaining four requests are paused in a Promise queue (failedQueue).
  • When the refresh resolves, all queued requests receive the new token and retry automatically:

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);
      }

      // If already refreshing, queue this request
      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);
  }
);

Thanks to this mechanism, student exam sessions remain seamless indefinitely while maintaining high security with 15-minute access token windows!


🐳 DevOps & Production Container Resilience

1. Ephemeral Upload Storage Disaster

In the original docker-compose.yml, the backend container had no volume mount for uploaded files. If a teacher uploaded 50 PDF learning modules and a container was restarted or updated (docker compose down && docker compose up), all uploaded files were destroyed permanently!

PR #1 introduces a dedicated Docker named volume:

YAML

# docker-compose.yml
services:
  backend:
    volumes:
      - backend_storage:/app/storage/uploads

volumes:
  postgres_data:
  redis_data:
  minio_data:
  backend_storage: # <--- Persistent Volume Guarantee

2. Spring Boot Actuator & Container Healthchecks

In production orchestration (Docker Swarm, Kubernetes, or AWS ECS), a container may be in Running status while the JVM is still warming up or failing database migrations. Routing traffic to an unready container results in HTTP 502 Bad Gateway errors.

PR #1 added 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

And configured native container health checks in 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 only starts when Spring Boot reports healthy!

Additionally, Nginx reverse proxy was updated to safely expose the health endpoint:

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;
}

📜 Open Source Governance & Reproducible Demo Seeding

To conform to open-source governance standards, PR #1 added:

  1. Official MIT License (LICENSE): Resolving ambiguous copyright status.
  2. Comprehensive Contribution Guide (CONTRIBUTING.md): Documenting Java 21 prerequisites, test commands, code styles, and Git branch workflows.
  3. One-Command Demo Database Seeding: Enabling contributors to test CBT exams immediately without manually registering dozens of users:

    BASH

    docker exec -i elearning-postgres psql -U postgres -d elearning < backend/src/main/resources/db/migration/seed_demo_data.sql
    

Production Readiness Checklist

Before taking any Spring Boot + React application live, verify your stack against this security and resilience checklist:

CategoryVerification ItemStatus in PR #1
Storage SecurityStrict .startsWith(uploadRoot) boundary check on file read, write, and delete✅ Resolved
Storage PersistenceNamed Docker volume mounted to upload root to survive restarts✅ Resolved
Exception SafetySanitized generic exception handler concealing internal SQL schemas✅ Resolved
Session IntegrityRefresh token rotation with asynchronous frontend request queueing✅ Resolved
ObservabilitySpring Boot Actuator liveness & readiness health checks exposed✅ Resolved
Container StartupOrchestrator health check with condition: service_healthy✅ Resolved
Open SourceOfficial OSI-approved license & automated seed scripts✅ Resolved

Conclusion & What's Next

Security hardening and production readiness are not afterthought checkboxes—they are core architectural disciplines. By eliminating path traversal vulnerabilities, establishing zero-downtime refresh token rotation, and locking down Docker infrastructure, Spring E-Learning is now battle-hardened for real-world academic deployments.

In Part 3, we will dive into PostgreSQL Flyway Schema Design & Domain Modeling, analyzing the relational structures powering examinations, classroom rosters, and automated grading pipelines.

Continue Reading

Previous article

← Previous Article

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

Next Article →

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

Next article