REST API di Bagian 2 menyimpan data di memori yang hilang saat server di-restart. Pada Bagian 3 kita memperbaikinya dengan dua peningkatan besar: database persisten via SQLite JDBC dengan foreign key antar pengguna dan tugas, serta authentication JWT untuk melindungi endpoint agar hanya pengguna yang login dapat mengakses tugasnya sendiri. Dua konsep ini adalah fondasi keamanan setiap API produksi.

Skema Database Relasional #

Dua tabel yang saling terkait: pengguna menyimpan akun, tugas menyimpan tugas dengan foreign key ke pengguna.id. Setiap tugas dimiliki oleh satu pengguna; pengguna dapat memiliki banyak tugas (relasi one-to-many):

text Skema relasional
pengguna (id, username, password_hash)
            |
            | 1
            |
            | N
tugas (id, judul, selesai, pemilik_id)

Foreign key pemilik_id memastikan setiap tugas selalu merujuk ke pengguna yang valid. Index pada pemilik_id mempercepat query “tampilkan semua tugas milik pengguna X”.

JWT (HS256) Workflow #

JSON Web Token (JWT) adalah format token yang ditandatangani secara kriptografis sehingga server tidak perlu menyimpan session. Alurnya:

  1. Pengguna mengirim username + password ke POST /api/auth/login.
  2. Server memverifikasi password, mengeluarkan JWT yang berisi klaim (sub, uid, exp).
  3. JWT ditandatangani dengan secret server menggunakan HMAC-SHA256.
  4. Pengguna menyertakan JWT di header Authorization: Bearer <token> untuk setiap request berikutnya.
  5. Server memverifikasi signature JWT setiap request; jika valid, baca klaim untuk identifikasi pengguna.

Karena signature terenkripsi, pengguna tidak dapat memalsukan klaim tanpa secret server. Tampering dengan payload menghasilkan signature yang tidak cocok.

Implementasi Database dan JWT #

Kode berikut menunjukkan integrasi database SQLite dengan foreign key, password hashing, dan JWT sign/verify menggunakan javax.crypto.Mac:

kotlin
// Bagian 3: Database dan authentication.
// Mendemonstrasikan integrasi database SQLite via JDBC dengan pool
// sederhana, serta JWT (HS256) sign dan verify dengan javax.crypto.Mac.

import java.sql.Connection
import java.sql.DriverManager
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import java.util.Base64

data class Pengguna(val id: Long, val username: String, val passwordHash: String)
data class Tugas(val id: Long, val judul: String, val selesai: Boolean, val pemilikId: Long)

// === Database layer (mirror dari Exposed + HikariCP) ===
class Database(private val conn: Connection) {
    init {
        conn.createStatement().use { stmt ->
            stmt.executeUpdate("""
                CREATE TABLE IF NOT EXISTS pengguna (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    username TEXT UNIQUE NOT NULL,
                    password_hash TEXT NOT NULL
                )
            """.trimIndent())
            stmt.executeUpdate("""
                CREATE TABLE IF NOT EXISTS tugas (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    judul TEXT NOT NULL,
                    selesai INTEGER NOT NULL DEFAULT 0,
                    pemilik_id INTEGER NOT NULL,
                    FOREIGN KEY (pemilik_id) REFERENCES pengguna(id)
                )
            """.trimIndent())
            stmt.executeUpdate("CREATE INDEX IF NOT EXISTS idx_tugas_pemilik ON tugas(pemilik_id)")
        }
    }

    fun daftarkanPengguna(username: String, passwordHash: String): Long {
        conn.prepareStatement(
            "INSERT INTO pengguna (username, password_hash) VALUES (?, ?)",
            java.sql.Statement.RETURN_GENERATED_KEYS
        ).use { ps ->
            ps.setString(1, username)
            ps.setString(2, passwordHash)
            ps.executeUpdate()
            ps.generatedKeys.use { rs -> if (rs.next()) return rs.getLong(1) }
        }
        return -1
    }

    fun cariPengguna(username: String): Pengguna? {
        conn.prepareStatement("SELECT id, username, password_hash FROM pengguna WHERE username = ?").use { ps ->
            ps.setString(1, username)
            ps.executeQuery().use { rs ->
                if (rs.next()) return Pengguna(
                    rs.getLong("id"), rs.getString("username"), rs.getString("password_hash")
                )
            }
        }
        return null
    }

