MUHAMMAD
FARI
MADYAN
[ Press ESC or Click to Skip ]

Rebuild Java Spring Boot + React E-Learning

1

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

2

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

3

Part 3: Arsitektur Keamanan Enterprise: Stateless JWT, Method-Level RBAC & Pertahanan OWASP Top 10

4

Part 4: Single VPS Production Deployment: 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 3.4 & React 19: Arsitektur Keamanan Enterprise – Stateless JWT, Method-Level RBAC & Pertahanan OWASP Top 10

Bedah tuntas arsitektur keamanan enterprise pada platform Java 21 Spring Boot 3.4 & React 19: validasi kriptografi JJWT 0.12, kontrol akses berbasis peran method-level via @PreAuthorize, pertahanan brute-force Token Bucket Bucket4j, dan mitigasi komprehensif standar OWASP Top 10.

Muhammad Fari MadyanPenulis

13 menit baca

·

14 Oktober 2026


Pergeseran Paradigma: Mengamankan Sistem Enterprise Terpisah

Saat melakukan migrasi dari monolit berbasis server-side rendering (seperti Laravel Blade atau Spring MVC klasik bertemplate Thymeleaf) menuju arsitektur terpisah (decoupled) Spring Boot 3.4 REST API dan React 19 Single Page Application (SPA), fondasi arsitektur keamanan mengalami pergeseran fundamental.

Pada arsitektur monolitik konvensional:

  • Browser mengandalkan session cookie stateful (JSESSIONID atau laravel_session).
  • Server menyimpan sesi pengguna dalam memori lokal atau shared cache Redis.
  • Setiap mutasi data HTTP terlindungi oleh token CSRF yang disematkan langsung ke formulir HTML.

Namun, pada platform Computer Based Testing (CBT) dan e-learning berskala tinggi (tersedia open-source di mfarim/spring-elearning-react), pendekatan stateful tersebut menimbulkan hambatan:

  1. Skalabilitas Tanpa Sesi (Stateless): Backend harus mampu beroperasi di banyak kontainer Docker di balik reverse proxy tanpa perlu konfigurasi sticky session atau session replication yang kompleks.
  2. Kesesuaian Multi-Klien: Endpoint REST dan WebSocket yang sama harus melayani browser desktop, aplikasi mobile (React Native / Android / iOS), hingga browser ujian aman (CBT lockdown browser) secara seragam.
  3. Pertahanan Berlapis (Defense-in-Depth): Pembatasan rute URL global (requestMatchers("/api/v1/admin/**")) tidak cukup aman untuk model bisnis akademik yang kompleks. Dibutuhkan evaluasi izin berbasis peran di tingkat method secara granular (method-level authorization).
  4. Ketahanan Terhadap Serangan Brute-Force & Banjir Trafik: Endpoint login publik dan pengumpulan jawaban ujian harus dibentengi dari serangan credential stuffing, kamus sandi otomatis, dan lonjakan HTTP bersamaan.

Dalam artikel ini, kita membedah implementasi arsitektur keamanan produksi pada mfarim/spring-elearning-react: mulai dari engine JJWT 0.12 stateless, kontrol akses deklaratif @PreAuthorize SpEL, rate limiting multi-tier Bucket4j, hingga matriks mitigasi standar OWASP Top 10.


1. Engine Stateless JWT Modern (JJWT 0.12)

Banyak aplikasi Java Spring Boot masih menggunakan pustaka JJWT versi lama (Jwts.parser().setSigningKey(...)) yang rentan, tidak lagi aman terhadap standar kriptografi modern, dan tidak memiliki type safety. Pada repositori ini, kami menerapkan API fasad modern JJWT 0.12.

