Endpoint kita sejauh ini terbuka untuk siapa saja. Pada Bagian 4 kita melindunginya dengan authentication JWT: klien harus mengirim token yang valid untuk mengakses /tasks. Kita juga menulis test suite yang memvalidasi perilaku API termasuk kasus sukses, galat validasi, dan token tidak valid.

Konsep JWT #

JSON Web Token (JWT) adalah format token yang ditandatangani secara kriptografis. Token terdiri dari tiga bagian yang dipisahkan titik: header, payload, dan signature. Server memverifikasi signature untuk memastikan token tidak dimodifikasi. JWT bersifat stateless: server tidak perlu menyimpan sesi; verifikasi hanya memerlukan secret key.

Tiga manfaat JWT untuk REST API:

  • Stateless: tidak butuh server-side session store.
  • Portable: token dapat divalidasi oleh multiple service.
  • Ekspirable: claim exp mengatur masa berlaku.

Implementasi JWT Ringkas #

Kode berikut menunjukkan JWT dengan HMAC-SHA256 tanpa library eksternal. Untuk produksi, gunakan library seperti jsonwebtoken atau Ktor auth-jwt:

kotlin
// Bagian 4: Authentication (JWT) dan testing.
// Mendemonstrasikan pembuatan dan verifikasi token JWT sederhana (HMAC),
// serta struktur test untuk endpoint yang dilindungi.

import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import java.util.Base64

object JwtRingkas {
    private const val ALGO = "HmacSHA256"
    private const val SECRET = "demo-secret-key-tidak-untuk-produksi"

    fun buat(userId: String, payload: Map<String, String>): String {
        val header = """{"alg":"HS256","typ":"JWT"}"""
        val body = buildString {
            append("{")
            append("\"sub\":\"$userId\"")
            payload.forEach { (k, v) -> append(",\"$k\":\"$v\"") }
            append("}")
        }
        val headerB64 = base64Url(header.toByteArray())
        val bodyB64 = base64Url(body.toByteArray())
        val signingInput = "$headerB64.$bodyB64"
        val signature = hmac(signingInput.toByteArray())
        return "$signingInput.${base64Url(signature)}"
    }

    fun verifikasi(token: String): Result<Map<String, String>> {
        val parts = token.split(".")
        if (parts.size != 3) return Result.failure(IllegalArgumentException("Format token tidak valid"))
        val (headerB64, bodyB64, sigB64) = parts
        val signingInput = "$headerB64.$bodyB64"
        val expectedSig = base64Url(hmac(signingInput.toByteArray()))
        if (sigB64 != expectedSig) return Result.failure(IllegalArgumentException("Signature tidak cocok"))
        val bodyJson = String(Base64.getUrlDecoder().decode(bodyB64))
        return Result.success(parseKlaim(bodyJson))
    }

    private fun hmac(data: ByteArray): ByteArray {
        val mac = Mac.getInstance(ALGO)
        mac.init(SecretKeySpec(SECRET.toByteArray(), ALGO))
        return mac.doFinal(data)
    }

    private fun base64Url(bytes: ByteArray): String =
        Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)

    private fun parseKlaim(json: String): Map<String, String> {
        val isi = json.trim().removePrefix("{").removeSuffix("}")
        val result = mutableMapOf<String, String>()
        val re = Regex(""""([^"]+)":"([^"]*)"""")
        re.findAll(isi).forEach { m ->
            val (k, v) = m.destructured
            result[k] = v
        }
        return result.toMap()
    }
}

object Auth {
    private val pengguna = mapOf(
        "budi" to "rahasia123",
        "siti" to "aman123"
    )

    fun login(nama: String, sandi: String): Result<String> {
        val expected = pengguna[nama]
        if (expected == null || expected != sandi) {
            return Result.failure(IllegalArgumentException("Kredensial tidak valid"))
        }
        val token = JwtRingkas.buat(nama, mapOf("role" to "user"))
        return Result.success(token)
    }

    fun otorisasi(token: String): Result<String> {
        val klaim = JwtRingkas.verifikasi(token).getOrElse {
            return Result.failure(IllegalArgumentException("Token tidak valid"))
        }
        return Result.success(klaim["sub"] ?: "unknown")
    }
}

object Test {
    private var lulus = 0
    private var gagal = 0
    fun nama(nama: String, blok: () -> Unit) {
        try { blok(); println("PASS  $nama"); lulus++ }
        catch (e: Throwable) { println("FAIL  $nama: ${e.message}"); gagal++ }
    }
    fun assertEquals(a: Any?, b: Any?) { if (a != b) throw AssertionError("diharapkan <$a> aktual <$b>") }
    fun assertTrue(v: Boolean) { if (!v) throw AssertionError("diharapkan true") }
    fun assertFalse(v: Boolean) { if (v) throw AssertionError("diharapkan false") }
    fun ringkasan() { println(); println("Total: ${lulus + gagal} | Lulus: $lulus | Gagal: $gagal") }
}

