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 #

PeranCakupan akses
adminSemua operasi, termasuk manajemen user
editorCRUD konten, tidak kelola user
userBaca 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.

kotlin Route dengan guard
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:

kotlin GuardedRouter DSL
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.

kotlin
// 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:

text
=== 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:

  1. interface Principal { val peran: Set<String> }: kontrak principal yang membawa peran. Setiap implementasi (UserPrincipal) harus menyatakan perannya — compiler menjamin field tersebut ada.
  2. fun hasAnyRole(principal, peranDibutuhkan): Boolean: memeriksa irisan. peranDibutuhkan.any { it in current } mengembalikan true bila minimal satu peran cocok.
  3. fun requireAnyRole(principal, peran, block): Pair<Int, String>: menggabungkan check dan eksekusi handler. Bila tidak diizinkan, kembalikan 403. Bila diizinkan, jalankan block dan kembalikan 200.
  4. router.get("/admin/users", setOf("admin")) { ... }: pendaftaran route dengan metadata peran. Lambda menerima Principal non-null karena guard sudah memverifikasi.
  5. route.handler(principal!!): assertion !! aman di sini karena requireAnyRole hanya 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 Authentication sebagai 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.