┌─────────────────────────────────────────────────────────────────────────────┐ │ ALUR AUTENTIKASI STATELESS JWT & FILTER │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ [ Klien React 19 ] │ │ │ │ │ │ 1. POST /api/v1/auth/login { email, password } │ │ ▼ │ │ [ RateLimitingFilter (Bucket4j: 10 req/menit/IP) ] │ │ │ │ │ │ 2. Lolos Rate Limiting & Menuju Filter Chain │ │ ▼ │ │ [ DaoAuthenticationProvider ] │ │ │ • Pencocokan hash BCryptPasswordEncoder(12) │ │ ▼ │ │ [ JwtProvider ] │ │ │ • Buat Access Token (15 menit) HMAC-SHA256 (Kunci >=256-bit) │ │ │ • Buat Refresh Token (7 hari) HMAC-SHA256 │ │ ▼ │ │ [ AuthResponse { accessToken, refreshToken, roles, user } ] │ │ │ │ │ │ 3. Request Berikutnya: Header "Authorization: Bearer <token>" │ │ ▼ │ │ [ JwtAuthenticationFilter (OncePerRequestFilter) ] │ │ │ • Verifikasi Tanda Tangan: parser().verifyWith(secretKey) │ │ │ • Ekstrak userId, roles, dan impersonatorAdminId │ │ │ • Daftarkan UserPrincipal ke SecurityContextHolder │ │ ▼ │ │ [ Lapisan Controller (@PreAuthorize Dievaluasi via SpEL) ] │ │ │ └─────────────────────────────────────────────────────────────────────────────┘

Penegakan Standar Kriptografi & Pembuatan Token

Pada berkas backend/src/main/java/com/elearning/security/JwtProvider.java, konstruktor memvalidasi kekuatan kunci kriptografi sejak pertama kali Spring Boot melakukan bootstrap:

JAVA

// backend/src/main/java/com/elearning/security/JwtProvider.java
@Component
public class JwtProvider {

    private final SecretKey secretKey;
    private final long accessTokenExpirationMs;
    private final long refreshTokenExpirationMs;

    public JwtProvider(
            @Value("${app.jwt.secret}") String secret,
            @Value("${app.jwt.access-token-expiration-ms:900000}") long accessTokenExpirationMs,
            @Value("${app.jwt.refresh-token-expiration-ms:604800000}") long refreshTokenExpirationMs
    ) {
        if (secret == null || secret.trim().isEmpty()) {
            throw new IllegalStateException("KRITIS: app.jwt.secret belum dikonfigurasi! Harap isi environment variable JWT_SECRET.");
        }
        byte[] keyBytes = Decoders.BASE64.decode(secret.trim());
        
        // Penegakan Kriptografi Keras: Kunci wajib minimal 256 bit (32 byte) untuk HMAC-SHA256
        if (keyBytes.length < 32) {
            throw new IllegalArgumentException("KRITIS: Kunci rahasia JWT wajib berukuran minimal 256 bit (32 byte) untuk HMAC-SHA256!");
        }
        this.secretKey = Keys.hmacShaKeyFor(keyBytes);
        this.accessTokenExpirationMs = accessTokenExpirationMs; // Default 15 menit
        this.refreshTokenExpirationMs = refreshTokenExpirationMs; // Default 7 hari
    }

    public String generateAccessToken(UserPrincipal userPrincipal) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + accessTokenExpirationMs);

        List<String> roles = userPrincipal.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toList());

        var builder = Jwts.builder()
                .subject(userPrincipal.getId().toString())
                .claim("email", userPrincipal.getEmail())
                .claim("name", userPrincipal.getName())
                .claim("roles", roles)
                .issuedAt(now)
                .expiration(expiryDate)
                .signWith(secretKey);

        // Sisipkan klaim audit impersonasi admin jika sedang aktif
        if (userPrincipal.getImpersonatorAdminId() != null) {
            builder.claim("impersonatorAdminId", userPrincipal.getImpersonatorAdminId());
        }

        return builder.compact();
    }
}

Inspeksi Token: JwtAuthenticationFilter

Setiap request HTTP yang masuk diperiksa oleh JwtAuthenticationFilter yang mewarisi OncePerRequestFilter:

JAVA

