Dua kata ini sering dipakai bergantian seolah bermakna sama, padahal menjawab dua pertanyaan yang berbeda. Authentication menjawab “siapa Anda” — memeriksa kredensial lalu membangun identitas. Authorization menjawab “apa yang boleh Anda lakukan” — mengevaluasi identitas itu terhadap aturan akses. Pada Chapter ini kita bedah kedua tahap itu di Spring Security: pembentukan objek Authentication, evaluasi role dan authority, dan alasan respons 401 serta 403 tidak boleh dipertukarkan.

Dua Pertanyaan yang Sering Tertukar #

Bayangkan resepsionis gedung perkantoran. Pertama, pengunjung menyerahkan kartu identitas dan resepsionis memeriksa keasliannya — itu authentication. Kedua, resepsionis melihat ruangan mana saja yang boleh dimasuki pemegang kartu itu — itu authorization. Pengunjung tanpa kartu ditolak di lobi; pengunjung berkartu yang memaksa masuk ruang server juga ditolak, dengan alasan berbeda. Tabel berikut memetakan perbedaannya:

AspekAuthenticationAuthorization
Pertanyaansiapa pengirim request?boleh melakukan aksi ini?
Inputkredensial (sandi, token)identitas + aturan akses
Berjalan lebih duluyasetelah identitas terbentuk
Respons saat gagal401 Unauthorized403 Forbidden

Pemisahan ini bukan formalitas arsitektur: kode keamanan yang menyebar ke tiap controller membuka celah di sambungannya. Filter chain pada Chapter Spring Security menempatkan AuthenticationFilter sebelum AuthorizationFilter — urutan yang sama menyertai kita sepanjang Chapter ini.

Authentication di Spring Security #

Inti autentikasi Spring Security adalah AuthenticationManager — padanan resepsionis tadi. Implementasi standarnya, ProviderManager, mendelegasikan pemeriksaan ke AuthenticationProvider; untuk login nama pengguna + kata sandi, kandidatnya DaoAuthenticationProvider. Provider ini tidak membaca tabel sendiri, melainkan bertanya ke UserDetailsService — kontrak satu method loadUserByUsername(String). Berikut bentuk nyatanya, ditampilkan apa adanya untuk Spring Boot 3.4/3.5:

java
@Service
public class PenggunaService implements UserDetailsService {

    private final PenggunaRepository repo;

    public PenggunaService(PenggunaRepository repo) {
        this.repo = repo;
    }

    @Override
    public UserDetails loadUserByUsername(String nama) {
        Pengguna p = repo.cariByUsername(nama)
                .orElseThrow(() -> new UsernameNotFoundException(nama));
        return User.builder()
                .username(p.getUsername())
                .password(p.getPasswordHash())   // nilai TER-HASH, bukan sandi polos
                .roles(p.getRoles().toArray(String[]::new))
                .build();
    }
}

@Bean
public AuthenticationManager authenticationManager(
        UserDetailsService layanan, PasswordEncoder encoder) {
    DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
    provider.setUserDetailsService(layanan);
    provider.setPasswordEncoder(encoder);
    return new ProviderManager(provider);
}

Ketika kredensial cocok, provider membentuk objek Authentication berisi principal (identitas, biasanya UserDetails), credentials (dibersihkan setelah pemeriksaan), dan authorities (daftar izin). Objek itu disimpan di SecurityContextHolder dan dibaca lapisan otorisasi — identitas sekali dibangun, dipakai di mana-mana.

Role, Authority, dan Awalan ROLE #

Spring Security menyimpan seluruh izin sebagai authority — string polos seperti EDITOR atau produk:tulis. Role hanyalah authority berawalan ROLE_: hasRole("ADMIN") mencari authority ROLE_ADMIN, sedangkan hasAuthority("ROLE_ADMIN") mencari string yang persis sama. Konsekuensinya, hasAuthority("ADMIN") pada data yang menyimpan ROLE_ADMIN gagal cocok diam-diam — pilih satu konvensi lalu pertahankan; roles(...) pada builder menambahkan awalan ROLE_ otomatis.

