Sampai di sini, siapa pun yang mengetahui alamat server dapat membuat, mengubah, bahkan menghapus tugas. Bagian penutup memasang pintu: setiap permintaan wajib membawa header X-API-Key, kunci diverifikasi terhadap collection users, dan operasi penghapusan dibatasi untuk peran admin. Kita tetap memakai crypto bawaan Node.js — tanpa dependensi autentikasi apa pun — sambil memegang prinsip penting: database tidak pernah menyimpan kunci asli.

Rancangan API Key #

Tiga keputusan membentuk mekanisme ini:

AspekKeputusanAlasan
Pembuatan kuncicrypto.randomBytes(32) → 64 karakter heksadesimal256-bit acak, mustahil ditebak
PenyimpananPrefix 8 karakter + hash SHA-256Prefix untuk pencarian, hash untuk verifikasi; kunci asli tidak pernah disimpan
Pembandingancrypto.timingSafeEqualWaktu perbandingan konstan, tidak membocorkan isi rahasia lewat pola durasi

Saat pendaftaran, kunci ditampilkan tepat satu kali. Yang tersimpan di database hanyalah api_key_prefix (untuk menemukan user tanpa memindai seluruh collection) dan api_key_hash (untuk memastikan kunci benar). Bila database bocor, penyerang memegang hash satu arah — bukan kunci yang bisa dipakai memanggil API.

Modul Pengguna #

js
// users.js — pengguna API beserta pembuatan dan verifikasi API key
// Bisa dijalankan langsung untuk mendaftarkan pengguna baru:
//   node src/users.js [email protected] "Budi" admin

const crypto = require("node:crypto");
const { ambilDb, tutupKoneksi } = require("./db");

const PERAN = ["admin", "anggota"];

// SHA-256 hex — satu arah; kunci asli tidak pernah disimpan.
function hashKunci(kunci) {
  return crypto.createHash("sha256").update(kunci).digest("hex");
}

async function siapkanIndexUser(db) {
  await db.collection("users").createIndex({ email: 1 }, { unique: true });
}

// Mengembalikan { id, api_key } — api_key hanya muncul di sini.
async function buatUser(db, data) {
  const kunci = crypto.randomBytes(32).toString("hex");
  const user = {
    nama: data.nama,
    email: data.email,
    peran: data.peran,
    api_key_prefix: kunci.slice(0, 8),
    api_key_hash: hashKunci(kunci),
    dibuat_pada: new Date(),
  };
  const hasil = await db.collection("users").insertOne(user);
  return { id: hasil.insertedId, api_key: kunci };
}

// Mencari user lewat prefix, lalu memverifikasi dengan perbandingan
// waktu-konstan (timingSafeEqual), bukan ===.
async function verifikasiApiKey(db, kunci) {
  if (typeof kunci !== "string" || kunci.length < 8) return null;
  const user = await db.collection("users").findOne({
    api_key_prefix: kunci.slice(0, 8),
  });
  if (!user) return null;
  const cocok = crypto.timingSafeEqual(
    Buffer.from(hashKunci(kunci), "hex"),
    Buffer.from(user.api_key_hash, "hex")
  );
  return cocok ? user : null;
}

if (require.main === module) {
  const [email, nama, peran] = process.argv.slice(2);
  if (!email || !nama || !PERAN.includes(peran)) {
    console.error('pemakaian: node src/users.js <email> <nama> <admin|anggota>');
    process.exit(1);
  }
  ambilDb()
    .then(function (db) {
      return siapkanIndexUser(db).then(function () {
        return buatUser(db, { nama: nama, email: email, peran: peran });
      });
    })
    .then(function (hasil) {
      console.log("user terdaftar:", email, "| peran:", peran);
      console.log("API key (tampilkan sekali saja, simpan baik-baik):");
      console.log(hasil.api_key);
      return tutupKoneksi();
    })
    .catch(function (galat) {
      const pesan = galat.code === 11000
        ? "email sudah terdaftar"
        : galat.message;
      console.error("gagal mendaftarkan:", pesan);
      process.exit(1);
    });
}

module.exports = { PERAN, siapkanIndexUser, buatUser, verifikasiApiKey, hashKunci };