// backend/src/main/java/com/elearning/security/JwtAuthenticationFilter.java
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtProvider jwtProvider;
    private final CustomUserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain
    ) throws ServletException, IOException {
        String token = getJwtFromRequest(request);

        if (StringUtils.hasText(token) && jwtProvider.validateToken(token)) {
            Long userId = jwtProvider.getUserIdFromToken(token);
            Long impersonatorAdminId = jwtProvider.getImpersonatorAdminIdFromToken(token);

            // Muat data otorisasi user secara aman
            UserDetails userDetails = userDetailsService.loadUserById(userId, impersonatorAdminId);
            
            UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                    userDetails,
                    null,
                    userDetails.getAuthorities()
            );
            authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

            // Set konteks keamanan pada thread eksekusi saat ini
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

    private String getJwtFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

2. Kontrol Akses Berbasis Peran Tingkat Method (RBAC) & SpEL

Banyak pengembang hanya mengonfigurasi keamanan pada level rute URL HTTP di dalam SecurityConfig.java:

JAVA

// Pencocokan URL makro (Penting sebagai perimeter awal, namun TIDAK cukup sendirian)
http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
    .requestMatchers("/api/v1/teacher/**").hasAnyRole("ADMIN", "TEACHER")
    .requestMatchers("/api/v1/student/**").hasAnyRole("ADMIN", "STUDENT")
    .anyRequest().authenticated()
);

Mengandalkan pembatasan rute URL saja memiliki risiko fatal:

  1. Risiko Refactoring Endpoint: Jika pengembang mengubah path /api/v1/teacher/exams menjadi /api/v1/exams, terjadi kebocoran otorisasi jika konfigurasi URL matcher tidak diperbarui.
  2. Tidak Mendukung Logika Kontekstual: URL matcher tidak bisa mengecek kepemilikan data (misal: memastikan guru A hanya bisa mengedit soal ujian miliknya sendiri, bukan ujian milik guru B).
  3. Kekurangan Granularitas: Aksi seperti memublikasikan draf soal atau menambahkan bobot nilai berada pada endpoint yang sama, namun membutuhkan izin yang berbeda.

Mengaktifkan Otorisasi Method Security

Pada Spring Boot 3 / Spring Security 6, kita mengaktifkan otorisasi deklaratif menggunakan anotasi @EnableMethodSecurity:

JAVA

// backend/src/main/java/com/elearning/config/SecurityConfig.java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true) // Mengaktifkan evaluasi @PreAuthorize & @PostAuthorize
@RequiredArgsConstructor
public class SecurityConfig {
    // ...
}

Pemetaan Authority di UserPrincipal

Spring Security mengevaluasi ekspresi hasRole('ADMIN') dengan mencari authority berawalan ROLE_. Di UserPrincipal.java, peran pengguna diubah secara konsisten menjadi SimpleGrantedAuthority:

JAVA

// backend/src/main/java/com/elearning/security/UserPrincipal.java
public static UserPrincipal create(User user, Long impersonatorAdminId) {
    Collection<GrantedAuthority> authorities = user.getRoles().stream()
            .map(role -> new SimpleGrantedAuthority(role.getName().name())) // contoh: "ROLE_ADMIN", "ROLE_TEACHER"
            .collect(Collectors.toList());

    return new UserPrincipal(
            user.getId(),
            user.getName(),
            user.getEmail(),
            user.getPassword(),
            user.isActive(),
            authorities,
            impersonatorAdminId
    );
}

Deklarasi Granular di Controller Menggunakan SpEL

Dengan method security, setiap endpoint mendeklarasikan kontrak izinnya secara eksplisit melalui Spring Expression Language (SpEL):

JAVA

// backend/src/main/java/com/elearning/modules/exam/controller/ExamController.java
@RestController
@RequestMapping("/api/v1/exams")
@RequiredArgsConstructor
public class ExamController {

    private final ExamService examService;

    // Siswa dan Guru dapat melihat daftar ujian yang relevan (difilter di service)
    @GetMapping
    public ResponseEntity<ApiResponse<List<ExamResponse>>> getExams(
            @AuthenticationPrincipal UserPrincipal principal) {
        return ResponseEntity.ok(ApiResponse.success(examService.getExamsForUser(principal), "Daftar ujian berhasil diambil"));
    }

