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:
| Aspek | Keputusan | Alasan |
|---|---|---|
| Pembuatan kunci | crypto.randomBytes(32) → 64 karakter heksadesimal | 256-bit acak, mustahil ditebak |
| Penyimpanan | Prefix 8 karakter + hash SHA-256 | Prefix untuk pencarian, hash untuk verifikasi; kunci asli tidak pernah disimpan |
| Pembandingan | crypto.timingSafeEqual | Waktu 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 #
// 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:
node src/users.js [email protected] "Budi" admin
node src/users.js [email protected] "Siti" anggotaOutput yang Dihasilkan:
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):
6ffe54b91c16fa2ae4440a470c0f81568f050df69ebce49f298d5f00dcf298eaKunci 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:
gagal mendaftarkan: email sudah terdaftarPintu Auth dan Peran di Server #
Dua perubahan pada server.js menutup proyek ini. Pertama, gerbang autentikasi di awal tangani() — sebelum routing apa pun:
// 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:
// 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:
// 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:
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:
{
"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:
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:
{
"_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:
// 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:
node tests/api-tes.jsOutput yang Dihasilkan:
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 LULUSCatatan 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
bcryptatauargon2yang 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-Keyyang dikirim lewat HTTP polos dapat dibaca di sepanjang jalur jaringan — aktifkan TLS sebagaimana dibahas chapter TLS dan Encryption. - Rotasi dan pencabutan. Tambahkan field
kadaluarsa_padaserta 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.jshanya merangkai semuanya lewatnode:httpmurni. - Data tidak pernah dipercaya: field asing dibuang, tipe dan enum diperiksa, id divalidasi formatnya sebelum menyentuh
ObjectId. - Semua kegagalan berbicara satu bahasa: amplop
errordengankodedanpesan, termasuk pemetaanE11000menjadi 409. - Kunci rahasia tidak pernah disimpan mentah; verifikasi memakai prefix lookup plus
timingSafeEqual. - Suite tes
tests/api-tes.jsmengunci seluruh perilaku — modifikasi apa pun di masa depat sebaiknya melewati tes ini lebih dulu.