Otorisasi berjalan di dua tempat: level URL pada authorizeHttpRequests (yang sudah kita konfigurasi di Chapter Spring Security) dan level method melalui anotasi @PreAuthorize — ditampilkan apa adanya:

java
@Service
public class LaporanService {

    @PreAuthorize("hasRole('ADMIN')")
    public Laporan auditKeuangan(int tahun) { ... }

    @PreAuthorize("hasRole('ADMIN') or hasAuthority('laporan:baca')")
    public Laporan ringkasan(int tahun) { ... }
}

Level URL melindungi tepi aplikasi; level method melindungi aturan bisnis di dalam service sehingga tetap berlaku meski method dipanggil dari scheduler, message listener, atau kelas lain. Kombinasi keduanya adalah praktik lazim: pagar kasar di URL, pagar halus di method.

Simulasi Dua Tahap Secara Nyata #

Mekanisme dua tahap itu layak dilihat dari dalam. Program Java murni berikut membangun UserDetailsService mini, AuthenticationManager mini, dan kebijakan akses per path, lalu melewatkan sembilan skenario:

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

public class AutentikasiOtorisasi {

    // ---- Sisi server: basis data pengguna. Peran TIDAK PERNAH dibaca dari klien. ----
    record Pengguna(String nama, String sandiTersimpan, List<String> peran) { }

    // Authentication yang berhasil: identitas + daftar otoritas.
    // Peran "USER" disimpan sebagai otoritas "ROLE_USER" (konvensi Spring Security).
    record Autentikasi(String nama, List<String> otoritas) {

        boolean punyaOtoritas(String o) {
            return otoritas.contains(o);
        }

        boolean punyaPeran(String p) {
            return otoritas.contains("ROLE_" + p);
        }
    }

    // Padanan UserDetailsService: mencari pengguna berdasarkan nama.
    static class LayananPengguna {

        private final Map<String, Pengguna> tabel = new LinkedHashMap<>();

        LayananPengguna() {
            tabel.put("budi", new Pengguna("budi", hash("kopiGayo123"), List.of("USER")));
            tabel.put("siti", new Pengguna("siti", hash("santiSiti456"), List.of("USER", "EDITOR")));
            tabel.put("admin", new Pengguna("admin", hash("rahasiaAdmin789"), List.of("USER", "ADMIN")));
        }

        Pengguna cari(String nama) {
            return tabel.get(nama);
        }
    }

    // Padanan AuthenticationManager + DaoAuthenticationProvider.
    static class PengelolaAutentikasi {

        private final LayananPengguna layanan;

        PengelolaAutentikasi(LayananPengguna layanan) {
            this.layanan = layanan;
        }

        // null = gagal (401); non-null = identitas terbentuk.
        Autentikasi autentikasi(String nama, String sandi) {
            Pengguna p = layanan.cari(nama);
            if (p == null || !p.sandiTersimpan().equals(hash(sandi))) {
                return null;
            }
            List<String> otoritas = new ArrayList<>();
            for (String peran : p.peran()) {
                otoritas.add("ROLE_" + peran);
            }
            return new Autentikasi(nama, otoritas);
        }
    }

    // Padanan authorizeHttpRequests: aturan akses per pola path.
    static class KebijakanAkses {

        String periksa(Autentikasi a, String path) {
            if (path.startsWith("/api/admin/")) {
                if (a == null) return "401";
                return a.punyaPeran("ADMIN") ? "200" : "403";
            }
            if (path.startsWith("/api/artikel/sunting/")) {
                if (a == null) return "401";
                return (a.punyaPeran("ADMIN") || a.punyaOtoritas("ROLE_EDITOR")) ? "200" : "403";
            }
            return "200"; // path publik
        }
    }

    // Versi RENTAN: keputusan akses memercayai header dari klien.
    static String periksaRentan(String headerPeran, String path) {
        return "ADMIN".equals(headerPeran) || !path.startsWith("/api/admin/") ? "200" : "403";
    }