    // Hanya Guru dan Admin yang diizinkan membuat paket ujian baru
    @PostMapping
    @PreAuthorize("hasAnyRole('ADMIN', 'TEACHER')")
    public ResponseEntity<ApiResponse<ExamResponse>> createExam(
            @Valid @RequestBody ExamDto dto,
            @AuthenticationPrincipal UserPrincipal principal) {
        ExamResponse response = examService.createExam(dto, principal);
        return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(response, "Ujian berhasil dibuat"));
    }

    // Pembaruan metadata ujian dibatasi hanya untuk Guru dan Admin
    @PutMapping("/{id}")
    @PreAuthorize("hasAnyRole('ADMIN', 'TEACHER')")
    public ResponseEntity<ApiResponse<ExamResponse>> updateExam(
            @PathVariable Long id,
            @Valid @RequestBody ExamDto dto,
            @AuthenticationPrincipal UserPrincipal principal) {
        return ResponseEntity.ok(ApiResponse.success(examService.updateExam(id, dto, principal), "Ujian diperbarui"));
    }

    // Pengelolaan butir bank soal hanya untuk tenaga pengajar
    @PostMapping("/{id}/questions")
    @PreAuthorize("hasAnyRole('ADMIN', 'TEACHER')")
    public ResponseEntity<ApiResponse<QuestionResponse>> addQuestion(
            @PathVariable Long id,
            @Valid @RequestBody QuestionDto dto,
            @AuthenticationPrincipal UserPrincipal principal) {
        return ResponseEntity.status(HttpStatus.CREATED).body(
                ApiResponse.success(examService.addQuestion(id, dto, principal), "Soal berhasil ditambahkan")
        );
    }
}

Pencegahan IDOR (Insecure Direct Object References)

Method security menjamin peran (role). Namun, seorang guru yang terautentikasi tidak boleh diizinkan menghapus ujian milik guru lain!

Untuk mencegah celah IDOR (OWASP A01), lapisan service memvalidasi kepemilikan data memanfaatkan objek UserPrincipal:

JAVA

// backend/src/main/java/com/elearning/modules/exam/service/ExamService.java
@Transactional
public void deleteExam(Long id, UserPrincipal principal) {
    Examination exam = examRepository.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("Ujian tidak ditemukan dengan id: " + id));

    // Admin dapat menghapus ujian apa pun; Guru hanya boleh menghapus ujian buatannya sendiri
    boolean isAdmin = principal.getAuthorities().stream()
            .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));
    boolean isOwner = exam.getTeacher().getId().equals(principal.getId());

    if (!isAdmin && !isOwner) {
        throw new SecurityException("Akses Ditolak: Anda tidak memiliki wewenang untuk menghapus ujian milik pengajar lain.");
    }

    examRepository.delete(exam);
}

3. Pertahanan Brute-Force & Banjir Trafik: Bucket4j Token Bucket

Endpoint login (/api/v1/auth/login) dan pengumpulan lembar jawaban ujian CBT (/api/v1/exams/{id}/submit) adalah sasaran empuk serangan siber. Tanpa pembatasan laju request (rate limiting) di sisi server, aplikasi rentan terhadap serangan kamus sandi (credential stuffing) atau banjir request saat jam mulai ujian.

Pada spring-elearning-react, kami menerapkan filter rate limiting multi-tier berbasis algoritma Token Bucket dari pustaka Bucket4j:

JAVA

// backend/src/main/java/com/elearning/security/RateLimitingFilter.java
@Component
@Slf4j
public class RateLimitingFilter extends OncePerRequestFilter {

    private final Map<String, Bucket> authIpBuckets = new ConcurrentHashMap<>();
    private final Map<String, Bucket> submitIpBuckets = new ConcurrentHashMap<>();
    private final Map<String, Bucket> generalIpBuckets = new ConcurrentHashMap<>();

