Autentikasi menjawab pertanyaan “siapa pemanggil”. Authorization menjawab “apa yang boleh dilakukan pemanggil”. Dua konsep ini sering dicampur, tetapi memisahkannya membuat kode lebih jelas: autentikasi di plugin Authentication, authorization di route-level guard. Pada chapter ini kita akan membangun sistem authorization berbasis peran (Role-Based Access Control) dengan route guard dan kebijakan akses.
Role-Based Access Control #
Pada RBAC, setiap principal membawa set peran — misalnya setOf("admin", "editor", "user"). Setiap route diberi label peran yang diizinkan. Saat permintaan datang, guard memeriksa apakah principal memiliki salah satu peran yang dibutuhkan. Bila ya, handler dijalankan. Bila tidak, guard mengembalikan 403 Forbidden.
Pemisahan peran menjadi set (bukan string tunggal) memungkinkan satu principal memiliki banyak peran sekaligus — mis. admin otomatis dapat melakukan apa yang dapat editor lakukan.
Peran Umum #
| Peran | Cakupan akses |
|---|---|
admin | Semua operasi, termasuk manajemen user |
editor | CRUD konten, tidak kelola user |
user | Baca konten, ubah profil sendiri |
tamu (anonim) | Baca konten publik |
Route-Level Guard #
Route-level guard adalah lapisan middleware yang memeriksa otorisasi sebelum handler dijalankan. Ktor tidak menyertakan guard bawaan, tetapi pola yang lazim adalah membungkus handler dengan check peran.
authenticate("jwt") {
get("/admin/users") {
val principal = call.principal<UserPrincipal>()!!
if (!principal.peran.contains("admin")) {
call.respond(HttpStatusCode.Forbidden, mapOf("code" to "FORBIDDEN"))
return@get
}
call.respond(userService.list())
}
}Pola ini bekerja, tetapi mengulang check di setiap route menjadi verbose. Solusinya: kelas Authorizer yang menyatukan logika otorisasi.
Authorizer dan GuardedRouter #
Authorizer adalah class yang menerima principal dan peran, lalu memutuskan apakah principal diizinkan. GuardedRouter membungkus pendaftaran route dengan metadata peran — sehingga deklarasi route terlihat deklaratif:
router.get("/admin/users", peran = setOf("admin")) { principal ->
"""{"users":["Budi","Siti"]}"""
}
router.get("/posts", peran = setOf("admin", "editor", "user")) { principal ->
"""{"posts":["post1"]}"""
}Parameter peran menyatakan bahwa principal harus memiliki salah satu peran tersebut. Handler menerima Principal non-null karena guard sudah memverifikasi.
Demo Authorization #
Kita akan membangun Authorizer dengan method hasRole, hasAnyRole, dan requireAnyRole, serta GuardedRouter yang mengaitkan route dengan metadata peran. Program ini mendemonstrasikan akses yang berhasil dan ditolak.
// ktor_authorization.kt
// Mendemonstrasikan authorization berbasis peran: principal yang membawa
// peran, route-level guard, dan kebijakan akses.
interface Principal {
val peran: Set<String>
}
data class UserPrincipal(
val id: Int,
val nama: String,
override val peran: Set<String>
) : Principal
class ApplicationCall {
var principal: Principal? = null
var responseStatus: Int = 200
var responseBody: String = ""
}
// Authorizer memeriksa apakah principal memiliki salah satu peran yang dibutuhkan
class Authorizer {
fun hasRole(principal: Principal?, peran: String): Boolean {
return principal?.peran?.contains(peran) == true
}
fun hasAnyRole(principal: Principal?, peranDibutuhkan: Set<String>): Boolean {
val current = principal?.peran ?: emptySet()
return peranDibutuhkan.any { it in current }
}
fun requireRole(principal: Principal?, peran: String, block: () -> String): String {
if (!hasRole(principal, peran)) {
return """{"code":"FORBIDDEN","message":"Peran $peran dibutuhkan"}"""
}
return block()
}
fun requireAnyRole(principal: Principal?, peran: Set<String>, block: () -> String): Pair<Int, String> {
if (!hasAnyRole(principal, peran)) {
return 403 to """{"code":"FORBIDDEN","message":"Peran $peran dibutuhkan"}"""
}
return 200 to block()
}
}
// Route guard: route dilindungi peran tertentu
class Route(val path: String, val peran: Set<String>, val handler: (Principal) -> String)
class GuardedRouter(val authorizer: Authorizer) {
private val routes = mutableListOf<Route>()
fun get(path: String, peran: Set<String>, handler: (Principal) -> String) {
routes.add(Route(path, peran, handler))
}
fun handle(path: String, principal: Principal?): Pair<Int, String> {
val route = routes.firstOrNull { it.path == path }
?: return 404 to """{"code":"NOT_FOUND","message":"Path $path tidak ada"}"""
return authorizer.requireAnyRole(principal, route.peran) { route.handler(principal!!) }
}
}
fun main() {
val authorizer = Authorizer()
val router = GuardedRouter(authorizer)
router.get("/admin/users", setOf("admin")) { """{"users":["Budi","Siti"]}""" }
router.get("/admin/dashboard", setOf("admin")) { """{"stats":{"total":100}}""" }
router.get("/posts", setOf("admin", "editor", "user")) { """{"posts":["post1","post2"]}""" }
router.get("/me", setOf("admin", "editor", "user")) { p ->
when (p) {
is UserPrincipal -> """{"id":${p.id},"nama":"${p.nama}"}"""
else -> """{"error":"unknown principal"}"""
}
}
val admin = UserPrincipal(1, "Budi", setOf("admin"))
val editor = UserPrincipal(2, "Siti", setOf("editor"))
val tamu = UserPrincipal(3, "Andi", setOf("tamu"))
val anonim: Principal? = null
println("=== Admin mengakses /admin/users ===")
val r1 = router.handle("/admin/users", admin)
println("Status: ${r1.first}, Body: ${r1.second}")
println("\n=== Editor mengakses /admin/users (forbidden) ===")
val r2 = router.handle("/admin/users", editor)
println("Status: ${r2.first}, Body: ${r2.second}")
println("\n=== Tamu mengakses /me (forbidden) ===")
val r3 = router.handle("/me", tamu)
println("Status: ${r3.first}, Body: ${r3.second}")
println("\n=== Anonim mengakses /posts (forbidden) ===")
val r4 = router.handle("/posts", anonim)
println("Status: ${r4.first}, Body: ${r4.second}")
println("\n=== Editor mengakses /me (ok) ===")
val r5 = router.handle("/me", editor)
println("Status: ${r5.first}, Body: ${r5.second}")
println("\n=== Path tidak ada ===")
val r6 = router.handle("/missing", admin)
println("Status: ${r6.first}, Body: ${r6.second}")
}Output yang Dihasilkan:
=== Admin mengakses /admin/users ===
Status: 200, Body: {"users":["Budi","Siti"]}
=== Editor mengakses /admin/users (forbidden) ===
Status: 403, Body: {"code":"FORBIDDEN","message":"Peran [admin] dibutuhkan"}
=== Tamu mengakses /me (forbidden) ===
Status: 403, Body: {"code":"FORBIDDEN","message":"Peran [admin, editor, user] dibutuhkan"}
=== Anonim mengakses /posts (forbidden) ===
Status: 403, Body: {"code":"FORBIDDEN","message":"Peran [admin, editor, user] dibutuhkan"}
=== Editor mengakses /me (ok) ===
Status: 200, Body: {"id":2,"nama":"Siti"}
=== Path tidak ada ===
Status: 404, Body: {"code":"NOT_FOUND","message":"Path /missing tidak ada"}Mari kita bedah bagian penting kode di atas:
interface Principal { val peran: Set<String> }: kontrak principal yang membawa peran. Setiap implementasi (UserPrincipal) harus menyatakan perannya — compiler menjamin field tersebut ada.fun hasAnyRole(principal, peranDibutuhkan): Boolean: memeriksa irisan.peranDibutuhkan.any { it in current }mengembalikan true bila minimal satu peran cocok.fun requireAnyRole(principal, peran, block): Pair<Int, String>: menggabungkan check dan eksekusi handler. Bila tidak diizinkan, kembalikan 403. Bila diizinkan, jalankanblockdan kembalikan 200.router.get("/admin/users", setOf("admin")) { ... }: pendaftaran route dengan metadata peran. Lambda menerimaPrincipalnon-null karena guard sudah memverifikasi.route.handler(principal!!): assertion!!aman di sini karenarequireAnyRolehanya menjalankan lambda bila principal non-null. Pola ini menggabungkan keamanan compile-time dengan runtime check.
Attribute-Based Access Control (ABAC) #
Untuk aturan yang lebih kompleks (mis. “user hanya bisa edit post yang ia buat”), RBAC tidak cukup. ABAC memeriksa atribut principal dan resource — mis. principal.id == post.authorId. ABAC lebih ekspresif tetapi lebih sulit diaudit. Banyak sistem memakai kombinasi RBAC untuk gate kasar dan ABAC untuk aturan halus.
Referensi Lanjutan #
- Ktor authentication
— panduan resmi plugin
Authenticationsebagai dasar penegakan kebijakan otorisasi di Ktor
Selanjutnya #
Setelah keamanan dasar terbangun, langkah berikutnya adalah persistensi data. Chapter Ktor Database Integration akan membahas integrasi database dengan connection pool (HikariCP) dan pola repository untuk akses data yang terstruktur.