    public static void main(String[] args) {
        PengelolaAutentikasi pengelola = new PengelolaAutentikasi(new LayananPengguna());
        KebijakanAkses kebijakan = new KebijakanAkses();

        System.out.println("== Tahap 1: Authentication (siapa Anda?) ==");
        Autentikasi budi = pengelola.autentikasi("budi", "kopiGayo123");
        Autentikasi salahSandi = pengelola.autentikasi("budi", "tebakan-salah");
        Autentikasi takDikenal = pengelola.autentikasi("hacker", "apa-pun");
        System.out.println("budi + sandi benar        -> " + ringkas(budi));
        System.out.println("budi + sandi salah        -> " + ringkas(salahSandi));
        System.out.println("pengguna tak dikenal      -> " + ringkas(takDikenal));
        System.out.println();

        System.out.println("== Tahap 2: Authorization (apa yang boleh?) ==");
        uji(kebijakan, budi, "/api/produk");
        uji(kebijakan, budi, "/api/admin/log");
        Autentikasi admin = pengelola.autentikasi("admin", "rahasiaAdmin789");
        uji(kebijakan, admin, "/api/admin/log");
        Autentikasi siti = pengelola.autentikasi("siti", "santiSiti456");
        uji(kebijakan, siti, "/api/artikel/sunting/12");
        uji(kebijakan, budi, "/api/artikel/sunting/12");
        uji(kebijakan, null, "/api/produk");
        uji(kebijakan, null, "/api/admin/log");
        System.out.println();

        System.out.println("== Tahap 3: jebakan peran dari klien ==");
        System.out.println("Request budi menyertakan header X-Role: ADMIN ke /api/admin/log");
        System.out.println("Versi rentan (percaya header) -> respons " + periksaRentan("ADMIN", "/api/admin/log"));
        System.out.println("Versi aman (peran dari server) -> respons " + kebijakan.periksa(budi, "/api/admin/log"));
    }

    // Fungsi hash tiruan agar sandi polos tidak pernah disimpan/dibandingkan langsung.
    // Chapter Password Encoding membedah algoritma yang tepat untuk kata sandi.
    static String hash(String sandi) {
        try {
            byte[] cerna = MessageDigest.getInstance("SHA-256")
                    .digest(sandi.getBytes(StandardCharsets.UTF_8));
            return "sha256$" + HexFormat.of().formatHex(cerna);
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException("SHA-256 wajib tersedia di JVM", e);
        }
    }

    static void uji(KebijakanAkses k, Autentikasi a, String path) {
        String siapa = a == null ? "anonim" : a.nama() + " " + a.otoritas();
        System.out.printf("%-40s %-26s -> %s%n", siapa, path, k.periksa(a, path));
    }

    static String ringkas(Autentikasi a) {
        return a == null ? "GAGAL (lempar AuthenticationException -> 401)"
                : "BERHASIL: nama=" + a.nama() + ", otoritas=" + a.otoritas();
    }
}

Output yang Dihasilkan:

text
== Tahap 1: Authentication (siapa Anda?) ==
budi + sandi benar        -> BERHASIL: nama=budi, otoritas=[ROLE_USER]
budi + sandi salah        -> GAGAL (lempar AuthenticationException -> 401)
pengguna tak dikenal      -> GAGAL (lempar AuthenticationException -> 401)

== Tahap 2: Authorization (apa yang boleh?) ==
budi [ROLE_USER]                         /api/produk                -> 200
budi [ROLE_USER]                         /api/admin/log             -> 403
admin [ROLE_USER, ROLE_ADMIN]            /api/admin/log             -> 200
siti [ROLE_USER, ROLE_EDITOR]            /api/artikel/sunting/12    -> 200
budi [ROLE_USER]                         /api/artikel/sunting/12    -> 403
anonim                                   /api/produk                -> 200
anonim                                   /api/admin/log             -> 401

== Tahap 3: jebakan peran dari klien ==
Request budi menyertakan header X-Role: ADMIN ke /api/admin/log
Versi rentan (percaya header) -> respons 200
Versi aman (peran dari server) -> respons 403