    // Kapasitas Rate Limiting Per Menit
    private static final int AUTH_CAPACITY = 10;       // Maksimal 10 request login/register per menit per IP
    private static final int SUBMIT_CAPACITY = 60;     // Maksimal 60 request submit/autosave per menit per IP
    private static final int GENERAL_CAPACITY = 120;   // Maksimal 120 request API umum per menit per IP

    private Bucket createBucket(int capacity, Duration duration) {
        Bandwidth limit = Bandwidth.classic(capacity, Refill.greedy(capacity, duration));
        return Bucket.builder().addLimit(limit).build();
    }

    @Override
    protected boolean shouldNotFilter(@NonNull HttpServletRequest request) {
        String path = request.getRequestURI();
        // Lewati rate limiting untuk file dokumentasi, health probe, dan preflight CORS OPTIONS
        return "OPTIONS".equalsIgnoreCase(request.getMethod())
                || path.startsWith("/actuator")
                || path.startsWith("/swagger-ui")
                || path.startsWith("/v3/api-docs")
                || path.startsWith("/ws");
    }

    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain
    ) throws ServletException, IOException {

        String clientIp = LoginRateLimiterService.getClientIp(request);
        String requestUri = request.getRequestURI();

        // Tier 1: Proteksi ketat pada Endpoint Autentikasi (Login / Register)
        if (requestUri.startsWith("/api/v1/auth/login") || requestUri.startsWith("/api/v1/auth/register")) {
            Bucket authBucket = authIpBuckets.computeIfAbsent(
                    clientIp, k -> createBucket(AUTH_CAPACITY, Duration.ofMinutes(1))
            );
            if (!authBucket.tryConsume(1)) {
                log.warn("Rate limit [AUTH] terlampaui untuk IP {} pada {}", clientIp, requestUri);
                sendRateLimitResponse(response, "Terlalu banyak percobaan autentikasi dari alamat IP ini. Silakan tunggu 1 menit sebelum mencoba lagi.");
                return;
            }
        }

        // Tier 2: Proteksi banjir autosave & submit ujian CBT
        else if (requestUri.contains("/exams/") && (requestUri.endsWith("/submit") || requestUri.endsWith("/answer"))) {
            Bucket submitBucket = submitIpBuckets.computeIfAbsent(
                    clientIp, k -> createBucket(SUBMIT_CAPACITY, Duration.ofMinutes(1))
            );
            if (!submitBucket.tryConsume(1)) {
                log.warn("Rate limit [EXAM SUBMIT] terlampaui untuk IP {} pada {}", clientIp, requestUri);
                sendRateLimitResponse(response, "Terlalu banyak permintaan submit ujian secara bersamaan. Silakan tunggu beberapa saat.");
                return;
            }
        }

        // Tier 3: Rate limit umum untuk seluruh endpoint API lainnya
        else if (requestUri.startsWith("/api/v1")) {
            Bucket generalBucket = generalIpBuckets.computeIfAbsent(
                    clientIp, k -> createBucket(GENERAL_CAPACITY, Duration.ofMinutes(1))
            );
            if (!generalBucket.tryConsume(1)) {
                log.warn("Rate limit [GENERAL API] terlampaui untuk IP {} pada {}", clientIp, requestUri);
                sendRateLimitResponse(response, "Batas permintaan API terlampaui. Silakan kurangi frekuensi permintaan Anda.");
                return;
            }
        }

        filterChain.doFilter(request, response);
    }

    private void sendRateLimitResponse(HttpServletResponse response, String message) throws IOException {
        response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); // HTTP 429
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");
        response.setHeader("Retry-After", "60");
        response.setHeader("X-RateLimit-Retry-After-Seconds", "60");

        String jsonPayload = String.format("{\"success\":false,\"message\":\"%s\",\"data\":null}", message);
        response.getWriter().write(jsonPayload);
        response.getWriter().flush();
    }
}

Posisi Filter & Pencegahan Eksekusi Ganda