    fun tambahTugas(judul: String, pemilikId: Long): Long {
        conn.prepareStatement(
            "INSERT INTO tugas (judul, selesai, pemilik_id) VALUES (?, 0, ?)",
            java.sql.Statement.RETURN_GENERATED_KEYS
        ).use { ps ->
            ps.setString(1, judul)
            ps.setLong(2, pemilikId)
            ps.executeUpdate()
            ps.generatedKeys.use { rs -> if (rs.next()) return rs.getLong(1) }
        }
        return -1
    }

    fun tugasByPemilik(pemilikId: Long): List<Tugas> {
        val hasil = mutableListOf<Tugas>()
        conn.prepareStatement(
            "SELECT id, judul, selesai, pemilik_id FROM tugas WHERE pemilik_id = ? ORDER BY id"
        ).use { ps ->
            ps.setLong(1, pemilikId)
            ps.executeQuery().use { rs ->
                while (rs.next()) {
                    hasil.add(Tugas(
                        rs.getLong("id"), rs.getString("judul"),
                        rs.getInt("selesai") == 1, rs.getLong("pemilik_id")
                    ))
                }
            }
        }
        return hasil
    }
}

// === JWT (HS256) ===
object JwtService {
    // Di produksi: simpan di environment variable, jangan hardcode!
    private val SECRET = "kotlin-tutorial-secret-key-yang-panjang-dan-aman"
    private const val HEADER = """{"alg":"HS256","typ":"JWT"}"""
    private val encoder = Base64.getUrlEncoder().withoutPadding()
    private val decoder = Base64.getUrlDecoder()

    fun sign(payload: String): String {
        val headerB64 = encoder.encodeToString(HEADER.toByteArray())
        val payloadB64 = encoder.encodeToString(payload.toByteArray())
        val data = "$headerB64.$payloadB64"
        val sig = hmacSha256(data)
        return "$data.$sig"
    }

    fun verify(token: String): String? {
        val parts = token.split(".")
        if (parts.size != 3) return null
        val (headerB64, payloadB64, sig) = parts
        val expectedSig = hmacSha256("$headerB64.$payloadB64")
        if (sig != expectedSig) return null
        return try {
            String(decoder.decode(payloadB64))
        } catch (e: Exception) { null }
    }

    private fun hmacSha256(data: String): String {
        val mac = Mac.getInstance("HmacSHA256")
        mac.init(SecretKeySpec(SECRET.toByteArray(), "HmacSHA256"))
        return encoder.encodeToString(mac.doFinal(data.toByteArray()))
    }
}

object PasswordHasher {
    fun hash(password: String): String {
        // Demo sederhana: SHA-256. Di produksi: bcrypt atau argon2.
        val md = java.security.MessageDigest.getInstance("SHA-256")
        return md.digest(password.toByteArray())
            .joinToString("") { "%02x".format(it) }
    }
    fun verify(password: String, hash: String): Boolean =
        hash(password) == hash
}

fun main() {
    val db = Database(DriverManager.getConnection("jdbc:sqlite::memory:"))

    println("=== Setup pengguna ===")
    db.daftarkanPengguna("budi", PasswordHasher.hash("rahasia123"))
    db.daftarkanPengguna("siti", PasswordHasher.hash("aman456"))
    println("Pengguna terdaftar: budi, siti")

    println()
    println("=== Login budi (password benar) ===")
    val pengguna = db.cariPengguna("budi")
    if (pengguna != null && PasswordHasher.verify("rahasia123", pengguna.passwordHash)) {
        val payload = """{"sub":"budi","uid":${pengguna.id},"exp":1735689600}"""
        val token = JwtService.sign(payload)
        println("  Token: ${token.take(60)}...")
        println("  Verifikasi: ${JwtService.verify(token) != null}")
    }

    println()
    println("=== Login budi (password salah) ===")
    val ok = PasswordHasher.verify("salah", pengguna?.passwordHash ?: "")
    println("  Hasil verifikasi: $ok (harus false)")

    println()
    println("=== Buat tugas untuk budi ===")
    db.tambahTugas("Belajar Ktor", pengguna!!.id)
    db.tambahTugas("Bangun REST API", pengguna.id)
    println("Tugas budi:")
    db.tugasByPemilik(pengguna.id).forEach { t ->
        println("  [${t.id}] ${t.judul} selesai=${t.selesai}")
    }

    println()
    println("=== Buat tugas untuk siti ===")
    val siti = db.cariPengguna("siti")!!
    db.tambahTugas("Belajar Multiplatform", siti.id)
    println("Tugas siti:")
    db.tugasByPemilik(siti.id).forEach { t ->
        println("  [${t.id}] ${t.judul} selesai=${t.selesai}")
    }

    println()
    println("=== Tamper token (token dimodifikasi) ===")
    val original = JwtService.sign("""{"sub":"budi","uid":1}""")
    val forged = original.dropLast(5) + "AAAAA"
    println("  Token asli valid: ${JwtService.verify(original) != null}")
    println("  Token forged valid: ${JwtService.verify(forged) != null}")
}