Mari kita bedah bagian kuncinya:

  1. LayananPengguna: padanan UserDetailsService — LinkedHashMap tiga pengguna ber-sandi ter-hash dan ber-daftar peran. cari(nama) hanya menyediakan data; keputusan tetap di pengelola autentikasi.
  2. PengelolaAutentikasi.autentikasi(nama, sandi): padanan DaoAuthenticationProvider — memanggil cari(...), menghitung ulang hash sandi yang dikirim, lalu membandingkan dengan nilai tersimpan. Sandi polos tidak pernah disimpan atau dibandingkan langsung; gagal pencocokan mengembalikan null, yang di Spring nyata menjadi AuthenticationException lalu respons 401.
  3. otoritas.add("ROLE_" + peran): baris ini meniru perilaku roles(...) pada User.builder() — peran EDITOR berubah menjadi authority ROLE_EDITOR. Karena itulah punyaPeran("ADMIN") cukup memeriksa keberadaan ROLE_ADMIN di daftar.
  4. KebijakanAkses.periksa(autentikasi, path): padanan authorizeHttpRequests. Dua cabang penting: request anonim ke path terlindungi menghasilkan 401 (identitas belum ada), request terautentikasi tanpa hak menghasilkan 403 (identitas ada, izin tidak). siti lolos menyunting karena ROLE_EDITOR; budi ditolak meski loginnya sah.
  5. Tahap 3 versi rentan vs aman: periksaRentan(headerPeran, path) memutuskan akses dari header X-Role yang dikirim klien — Budi cukup menyelipkan X-Role: ADMIN untuk mendapat respons 200. Versi aman mengabaikan header sepenuhnya; keputusan hanya membaca otoritas dari Autentikasi yang dibangun server.

Autentikasi simulasi kita berhasil karena hash sandi yang dikirim cocok dengan hash tersimpan. Pertanyaan berikutnya: algoritma apa yang tepat untuk hash itu, mengapa SHA-256 yang cepat justru kelemahan, dan bagaimana DelegatingPasswordEncoder mengelolanya — pokok bahasan Chapter Password Encoding.

Referensi #

Berkas Sumber

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

AutentikasiOtorisasi.java

Unduh berkas
java
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

public class AutentikasiOtorisasi {

    // ---- Sisi server: basis data pengguna. Peran TIDAK PERNAH dibaca dari klien. ----
    record Pengguna(String nama, String sandiTersimpan, List<String> peran) { }

    // Authentication yang berhasil: identitas + daftar otoritas.
    // Peran "USER" disimpan sebagai otoritas "ROLE_USER" (konvensi Spring Security).
    record Autentikasi(String nama, List<String> otoritas) {

        boolean punyaOtoritas(String o) {
            return otoritas.contains(o);
        }

        boolean punyaPeran(String p) {
            return otoritas.contains("ROLE_" + p);
        }
    }

    // Padanan UserDetailsService: mencari pengguna berdasarkan nama.
    static class LayananPengguna {

        private final Map<String, Pengguna> tabel = new LinkedHashMap<>();

        LayananPengguna() {
            tabel.put("budi", new Pengguna("budi", hash("kopiGayo123"), List.of("USER")));
            tabel.put("siti", new Pengguna("siti", hash("santiSiti456"), List.of("USER", "EDITOR")));
            tabel.put("admin", new Pengguna("admin", hash("rahasiaAdmin789"), List.of("USER", "ADMIN")));
        }

        Pengguna cari(String nama) {
            return tabel.get(nama);
        }
    }

    // Padanan AuthenticationManager + DaoAuthenticationProvider.
    static class PengelolaAutentikasi {

        private final LayananPengguna layanan;

        PengelolaAutentikasi(LayananPengguna layanan) {
            this.layanan = layanan;
        }