Kesalahan umum dalam konfigurasi Spring Boot adalah menandai filter sebagai @Component sekaligus mendaftarkannya pada HttpSecurity.addFilterBefore(...). Kondisi ini menyebabkan servlet container mengeksekusi filter dua kali per request!

Pada SecurityConfig.java, hal ini diselesaikan dengan:

  1. Menonaktifkan registrasi otomatis servlet via FilterRegistrationBean.
  2. Menempatkan RateLimitingFilter sebelum LogoutFilter.class, sehingga request yang melebihi batas ditolak sebelum membebani koneksi basis data atau enkripsi kriptografi:

JAVA

// backend/src/main/java/com/elearning/config/SecurityConfig.java
http
    .addFilterBefore(rateLimitingFilter, LogoutFilter.class)
    .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

@Bean
public FilterRegistrationBean<RateLimitingFilter> rateLimitingFilterRegistration(RateLimitingFilter filter) {
    FilterRegistrationBean<RateLimitingFilter> registration = new FilterRegistrationBean<>(filter);
    registration.setEnabled(false); // Mencegah eksekusi ganda oleh Servlet Container
    return registration;
}

4. Engine Impersonasi Administrator 1-Klik

Pada lingkungan akademik berskala nyata, staf administrator sekolah sering kali perlu memeriksa kendala tampilan atau kejanggalan pada sesi pengerjaan ujian seorang siswa. Pada sistem monolitik berbasis session cookie, admin biasanya menggunakan helper Auth::loginUsingId(). Bagaimana cara melakukan hal ini pada arsitektur stateless JWT tanpa membocorkan kata sandi atau merusak audit trail?

Pada AuthController.java dan AuthService.java, kami merancang Engine Impersonasi Aman:

JAVA

// backend/src/main/java/com/elearning/modules/auth/controller/AuthController.java
@PostMapping("/admin/impersonate/{userId}")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Mulai Impersonasi", description = "Admin dapat login sebagai siswa atau guru untuk investigasi")
public ResponseEntity<ApiResponse<AuthResponse>> impersonate(
        @PathVariable Long userId,
        @AuthenticationPrincipal UserPrincipal adminPrincipal
) {
    AuthResponse response = authService.impersonate(userId, adminPrincipal);
    return ResponseEntity.ok(ApiResponse.ok(response, "Sesi impersonasi berhasil dimulai"));
}

@PostMapping("/admin/stop-impersonate")
@Operation(summary = "Hentikan Impersonasi", description = "Kembalikan sesi impersonasi ke akun admin semula")
public ResponseEntity<ApiResponse<AuthResponse>> stopImpersonate(
        @AuthenticationPrincipal UserPrincipal currentPrincipal
) {
    AuthResponse response = authService.stopImpersonate(currentPrincipal);
    return ResponseEntity.ok(ApiResponse.ok(response, "Kembali ke sesi admin"));
}

Bagaimana Token Menjaga Jejak Audit?

Saat authService.impersonate(userId, adminPrincipal) dieksekusi:

  1. Sistem memastikan pengguna target berstatus aktif.
  2. Dibuat sebuah token akses baru yang memiliki subject berupa ID siswa target dengan roles sesuai hak akses siswa tersebut.
  3. Klaim khusus impersonatorAdminId disisipkan ke dalam token berisi ID administrator yang melakukan impersonasi.
  4. Ketika admin menekan tombol Kembali ke Akun Admin (/api/v1/admin/stop-impersonate), server membaca currentPrincipal.getImpersonatorAdminId(), memvalidasi akun admin semula, dan menerbitkan kembali token admin tanpa perlu meminta input password ulang.

5. Matriks Mitigasi Standar OWASP Top 10

Berikut adalah pemetaan komprehensif bagaimana setiap kategori kerentanan dalam standar OWASP Top 10 (2021) dimitigasi secara sistematis pada repositori Spring Boot + React ini:

Kategori OWASPSkenario RisikoMitigasi Teknis di spring-elearning-react
A01: Broken Access ControlSiswa mengubah nilai, membaca ujian kelas lain (IDOR), atau manipulasi folder server (path traversal).• Anotasi @PreAuthorize("hasAnyRole(...)") pada level method.
• Validasi kepemilikan data di lapisan service.
• Pembatasan boundary ketat .startsWith(uploadDir) di FileStorageService.
A02: Cryptographic FailuresPenyimpanan password lemah atau penggunaan kunci JWT yang mudah ditebak.• Hashing password adaptif BCryptPasswordEncoder faktor kekuatan 12.
• Validasi kunci rahasia minimal 256-bit (32-byte) pada JwtProvider.
• Enkripsi HTTPS wajib pada Host Nginx reverse proxy.
A03: InjectionSerangan SQL injection pada kolom pencarian siswa atau bank soal ujian.• 100% Parameterized queries via Spring Data JPA dan Hibernate ORM.
• Nol konkatenasi string SQL mentah.
• Validasi tipe data ketat via DTO.
A04: Insecure DesignSiswa memanipulasi jam laptop untuk memperpanjang waktu ujian secara curang.• Penghitungan sisa waktu ujian sepenuhnya otoritatif di server (calculateRemainingSeconds).
• Telemetri perpindahan tab/jendela (tab switch) tercatat otomatis di backend.
A05: Security MisconfigurationPesan error SQL membocorkan nama tabel dan versi basis data ke publik.• GlobalExceptionHandler menyamarkan exception internal dengan pesan generik yang aman (mitigasi CWE-209).
• Endpoint Actuator dibatasi hanya untuk /actuator/health dan /actuator/info.
A06: Vulnerable ComponentsKerentanan CVE pada pustaka Maven pihak ketiga.• Pijakan modern menggunakan Java 21 LTS dan Spring Boot 3.4.3.
• Pemindaian otomatis berkala dependensi via Dependabot.
A07: Identification FailuresSerangan brute-force kata sandi dan pembajakan token kedaluwarsa.• Filter multi-tier Bucket4j RateLimitingFilter (10 req/menit/IP).
• Engine rotasi Refresh Token otomatis.
• Jendela waktu access token singkat (15 menit).
A08: Software & Data IntegrityPengiriman payload JSON berbahaya atau data entitas tidak valid.• Validasi deklaratif jakarta.validation (@Valid, @NotBlank, @Size, @Email).
• Imutabilitas DTO menggunakan Java records dan Lombok builders.
A09: Security Logging & MonitoringSerangan siber tidak terdeteksi karena minimnya log server.• Logging terstruktur SLF4J untuk seluruh exception keamanan.
• Pencatatan audit admin impersonator pada setiap klaim JWT.
• Probe kesehatan Actuator untuk kesiapan kontainer Docker.
A10: Server-Side Request Forgery (SSRF)Penyerang memaksa server mengunduh URL berbahaya dari jaringan internal.• Direktori upload berkas terisolasi secara lokal.
• Tidak mengizinkan download URL eksternal yang tidak divalidasi.

Kesimpulan & Langkah Selanjutnya

Membangun keamanan aplikasi tingkat enterprise tidak dapat diselesaikan hanya dengan plugin tambahan atau konfigurasi instan—dibutuhkan arsitektur berlapis yang dirancang matang sejak awal.

Melalui integrasi engine JJWT 0.12, kontrol akses deklaratif @PreAuthorize method security, rate limiting Token Bucket Bucket4j, dan penerapan standar OWASP Top 10, platform Spring E-Learning membuktikan bahwa arsitektur Java 21 & React 19 sanggup menyajikan ketahanan kelas enterprise tanpa mengorbankan performa tinggi pada pelaksanaan ujian sekolah massal.

Pada Part 4 (Final), seri ini akan ditutup dengan panduan implementasi operasional server nyata: Single VPS Production Deployment: Docker Compose, Host Nginx Reverse Proxy & SSL Let's Encrypt.

Lanjut Membaca

Previous article thumbnail

← Artikel Sebelumnya

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

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