Output yang Dihasilkan:

text
=== Setup pengguna ===
Pengguna terdaftar: budi, siti

=== Login budi (password benar) ===
  Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJidWRpIiwidWl...
  Verifikasi: true

=== Login budi (password salah) ===
  Hasil verifikasi: false (harus false)

=== Buat tugas untuk budi ===
Tugas budi:
  [1] Belajar Ktor selesai=false
  [2] Bangun REST API selesai=false

=== Buat tugas untuk siti ===
Tugas siti:
  [3] Belajar Multiplatform selesai=false

=== Tamper token (token dimodifikasi) ===
  Token asli valid: true
  Token forged valid: false

Mari kita bedah komponen utama:

  1. FOREIGN KEY (pemilik_id) REFERENCES pengguna(id): constraint foreign key yang memastikan setiap tugas selalu merujuk ke pengguna yang valid. Mencoba insert pemilik_id = 999 saat tidak ada pengguna dengan ID tersebut akan gagal. Untuk SQLite, aktifkan PRAGMA foreign_keys = ON agar constraint dijalankan (default off).
  2. Mac.getInstance("HmacSHA256") + SecretKeySpec: implementasi HMAC-SHA256 dengan Java Crypto API. HMAC menggabungkan secret dengan data menggunakan fungsi hash kriptografis; output tidak dapat dipalsukan tanpa secret.
  3. encoder.encodeToString(...) Base64 URL-safe: JWT menggunakan Base64 URL-safe (karakter - dan _ bukan + dan /) agar aman di header HTTP. withoutPadding() menghilangkan = di akhir.
  4. verify(token): String?: mengembalikan payload sebagai String jika signature cocok, atau null jika tidak cocok. Pemanggil memeriksa null untuk menentukan apakah token valid.
  5. val forged = original.dropLast(5) + "AAAAA": simulasi tampering. Signature berubah; verify mendeteksi mismatch dan mengembalikan null. Inilah inti keamanan JWT — modifikasi satu byte pun membuat signature tidak valid.

Password Hashing: SHA-256 vs bcrypt #

Demo di atas memakai SHA-256 sederhana untuk password hashing. SHA-256 adalah hash kriptografis, tetapi tidak aman untuk password karena terlalu cepat. Attacker dengan GPU dapat menebak miliaran password per detik dengan SHA-256.

Untuk produksi, gunakan bcrypt atau argon2:

  • Slow by design: bcrypt sengaja lambat (~100ms per hash), membuat brute-force tidak praktis.
  • Salt otomatis: bcrypt menanam salt di output, sehingga dua pengguna dengan password yang sama menghasilkan hash berbeda.
  • Cost factor: parameter yang dapat dinaikkan seiring hardware menjadi lebih cepat, menjaga keamanan jangka panjang.

Di JVM, library bcrypt atau spring-security-crypto menyediakan implementasi yang siap pakai.

Authorization: Token vs Session #

JWT berbeda dari session tradisional dalam beberapa aspek:

  • Stateless: server tidak menyimpan session; semua informasi ada di token. Cocok untuk horizontal scaling.
  • Expired via exp claim: token memiliki masa berlaku yang tertera di payload. Server memeriksa exp setiap request.
  • Tidak dapat di-revoke tanpa extra work: JWT valid sampai expired. Untuk revoke, simpan blacklist token di server (kehilangan sebagian sifat stateless).

Referensi Lanjutan #

Ringkasan Bagian 3 #

Database relasional dan authentication JWT kini terhubung. Database menangani CRUD dengan foreign key dan index. JwtService menandatangani dan memverifikasi token HS256. PasswordHasher memvalidasi password (dengan catatan demo; produksi pakai bcrypt). Pada Bagian 4 kita menulis pengujian dengan Ktor Test Engine dan menyiapkan deployment Docker.