        // null = gagal (401); non-null = identitas terbentuk.
        Autentikasi autentikasi(String nama, String sandi) {
            Pengguna p = layanan.cari(nama);
            if (p == null || !p.sandiTersimpan().equals(hash(sandi))) {
                return null;
            }
            List<String> otoritas = new ArrayList<>();
            for (String peran : p.peran()) {
                otoritas.add("ROLE_" + peran);
            }
            return new Autentikasi(nama, otoritas);
        }
    }

    // Padanan authorizeHttpRequests: aturan akses per pola path.
    static class KebijakanAkses {

        String periksa(Autentikasi a, String path) {
            if (path.startsWith("/api/admin/")) {
                if (a == null) return "401";
                return a.punyaPeran("ADMIN") ? "200" : "403";
            }
            if (path.startsWith("/api/artikel/sunting/")) {
                if (a == null) return "401";
                return (a.punyaPeran("ADMIN") || a.punyaOtoritas("ROLE_EDITOR")) ? "200" : "403";
            }
            return "200"; // path publik
        }
    }

    // Versi RENTAN: keputusan akses memercayai header dari klien.
    static String periksaRentan(String headerPeran, String path) {
        return "ADMIN".equals(headerPeran) || !path.startsWith("/api/admin/") ? "200" : "403";
    }

    public static void main(String[] args) {
        PengelolaAutentikasi pengelola = new PengelolaAutentikasi(new LayananPengguna());
        KebijakanAkses kebijakan = new KebijakanAkses();

        System.out.println("== Tahap 1: Authentication (siapa Anda?) ==");
        Autentikasi budi = pengelola.autentikasi("budi", "kopiGayo123");
        Autentikasi salahSandi = pengelola.autentikasi("budi", "tebakan-salah");
        Autentikasi takDikenal = pengelola.autentikasi("hacker", "apa-pun");
        System.out.println("budi + sandi benar        -> " + ringkas(budi));
        System.out.println("budi + sandi salah        -> " + ringkas(salahSandi));
        System.out.println("pengguna tak dikenal      -> " + ringkas(takDikenal));
        System.out.println();

        System.out.println("== Tahap 2: Authorization (apa yang boleh?) ==");
        uji(kebijakan, budi, "/api/produk");
        uji(kebijakan, budi, "/api/admin/log");
        Autentikasi admin = pengelola.autentikasi("admin", "rahasiaAdmin789");
        uji(kebijakan, admin, "/api/admin/log");
        Autentikasi siti = pengelola.autentikasi("siti", "santiSiti456");
        uji(kebijakan, siti, "/api/artikel/sunting/12");
        uji(kebijakan, budi, "/api/artikel/sunting/12");
        uji(kebijakan, null, "/api/produk");
        uji(kebijakan, null, "/api/admin/log");
        System.out.println();

        System.out.println("== Tahap 3: jebakan peran dari klien ==");
        System.out.println("Request budi menyertakan header X-Role: ADMIN ke /api/admin/log");
        System.out.println("Versi rentan (percaya header) -> respons " + periksaRentan("ADMIN", "/api/admin/log"));
        System.out.println("Versi aman (peran dari server) -> respons " + kebijakan.periksa(budi, "/api/admin/log"));
    }

    // Fungsi hash tiruan agar sandi polos tidak pernah disimpan/dibandingkan langsung.
    // Chapter Password Encoding membedah algoritma yang tepat untuk kata sandi.
    static String hash(String sandi) {
        try {
            byte[] cerna = MessageDigest.getInstance("SHA-256")
                    .digest(sandi.getBytes(StandardCharsets.UTF_8));
            return "sha256$" + HexFormat.of().formatHex(cerna);
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException("SHA-256 wajib tersedia di JVM", e);
        }
    }

    static void uji(KebijakanAkses k, Autentikasi a, String path) {
        String siapa = a == null ? "anonim" : a.nama() + " " + a.otoritas();
        System.out.printf("%-40s %-26s -> %s%n", siapa, path, k.periksa(a, path));
    }

    static String ringkas(Autentikasi a) {
        return a == null ? "GAGAL (lempar AuthenticationException -> 401)"
                : "BERHASIL: nama=" + a.nama() + ", otoritas=" + a.otoritas();
    }
}