Aplikasi yang menerima request dari internet menanggung beban yang tidak bisa diwujudkan lewat kebiasaan baik saja: permintaan tanpa identitas, token palsu, pemalsuan lintas situs, sesi pembajakan. Spring Security menangani seluruh beban itu di satu tempat — bukan dengan kode yang kita tempel di setiap controller, melainkan dengan filter chain: deretan filter servlet yang memeriksa setiap request sebelum mencapai kode aplikasi. Pada Chapter ini kita bedah arsitektur filter chain, konfigurasi modern berbasis SecurityFilterChain, proteksi CSRF, dan pilihan sesi versus stateless.

Arsitektur Filter Chain #

Servlet container mengenal konsep filter — kode yang membungkus pemrosesan request/response. Spring Security memanfaatkannya dengan memasang springSecurityFilterChain yang membawa belasan filter terurut: SecurityContextHolderFilter menyiapkan konteks keamanan, CsrfFilter memeriksa token CSRF, UsernamePasswordAuthenticationFilter membaca form login, BearerTokenAuthenticationFilter membaca token JWT, hingga AuthorizationFilter yang memutuskan akses per path. Ketika satu filter menolak, ExceptionTranslationFilter dan AuthenticationEntryPoint menerjemahkan penolakan menjadi respons 401/403 — request tidak pernah sampai ke controller.

Konteks identitas mengalir lewat SecurityContextHolder: setelah authentication berhasil, objek Authentication disimpan di sana (secara bawaan per-thread) dan lapisan berikutnya membacanya untuk keputusan otorisasi. Diagram berikut merangkum alurnya:

Konfigurasi SecurityFilterChain #

Sejak Spring Security 6 (dan Boot 3), kelas WebSecurityConfigurerAdapter telah dihapus; konfigurasi kini berdiri di atas bean SecurityFilterChain bergaya komponen. Berikut konfigurasi API stateless yang lazim — ditampilkan apa adanya untuk Spring Boot 3.4/3.5:

java
@Configuration
@EnableWebSecurity
public class KonfigurasiKeamanan {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(sesi -> sesi
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/publik/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated())
            .httpBasic(Customizer.withDefaults());
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

Gaya lambda di atas menggantikan chaining lama .and() — tiap blok mengonfigurasi satu aspek dan mengembalikan HttpSecurity. Method authorizeHttpRequests adalah jantung otorisasi: requestMatchers mencocokkan path, lalu aturan (permitAll, hasRole, authenticated) menentukan siapa yang lolos; anyRequest().authenticated() menjadi pagar penutup.

CSRF dan Pilihan Stateless #

Cross-Site Request Forgery mengeksploitasi sesi cookie: saat Budi masih login di aplikasi kita, halaman jahat di tab lain bisa mengirim request atas namanya. CsrfFilter menutup celah ini dengan mensyaratkan token yang tidak bisa dibaca pihak lain — dan proteksi ini aktif secara bawaan untuk aplikasi berbasis cookie/sesi. Untuk API stateless murni yang diautentikasi lewat header Authorization: Bearer ..., cookie tidak dipakai dan proteksi CSRF lazim dimatikan seperti pada konfigurasi di atas.

Pilihan sesi versus stateless menentukan banyak hal:

AspekSession + cookieStateless (JWT)
State di serverada (sesi per pengguna)tidak ada
Skala horizontalbutuh session store bersamacukup load balancer
Pencabutan akseslangsung (hapus sesi)menunggu token kedaluwarsa
Ukuran per requestcookie keciltoken relatif besar di header

API publik dengan banyak instance umumnya memilih stateless; aplikasi internal berbasis browser sering tetap paling produktif dengan sesi — Spring Security mendukung keduanya tanpa mengubah kode controller.

Simulasi Filter Chain #

Urutan filter dan pemutusan rantai layak diamati secara langsung. Program Java murni berikut membangun rantai empat filter dan melewatkan lima request yang berbeda nasibnya:

import java.util.List;
import java.util.Map;

public class RantaiFilter {

    record Permintaan(String method, String path, String token, String csrf) { }

    interface Filter {
        String nama();

        // null = lanjut ke filter berikutnya; non-null = alasan penolakan.
        String proses(Permintaan p);
    }

    // Filter pertama: CORS memeriksa asal permintaan.
    static class FilterCors implements Filter {
        @Override
        public String nama() { return "CorsFilter"; }

        @Override
        public String proses(Permintaan p) {
            return null; // asal diizinkan untuk demo
        }
    }

    // CSRF: method penulisan wajib membawa token CSRF.
    static class FilterCsrf implements Filter {
        @Override
        public String nama() { return "CsrfFilter"; }