fun main() {
    println("=== Login ===")
    val tokenBerhasil = Auth.login("budi", "rahasia123")
    println("Login budi: ${tokenBerhasil.fold(onSuccess = { "token: ${it.take(40)}..." }, onFailure = { it.message })}")
    val tokenGagal = Auth.login("budi", "salah")
    println("Login salah: ${tokenGagal.fold(onSuccess = { "berhasil?!" }, onFailure = { it.message })}")

    println()
    println("=== Verifikasi token ===")
    val token = tokenBerhasil.getOrThrow()
    val klaim = JwtRingkas.verifikasi(token).getOrThrow()
    println("Klaim: $klaim")

    println()
    println("=== Token dirusak ===")
    val rusak = token.dropLast(5) + "XXXXX"
    val hasilRusak = JwtRingkas.verifikasi(rusak)
    println("Verifikasi rusak: ${hasilRusak.fold(onSuccess = { "berhasil?!" }, onFailure = { it.message })}")

    println()
    println("=== Test suite ===")
    with(Test) {
        nama("login berhasil kembalikan token") {
            val t = Auth.login("siti", "aman123")
            assertTrue(t.isSuccess)
        }
        nama("login gagal kembalikan galat") {
            val t = Auth.login("siti", "salah")
            assertTrue(t.isFailure)
        }
        nama("otorisasi token valid kembalikan sub") {
            val t = Auth.login("budi", "rahasia123").getOrThrow()
            val sub = Auth.otorisasi(t).getOrThrow()
            assertEquals("budi", sub)
        }
        nama("otorisasi token rusak gagal") {
            val r = Auth.otorisasi("abc.def.ghi")
            assertTrue(r.isFailure)
        }
        nama("token dengan payload kustom") {
            val t = JwtRingkas.buat("user123", mapOf("role" to "admin", "tim" to "backend"))
            val k = JwtRingkas.verifikasi(t).getOrThrow()
            assertEquals("admin", k["role"])
            assertEquals("backend", k["tim"])
        }
        ringkasan()
    }
}

Output yang Dihasilkan:

text
=== Login ===
Login budi: token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ...
Login salah: Kredensial tidak valid

=== Verifikasi token ===
Klaim: {sub=budi, role=user}

=== Token dirusak ===
Verifikasi rusak: Signature tidak cocok

=== Test suite ===
PASS  login berhasil kembalikan token
PASS  login gagal kembalikan galat
PASS  otorisasi token valid kembalikan sub
PASS  otorisasi token rusak gagal
PASS  token dengan payload kustom

Total: 5 | Lulus: 5 | Gagal: 0

Mari kita bedah komponen JWT:

  1. Mac.getInstance("HmacSHA256"): mendapatkan instance HMAC dengan algoritma SHA-256. Mac adalah class Java standard library untuk komputasi HMAC.
  2. SecretKeySpec(SECRET.toByteArray(), ALGO): membuat kunci rahasia dari string. Secret harus tetap konfidensial; jika bocor, siapa pun dapat memalsukan token.
  3. Base64.getUrlEncoder().withoutPadding(): Base64URL adalah varian Base64 yang aman untuk URL (+ menjadi -, / menjadi _, dan trailing = dihilangkan). Format standar JWT.
  4. token.split("."): token JWT selalu memiliki tiga bagian. Jika bukan tiga, format tidak valid.
  5. sigB64 != expectedSig: perbandingan signature. Jika tidak cocok, token telah dimodifikasi dan harus ditolak.
  6. Regex(""""([^"]+)":"([^"]*)""""): parsing JSON sederhana untuk mengekstrak klaim. Untuk produksi, gunakan kotlinx.serialization yang menangani JSON dengan benar.

Alur Authentication #

Alur authentication terdiri dari dua langkah:

  1. Login: klien mengirim username dan password ke /auth/login. Server memverifikasi kredensial dan mengembalikan JWT.
  2. Akses endpoint terlindungi: klien menyertakan JWT di header Authorization: Bearer <token>. Server memverifikasi signature dan ekstrak identitas pengguna.

Pola ini memisahkan login dari akses, sehingga token dapat dipakai berkali-kali tanpa mengirim password.

Test Suite #

Test suite mencakup tiga kategori:

  • Login sukses/gagal: kredensial valid mengembalikan token; kredensial salah mengembalikan galat.
  • Otorisasi token valid/rusak: token yang ditandatangani server valid; token yang dimodifikasi ditolak.
  • Payload kustom: klaim tambahan seperti role dan tim dapat ditambahkan dan dibaca kembali.

Test yang baik tidak bergantung pada urutan eksekusi. Setiap @Test harus dapat berjalan terpisah. Pada kode di atas, state pengguna tidak berubah, sehingga test tidak saling memengaruhi.

Referensi Lanjutan #

  • Ktor authentication — panduan Authentication untuk melindungi endpoint dengan JWT atau sesi pengguna
  • Ktor testing — panduan testApplication untuk menguji route tanpa menjalankan server nyata

Ringkasan Bagian 4 dan Penutup Proyek #

Authentication JWT dan test suite kini lengkap. JwtRingkas membuat dan memverifikasi token dengan HMAC-SHA256; Auth mengelola login dan otorisasi; Test harness memvalidasi lima skenario kunci.

Proyek REST API Manajemen Tugas telah selesai. Kita mendesain endpoint di Bagian 1, mengimplementasikan routing di Bagian 2, menambahkan database SQLite di Bagian 3, dan melindungi endpoint dengan JWT serta menulis test di Bagian 4. API kini siap melayani klien dengan keamanan dan ketertiban kontrak. Selanjutnya, kita akan beralih ke proyek Aplikasi Inventaris yang fokus pada transaksi barang masuk dan keluar dengan database yang lebih kompleks.