Daftarkan dua pengguna — satu admin, satu anggota biasa:

bash
node src/users.js [email protected] "Budi" admin
node src/users.js [email protected] "Siti" anggota

Output yang Dihasilkan:

text
user terdaftar: [email protected] | peran: admin
API key (tampilkan sekali saja, simpan baik-baik):
cfb1c093cbae4336ba2f4275672b9fa1782bec749aa5e8bd7db23afa54ed0d74
user terdaftar: [email protected] | peran: anggota
API key (tampilkan sekali saja, simpan baik-baik):
6ffe54b91c16fa2ae4440a470c0f81568f050df69ebce49f298d5f00dcf298ea

Kunci Anda pasti berbeda — randomBytes menghasilkan nilai baru setiap eksekusi. Coba daftarkan email yang sama sekali lagi dan unique index email menolaknya dengan E11000 yang kini diterjemahkan menjadi pesan yang jelas:

text
gagal mendaftarkan: email sudah terdaftar

Pintu Auth dan Peran di Server #

Dua perubahan pada server.js menutup proyek ini. Pertama, gerbang autentikasi di awal tangani() — sebelum routing apa pun:

js server.js — gerbang autentikasi
// mdtest: skip — potongan dari dalam fungsi tangani(), bukan skrip utuh
    // Pintu pertama: semua permintaan wajib membawa API key yang valid.
    const user = await verifikasiApiKey(db, req.headers["x-api-key"]);
    if (!user) {
      return kirimJson(res, 401, {
        error: { kode: "TIDAK_TERAUTENTIKASI", pesan: "header X-API-Key hilang atau tidak valid" },
      });
    }

Kedua, pemeriksaan peran tepat di handler DELETE:

js server.js — DELETE khusus admin
// mdtest: skip — potongan dari dalam fungsi tangani(), bukan skrip utuh
      if (req.method === "DELETE") {
        if (user.peran !== "admin") {
          return kirimJson(res, 403, {
            error: { kode: "PERAN_TIDAK_CUKUP", pesan: "hanya admin yang boleh menghapus tugas" },
          });
        }
        const terhapus = await tugas.hapusTugas(db, bagian[1]);

Berikut server.js final utuh — satu-satunya berkas yang perlu diunduh ulang karena bertambah dua lapisan:

js
// server.js — REST API manajemen tugas (node:http, tanpa framework)
// Routing + CRUD + validasi + autentikasi API key.
// Variabel lingkungan: PORT (default 3000), MONGODB_URI, DB_NAME.

const http = require("node:http");
const { ambilDb } = require("./db");
const tugas = require("./tugas");
const { validasiTugas, validasiUbah, idValid } = require("./validasi");
const { siapkanIndexUser, verifikasiApiKey } = require("./users");

const PORT = Number(process.env.PORT || 3000);

// Galat yang membawa kode status HTTP sendiri.
class GalatApi extends Error {
  constructor(kodeStatus, kode, pesan) {
    super(pesan);
    this.kodeStatus = kodeStatus;
    this.kode = kode;
  }
}

function kirimJson(res, kodeStatus, payload) {
  const isi = JSON.stringify(payload, null, 2);
  res.writeHead(kodeStatus, {
    "Content-Type": "application/json; charset=utf-8",
  });
  res.end(isi);
}

function bacaBody(req) {
  return new Promise(function (selesai, gagal) {
    let isi = "";
    req.on("data", function (potongan) {
      isi += potongan;
      if (isi.length > 64 * 1024) {
        gagal(new GalatApi(400, "PAYLOAD_BESAR", "body melebihi 64 KB"));
        req.destroy();
      }
    });
    req.on("end", function () { selesai(isi); });
    req.on("error", gagal);
  });
}

// Menerjemahkan galat JavaScript/driver menjadi respons HTTP.
function petakanGalat(galat) {
  if (galat instanceof GalatApi) {
    return { status: galat.kodeStatus, isi: { kode: galat.kode, pesan: galat.message } };
  }
  if (galat instanceof SyntaxError) {
    return { status: 400, isi: { kode: "JSON_TIDAK_VALID", pesan: "body bukan JSON yang valid" } };
  }
  if (galat.code === 11000) {
    const field = Object.keys(galat.keyValue || {})[0] || "field";
    return {
      status: 409,
      isi: { kode: "DUPLIKAT", pesan: field + " sudah dipakai tugas lain" },
    };
  }
  return {
    status: 500,
    isi: { kode: "KESALAHAN_SERVER", pesan: "terjadi kesalahan tak terduga" },
  };
}

async function tangani(req, res) {
  const url = new URL(req.url, "http://localhost");
  const bagian = url.pathname.split("/").filter(Boolean);

  try {
    const db = await ambilDb();

    // Pintu pertama: semua permintaan wajib membawa API key yang valid.
    const user = await verifikasiApiKey(db, req.headers["x-api-key"]);
    if (!user) {
      return kirimJson(res, 401, {
        error: { kode: "TIDAK_TERAUTENTIKASI", pesan: "header X-API-Key hilang atau tidak valid" },
      });
    }

    if (req.method === "GET" && bagian[0] === "tugas" && bagian.length === 1) {
      const hasil = await tugas.daftarTugas(db, {
        status: url.searchParams.get("status"),
        halaman: url.searchParams.get("halaman"),
        batas: url.searchParams.get("batas"),
      });
      return kirimJson(res, 200, hasil);
    }

    if (bagian[0] === "tugas" && bagian.length === 2) {
      if (!idValid(bagian[1])) {
        return kirimJson(res, 400, {
          error: { kode: "ID_TIDAK_VALID", pesan: "id harus 24 karakter heksadesimal" },
        });
      }
      if (req.method === "GET") {
        const satu = await tugas.cariTugas(db, bagian[1]);
        if (!satu) {
          return kirimJson(res, 404, {
            error: { kode: "TUGAS_TIDAK_ADA", pesan: "tugas tidak ditemukan" },
          });
        }
        return kirimJson(res, 200, satu);
      }
      if (req.method === "PUT") {
        const data = JSON.parse(await bacaBody(req));
        const hasil = validasiUbah(data);
        if (!hasil.valid) {
          return kirimJson(res, 400, {
            error: { kode: "VALIDASI", pesan: "payload tidak valid", detail: hasil.galat },
          });
        }
        const ubah = await tugas.ubahTugas(db, bagian[1], hasil.nilai);
        if (!ubah) {
          return kirimJson(res, 404, {
            error: { kode: "TUGAS_TIDAK_ADA", pesan: "tugas tidak ditemukan" },
          });
        }
        return kirimJson(res, 200, ubah);
      }
      if (req.method === "DELETE") {
        if (user.peran !== "admin") {
          return kirimJson(res, 403, {
            error: { kode: "PERAN_TIDAK_CUKUP", pesan: "hanya admin yang boleh menghapus tugas" },
          });
        }
        const terhapus = await tugas.hapusTugas(db, bagian[1]);
        if (!terhapus) {
          return kirimJson(res, 404, {
            error: { kode: "TUGAS_TIDAK_ADA", pesan: "tugas tidak ditemukan" },
          });
        }
        return kirimJson(res, 200, { terhapus: true });
      }
    }

    if (req.method === "POST" && bagian[0] === "tugas" && bagian.length === 1) {
      const data = JSON.parse(await bacaBody(req));
      const hasil = validasiTugas(data);
      if (!hasil.valid) {
        return kirimJson(res, 400, {
          error: { kode: "VALIDASI", pesan: "payload tidak valid", detail: hasil.galat },
        });
      }
      const baru = await tugas.buatTugas(db, hasil.nilai);
      return kirimJson(res, 201, baru);
    }

    return kirimJson(res, 404, {
      error: { kode: "ROUTE_TIDAK_ADA", pesan: "endpoint tidak dikenal" },
    });
  } catch (galat) {
    const petakan = petakanGalat(galat);
    if (petakan.status === 500) console.error(galat);
    return kirimJson(res, petakan.status, { error: petakan.isi });
  }
}

function buatServer() {
  return http.createServer(tangani);
}

if (require.main === module) {
  buatServer().listen(PORT, "127.0.0.1", async function () {
    try {
      const db = await ambilDb();
      await tugas.siapkanIndex(db);
      await siapkanIndexUser(db);
      console.log("API manajemen tugas berjalan di http://127.0.0.1:" + PORT);
    } catch (galat) {
      console.error("gagal mulai:", galat.message);
      process.exit(1);
    }
  });
}

module.exports = { buatServer, tangani };

Perbedaan Respons 401 dan 403 #

Nyalakan ulang server lalu uji ketiganya — tanpa kunci, kunci salah, dan kunci benar:

bash
KUNCI_BUDI="cfb1c093cbae4336ba2f4275672b9fa1782bec749aa5e8bd7db23afa54ed0d74"
KUNCI_SITI="6ffe54b91c16fa2ae4440a470c0f81568f050df69ebce49f298d5f00dcf298ea"

curl -s http://127.0.0.1:3000/tugas
curl -s http://127.0.0.1:3000/tugas -H "X-API-Key: kunci-palsu-1234567890abcdef"

Output yang Dihasilkan:

text
{
  "error": {
    "kode": "TIDAK_TERAUTENTIKASI",
    "pesan": "header X-API-Key hilang atau tidak valid"
  }
}
{
  "error": {
    "kode": "TIDAK_TERAUTENTIKASI",
    "pesan": "header X-API-Key hilang atau tidak valid"
  }
}

Kedua respons sengaja identik — klien tidak diberi tahu apakah kuncinya hilang atau salah, agar penyerang tidak memperoleh petunjuk. Lanjut ke pemeriksaan peran: Siti bisa membaca dan membuat tugas, tetapi tidak menghapus:

bash
curl -s -X POST http://127.0.0.1:3000/tugas -H "X-API-Key: $KUNCI_SITI" \
  -H "Content-Type: application/json" \
  -d '{"kode":"TG-001","judul":"Menulis dokumen API","deskripsi":"Rancang endpoint awal"}'

curl -s -X DELETE http://127.0.0.1:3000/tugas/6ad40cd8356fec72800842b6 \
  -H "X-API-Key: $KUNCI_SITI"

curl -s -X DELETE http://127.0.0.1:3000/tugas/6ad40cd8356fec72800842b6 \
  -H "X-API-Key: $KUNCI_BUDI"

Output yang Dihasilkan:

text
{
  "_id": "6ad40cd8356fec72800842b6",
  "kode": "TG-001",
  "judul": "Menulis dokumen API",
  "deskripsi": "Rancang endpoint awal",
  "status": "todo",
  "dibuat_pada": "2026-10-18T00:03:36.452Z",
  "diubah_pada": "2026-10-18T00:03:36.452Z"
}
{
  "error": {
    "kode": "PERAN_TIDAK_CUKUP",
    "pesan": "hanya admin yang boleh menghapus tugas"
  }
}
{
  "terhapus": true
}

Satu id, dua nasib: 403 untuk Siti, 200 untuk Budi. Seluruh proyek ditutup dengan suite tes mandiri — sebagian besar menguji repository, validasi, dan user secara langsung tanpa jaringan, lalu satu tes HTTP menaikkan server di port acak (listen(0)) dan memanggilnya dengan fetch bawaan Node.js:

js
// tests/api-tes.js — suite tes mandiri proyek API manajemen tugas
// Sebagian besar menguji repository, validasi, dan user secara langsung
// tanpa jaringan; ditutup satu tes HTTP dengan server di port acak.
// Jalankan dari folder proyek:
//   node tests/api-tes.js

process.env.DB_NAME = "proyek_api_tugas_tes"; // database khusus tes

const assert = require("node:assert");
const { ambilDb, tutupKoneksi } = require("../src/db");
const tugas = require("../src/tugas");
const { validasiTugas, validasiUbah, idValid } = require("../src/validasi");
const { siapkanIndexUser, buatUser, verifikasiApiKey } = require("../src/users");
const { buatServer } = require("../src/server");

async function tesValidasi() {
  const baik = validasiTugas({ kode: "TG-001", judul: "Uji validasi" });
  assert.equal(baik.valid, true);
  assert.equal(baik.nilai.status, "todo"); // nilai bawaan terpasang

  // field tak dikenal dibuang, bukan ditolak
  const asing = validasiTugas({
    kode: "TG-002", judul: "Uji field asing", peran: "admin",
  });
  assert.equal(asing.valid, true);
  assert.equal("peran" in asing.nilai, false);

  const galat = validasiTugas({ kode: "TG-103", judul: 123, status: "selesai" });
  assert.equal(galat.valid, false);
  assert.equal(galat.galat.length, 2);

  const ubah = validasiUbah({ prioritas: "tinggi" });
  assert.equal(ubah.valid, false);

  assert.equal(idValid("bukan-id"), false);
  assert.equal(idValid("0123456789abcdef01234567"), true);
}

async function tesRepository() {
  const db = await ambilDb();
  await db.dropDatabase();
  await tugas.siapkanIndex(db);

  const t1 = await tugas.buatTugas(db, {
    kode: "TG-001", judul: "Belajar driver", deskripsi: "latihan",
  });
  await tugas.buatTugas(db, { kode: "TG-002", judul: "Menulis tes" });
  assert.equal(String(t1._id).length, 24);

  // kode kembar ditolak index unik dengan E11000
  await assert.rejects(
    () => tugas.buatTugas(db, { kode: "TG-001", judul: "Duplikat" }),
    { code: 11000 }
  );

  const daftar = await tugas.daftarTugas(db, { status: null, halaman: 1, batas: 10 });
  assert.equal(daftar.total, 2);
  assert.equal(daftar.data.length, 2);

  const todo = await tugas.daftarTugas(db, { status: "todo", halaman: 1, batas: 10 });
  assert.equal(todo.total, 2);

  await tugas.ubahTugas(db, String(t1._id), { status: "doing" });
  const sesudah = await tugas.cariTugas(db, String(t1._id));
  assert.equal(sesudah.status, "doing");

  assert.equal(await tugas.hapusTugas(db, String(t1._id)), true);
  assert.equal(await tugas.hapusTugas(db, String(t1._id)), false);
  assert.equal(await tugas.cariTugas(db, "bukan-id"), null);
}

async function tesUser() {
  const db = await ambilDb();
  await siapkanIndexUser(db);

  const hasil = await buatUser(db, {
    nama: "Budi", email: "[email protected]", peran: "admin",
  });
  assert.equal(hasil.api_key.length, 64);

  const user = await verifikasiApiKey(db, hasil.api_key);
  assert.equal(user.email, "[email protected]");
  assert.equal(user.peran, "admin");

  assert.equal(await verifikasiApiKey(db, "kunci-palsu-1234567890abcdef"), null);
  assert.equal(await verifikasiApiKey(db, undefined), null);

  // email kembar ditolak index unik
  await assert.rejects(
    () => buatUser(db, { nama: "Budi Dua", email: "[email protected]", peran: "anggota" }),
    { code: 11000 }
  );
}

async function tesHttp() {
  const db = await ambilDb();
  await db.collection("tugas").deleteMany({});
  const kunciAdmin = (await buatUser(db, {
    nama: "Dewi", email: "[email protected]", peran: "admin",
  })).api_key;
  const kunciAnggota = (await buatUser(db, {
    nama: "Siti", email: "[email protected]", peran: "anggota",
  })).api_key;
  const header = (kunci) => ({
    "X-API-Key": kunci,
    "Content-Type": "application/json",
  });

  const server = buatServer();
  await new Promise((selesai) => server.listen(0, "127.0.0.1", selesai));
  const alamat = "http://127.0.0.1:" + server.address().port;

  let r = await fetch(alamat + "/tugas");
  assert.equal(r.status, 401);

  r = await fetch(alamat + "/tugas", { headers: header("kunci-palsu-1234567890abcdef") });
  assert.equal(r.status, 401);

  r = await fetch(alamat + "/tugas", {
    method: "POST",
    headers: header(kunciAnggota),
    body: JSON.stringify({ kode: "TG-900", judul: "Uji lewat HTTP" }),
  });
  assert.equal(r.status, 201);
  const tugasBaru = await r.json();

  r = await fetch(alamat + "/tugas", {
    method: "POST",
    headers: header(kunciAnggota),
    body: JSON.stringify({ judul: 9 }),
  });
  assert.equal(r.status, 400);
  assert.equal((await r.json()).error.kode, "VALIDASI");

  r = await fetch(alamat + "/tugas", {
    method: "POST",
    headers: header(kunciAnggota),
    body: JSON.stringify({ kode: "TG-900", judul: "Duplikat" }),
  });
  assert.equal(r.status, 409);

  r = await fetch(alamat + "/tugas/" + tugasBaru._id, {
    method: "DELETE",
    headers: header(kunciAnggota),
  });
  assert.equal(r.status, 403);

  r = await fetch(alamat + "/tugas/" + tugasBaru._id, {
    method: "DELETE",
    headers: header(kunciAdmin),
  });
  assert.equal(r.status, 200);

  r = await fetch(alamat + "/pengguna", { headers: header(kunciAdmin) });
  assert.equal(r.status, 404);

  server.close();
}

(async function main() {
  try {
    await tesValidasi();
    console.log("lulus - validasi payload dan format id");
    await tesRepository();
    console.log("lulus - repository tugas (CRUD, E11000, pagination)");
    await tesUser();
    console.log("lulus - user, API key, dan timing-safe compare");
    await tesHttp();
    console.log("lulus - server HTTP (401/400/403/409/404/sukses)");
    console.log("SEMUA TES LULUS");
  } catch (galat) {
    console.error("TES GAGAL:", galat.message);
    process.exitCode = 1;
  } finally {
    const db = await ambilDb();
    await db.dropDatabase();
    await tutupKoneksi();
  }
})();

Tes memakai database proyek_api_tugas_tes lewat process.env.DB_NAME yang dipasang sebelum modul lain di-require, lalu membersihkan dirinya sendiri di blok finally. Jalankan dari folder proyek:

bash
node tests/api-tes.js

Output yang Dihasilkan:

text
lulus - validasi payload dan format id
lulus - repository tugas (CRUD, E11000, pagination)
lulus - user, API key, dan timing-safe compare
lulus - server HTTP (401/400/403/409/404/sukses)
SEMUA TES LULUS

Catatan Keamanan untuk Produksi #

Mekanisme ini tepat untuk API server-ke-server, tetapi produksi menuntut lebih:

  • Password manusia bukan API key. SHA-256 sekali jalan terlalu cepat dipecahkan brute force untuk password yang dipilih manusia. Gunakan bcrypt atau argon2 yang memang dirancang lambat — kunci 256-bit acak aman di-hash cepat karena ruang nilainya terlalu besar untuk ditebak.
  • JWT untuk pengguna aplikasi. Bila klien adalah aplikasi seluler atau web milik manusia, pola login → token JWT → verifikasi stateless lebih cocok; API key statis lebih sesuai untuk integrasi antar-server. Keduanya bisa berjalan berdampingan.
  • HTTPS wajib. Header X-API-Key yang dikirim lewat HTTP polos dapat dibaca di sepanjang jalur jaringan — aktifkan TLS sebagaimana dibahas chapter TLS dan Encryption.
  • Rotasi dan pencabutan. Tambahkan field kadaluarsa_pada serta endpoint pencabutan kunci; kunci yang tidak bisa dicabut adalah kunci yang tidak bisa dipercaya jangka panjang.
  • Batas laju permintaan. Verifikasi kunci murah, tetapi tanpa rate limiting penyerang bisa menebak kunci dalam volume besar.

Daftar lengkapnya — least privilege untuk user database, isolasi jaringan, audit — tersedia di chapter Security Checklist dan Least Privilege pada Section Keamanan.

Rangkuman #

  • Koneksi, repository, validasi, dan auth hidup di modul terpisah; server.js hanya merangkai semuanya lewat node:http murni.
  • Data tidak pernah dipercaya: field asing dibuang, tipe dan enum diperiksa, id divalidasi formatnya sebelum menyentuh ObjectId.
  • Semua kegagalan berbicara satu bahasa: amplop error dengan kode dan pesan, termasuk pemetaan E11000 menjadi 409.
  • Kunci rahasia tidak pernah disimpan mentah; verifikasi memakai prefix lookup plus timingSafeEqual.
  • Suite tes tests/api-tes.js mengunci seluruh perilaku — modifikasi apa pun di masa depat sebaiknya melewati tes ini lebih dulu.