        @Override
        public String proses(Permintaan p) {
            boolean methodTulis = "POST".equals(p.method()) || "PUT".equals(p.method())
                    || "DELETE".equals(p.method()) || "PATCH".equals(p.method());
            if (methodTulis && (p.csrf() == null || p.csrf().isBlank())) {
                return "token CSRF hilang untuk method " + p.method();
            }
            return null;
        }
    }

    // Authentication: identitas wajib ada.
    static class FilterAuth implements Filter {
        private final Map<String, String> tokenKePengguna = Map.of(
                "token-budi", "budi",
                "token-admin", "admin");

        @Override
        public String nama() { return "AuthenticationFilter"; }

        @Override
        public String proses(Permintaan p) {
            if (p.token() == null || !tokenKePengguna.containsKey(p.token())) {
                return "tidak terautentikasi";
            }
            return null;
        }
    }

    // Authorization: aturan akses per pola path.
    static class FilterOtorisasi implements Filter {
        private final Map<String, String> tokenKePeran = Map.of(
                "token-budi", "USER",
                "token-admin", "ADMIN");

        @Override
        public String nama() { return "AuthorizationFilter"; }

        @Override
        public String proses(Permintaan p) {
            String peran = p.token() == null ? null : tokenKePeran.get(p.token());
            if (p.path().startsWith("/api/admin") && !"ADMIN".equals(peran)) {
                return "peran " + peran + " tidak berhak";
            }
            return null;
        }
    }

    public static void main(String[] args) {
        List<Filter> rantai = List.of(
                new FilterCors(),
                new FilterCsrf(),
                new FilterAuth(),
                new FilterOtorisasi());

        layani(rantai, new Permintaan("GET", "/api/produk", "token-budi", null));
        layani(rantai, new Permintaan("POST", "/api/produk", "token-budi", null));
        layani(rantai, new Permintaan("GET", "/api/produk", null, null));
        layani(rantai, new Permintaan("GET", "/api/admin/log", "token-budi", null));
        layani(rantai, new Permintaan("GET", "/api/admin/log", "token-admin", null));
    }

    // SecurityFilterChain mini: proses berurutan, tolakan memutus rantai.
    static void layani(List<Filter> rantai, Permintaan p) {
        System.out.println("Request " + p.method() + " " + p.path()
                + " token=" + p.token() + " csrf=" + p.csrf());
        for (Filter f : rantai) {
            String penolakan = f.proses(p);
            System.out.println("  " + f.nama() + " -> "
                    + (penolakan == null ? "lanjut" : "TOLAK (" + penolakan + ")"));
            if (penolakan != null) {
                System.out.println("  respons: 403 Forbidden, handler tidak terpanggil");
                System.out.println();
                return;
            }
        }
        System.out.println("  respons: 200 OK, handler dipanggil");
        System.out.println();
    }
}

Output yang Dihasilkan:

text
Request GET /api/produk token=token-budi csrf=null
  CorsFilter -> lanjut
  CsrfFilter -> lanjut
  AuthenticationFilter -> lanjut
  AuthorizationFilter -> lanjut
  respons: 200 OK, handler dipanggil

Request POST /api/produk token=token-budi csrf=null
  CorsFilter -> lanjut
  CsrfFilter -> TOLAK (token CSRF hilang untuk method POST)
  respons: 403 Forbidden, handler tidak terpanggil

Request GET /api/produk token=null csrf=null
  CorsFilter -> lanjut
  CsrfFilter -> lanjut
  AuthenticationFilter -> TOLAK (tidak terautentikasi)
  respons: 403 Forbidden, handler tidak terpanggil

Request GET /api/admin/log token=token-budi csrf=null
  CorsFilter -> lanjut
  CsrfFilter -> lanjut
  AuthenticationFilter -> lanjut
  AuthorizationFilter -> TOLAK (peran USER tidak berhak)
  respons: 403 Forbidden, handler tidak terpanggil

Request GET /api/admin/log token=token-admin csrf=null
  CorsFilter -> lanjut
  CsrfFilter -> lanjut
  AuthenticationFilter -> lanjut
  AuthorizationFilter -> lanjut
  respons: 200 OK, handler dipanggil

Mari kita bedah simulasi tersebut:

  1. interface Filter dengan proses(p): kontrak mengembalikan null untuk melanjutkan atau alasan penolakan sebagai String — bentuk sederhana dari keputusan AccessDeniedException di filter nyata. FilterCors, FilterCsrf, FilterAuth, dan FilterOtorisasi berurutan persis urutan logis Spring Security.
  2. FilterCsrf.proses: hanya method penulisan (POST/PUT/DELETE/PATCH) yang diperiksa — method GET dianggap aman. Request kedua POST /api/produk tanpa token CSRF ditolak di sini, dan output menunjukkan tiga filter setelahnya tidak pernah berjalan.
  3. FilterAuth dan FilterOtorisasi: auth menjawab “siapa”, otorisasi menjawab “boleh atau tidak”. Request keempat lolos authentication sebagai token-budi namun ditolak authorization karena path /api/admin/** menuntut peran ADMIN; request kelima dengan token-admin meloloskan seluruh rantai hingga handler.
  4. layani(...): loop rantai berhenti pada penolakan pertama — model fail fast yang sama dengan FilterChainProxy milik Spring. Bandingkan baris respons: 403 Forbidden, handler tidak terpanggil dengan 200 OK, handler dipanggil: seluruh keputusan keamanan selesai sebelum kode bisnis berjalan.

Filter chain menjawab “bagaimana request diperiksa”; pertanyaan “bagaimana identitas dibangun dan izin dievaluasi” menjadi bahasan dua Chapter berikutnya — mulai dari authentication dan authorization yang membedakan kedua kata yang sering tertukar itu.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

RantaiFilter.java

Unduh berkas
java
import java.util.List;
import java.util.Map;

public class RantaiFilter {

    record Permintaan(String method, String path, String token, String csrf) { }

    interface Filter {
        String nama();

        // null = lanjut ke filter berikutnya; non-null = alasan penolakan.
        String proses(Permintaan p);
    }

    // Filter pertama: CORS memeriksa asal permintaan.
    static class FilterCors implements Filter {
        @Override
        public String nama() { return "CorsFilter"; }

        @Override
        public String proses(Permintaan p) {
            return null; // asal diizinkan untuk demo
        }
    }

    // CSRF: method penulisan wajib membawa token CSRF.
    static class FilterCsrf implements Filter {
        @Override
        public String nama() { return "CsrfFilter"; }

        @Override
        public String proses(Permintaan p) {
            boolean methodTulis = "POST".equals(p.method()) || "PUT".equals(p.method())
                    || "DELETE".equals(p.method()) || "PATCH".equals(p.method());
            if (methodTulis && (p.csrf() == null || p.csrf().isBlank())) {
                return "token CSRF hilang untuk method " + p.method();
            }
            return null;
        }
    }

    // Authentication: identitas wajib ada.
    static class FilterAuth implements Filter {
        private final Map<String, String> tokenKePengguna = Map.of(
                "token-budi", "budi",
                "token-admin", "admin");

        @Override
        public String nama() { return "AuthenticationFilter"; }

        @Override
        public String proses(Permintaan p) {
            if (p.token() == null || !tokenKePengguna.containsKey(p.token())) {
                return "tidak terautentikasi";
            }
            return null;
        }
    }

    // Authorization: aturan akses per pola path.
    static class FilterOtorisasi implements Filter {
        private final Map<String, String> tokenKePeran = Map.of(
                "token-budi", "USER",
                "token-admin", "ADMIN");

        @Override
        public String nama() { return "AuthorizationFilter"; }

        @Override
        public String proses(Permintaan p) {
            String peran = p.token() == null ? null : tokenKePeran.get(p.token());
            if (p.path().startsWith("/api/admin") && !"ADMIN".equals(peran)) {
                return "peran " + peran + " tidak berhak";
            }
            return null;
        }
    }

    public static void main(String[] args) {
        List<Filter> rantai = List.of(
                new FilterCors(),
                new FilterCsrf(),
                new FilterAuth(),
                new FilterOtorisasi());

        layani(rantai, new Permintaan("GET", "/api/produk", "token-budi", null));
        layani(rantai, new Permintaan("POST", "/api/produk", "token-budi", null));
        layani(rantai, new Permintaan("GET", "/api/produk", null, null));
        layani(rantai, new Permintaan("GET", "/api/admin/log", "token-budi", null));
        layani(rantai, new Permintaan("GET", "/api/admin/log", "token-admin", null));
    }

    // SecurityFilterChain mini: proses berurutan, tolakan memutus rantai.
    static void layani(List<Filter> rantai, Permintaan p) {
        System.out.println("Request " + p.method() + " " + p.path()
                + " token=" + p.token() + " csrf=" + p.csrf());
        for (Filter f : rantai) {
            String penolakan = f.proses(p);
            System.out.println("  " + f.nama() + " -> "
                    + (penolakan == null ? "lanjut" : "TOLAK (" + penolakan + ")"));
            if (penolakan != null) {
                System.out.println("  respons: 403 Forbidden, handler tidak terpanggil");
                System.out.println();
                return;
            }
        }
        System.out.println("  respons: 200 OK, handler dipanggil");
        System.out.println();
    }
}