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:
| Aspek | Authentication | Authorization |
|---|---|---|
| Pertanyaan | siapa pengirim request? | boleh melakukan aksi ini? |
| Input | kredensial (sandi, token) | identitas + aturan akses |
| Berjalan lebih dulu | ya | setelah identitas terbentuk |
| Respons saat gagal | 401 Unauthorized | 403 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:
@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:
@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:
== 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 403Mari kita bedah bagian kuncinya:
LayananPengguna: padananUserDetailsService—LinkedHashMaptiga pengguna ber-sandi ter-hash dan ber-daftar peran.cari(nama)hanya menyediakan data; keputusan tetap di pengelola autentikasi.PengelolaAutentikasi.autentikasi(nama, sandi): padananDaoAuthenticationProvider— memanggilcari(...), menghitung ulang hash sandi yang dikirim, lalu membandingkan dengan nilai tersimpan. Sandi polos tidak pernah disimpan atau dibandingkan langsung; gagal pencocokan mengembalikannull, yang di Spring nyata menjadiAuthenticationExceptionlalu respons 401.otoritas.add("ROLE_" + peran): baris ini meniru perilakuroles(...)padaUser.builder()— peranEDITORberubah menjadi authorityROLE_EDITOR. Karena itulahpunyaPeran("ADMIN")cukup memeriksa keberadaanROLE_ADMINdi daftar.KebijakanAkses.periksa(autentikasi, path): padananauthorizeHttpRequests. Dua cabang penting: request anonim ke path terlindungi menghasilkan 401 (identitas belum ada), request terautentikasi tanpa hak menghasilkan 403 (identitas ada, izin tidak).sitilolos menyunting karenaROLE_EDITOR;budiditolak meski loginnya sah.- Tahap 3 versi rentan vs aman:
periksaRentan(headerPeran, path)memutuskan akses dari headerX-Roleyang dikirim klien — Budi cukup menyelipkanX-Role: ADMINuntuk mendapat respons 200. Versi aman mengabaikan header sepenuhnya; keputusan hanya membaca otoritas dariAutentikasiyang 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 #
- Spring Security Reference: Authentication
- Spring Security Reference: DaoAuthenticationProvider
- Spring Security Reference: Authorize HttpServletRequests
- Spring Security Reference: Method Security
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
AutentikasiOtorisasi.java
Unduh berkasimport 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();
}
}