Login yang berhasil kini berakhir di satu pertanyaan: bagaimana permintaan berikutnya membuktikan bahwa pemiliknya sudah masuk? Proyek ini memilih token gaya JWT yang ditandatangani HMAC-SHA256 dan dikirim lewat header Authorization: Bearer — pola stateless dari Chapter Token dan JWT dan Middleware Autentikasi. Token membawa identitas dan peran di dalam klaimnya, sehingga endpoint terlindungi cukup memverifikasi tanda tangan tanpa membuka penyimpanan. Pada bagian ini kita membangun modul token, memasangnya di ujung jalur login, lalu menegakkan dua gerbang berbeda pada rute /aku dan /admin — persis pembedaan 401 dan 403 dari Chapter Role-Based Access Control.

Menandatangani dan Memverifikasi Token #

Dua function publik di src/token.js mengurus seluruh matematikanya. Bagian pertama menerbitkan token:

js src/token.js
function tandaTangan(header, payload, rahasia) {
  const hmac = createHmac('sha256', rahasia);
  hmac.update(`${header}.${payload}`);
  return base64url(hmac.digest());
}

export function buatToken(claims, rahasia) {
  const header = base64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
  const payload = base64url(JSON.stringify(claims));
  return `${header}.${payload}.${tandaTangan(header, payload, rahasia)}`;
}

tandaTangan pada baris 9-13 menghitung HMAC atas string header.payload memakai createHmac — mengubah satu byte saja pada kedua bagian itu mengubah hasil akhirnya. buatToken pada baris 15-19 menyusun string tiga bagian yang dipisah titik: header menyatakan algoritma, payload membawa klaim, dan tanda tangan menjadi segel keasliannya. Seluruh bagian dikodekan base64url sehingga aman berada di dalam header HTTP.

Bagian kedua memeriksa token yang datang — berlapis, karena masukan dari jaringan tidak bisa dipercaya bentuknya:

js src/token.js
export function verifikasiToken(token, rahasia, waktuSekarang) {
  if (typeof token !== 'string') {
    return { valid: false, alasan: 'token harus berupa string' };
  }
  const bagian = token.split('.');
  if (bagian.length !== 3) {
    return { valid: false, alasan: 'struktur harus 3 bagian' };
  }
  const [header, payload, tanda] = bagian;

  const tandaBaru = tandaTangan(header, payload, rahasia);
  const a = Buffer.from(tanda);
  const b = Buffer.from(tandaBaru);
  const cocok = a.length === b.length && timingSafeEqual(a, b);
  if (!cocok) {
    return { valid: false, alasan: 'tanda tangan tidak cocok' };
  }

  let claims;
  try {
    claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
  } catch {
    return { valid: false, alasan: 'payload tidak bisa dibaca' };
  }
  if (typeof claims.exp !== 'number' || !Number.isFinite(claims.exp)) {
    return { valid: false, alasan: 'klaim exp tidak ada' };
  }
  if (claims.exp <= waktuSekarang) {
    return { valid: false, alasan: 'token kedaluwarsa' };
  }
  return { valid: true, claims };
}

Empat lapis pemeriksaan berdiri berurutan:

  1. Baris 22-28 (bentuk dasar): token harus string dengan tepat tiga bagian — token pendek, panjang, atau berformat asing ditolak sebelum menyentuh crypto.
  2. Baris 31-37 (tanda tangan): tanda tangan dihitung ulang dari header.payload yang diterima lalu dibandingkan memakai timingSafeEqual dengan pemeriksaan panjang lebih dulu — pola yang sama dengan verifikasi sandi di Bagian 2. Pemalsuan klaim tanpa kunci pasti gagal di sini.
  3. Baris 39-47 (payload dan klaim): payload didekode dan diurai di dalam try/catch — payload yang bukan JSON valid, atau klaim exp yang hilang dan bukan angka, ditolak tanpa meruntuhkan proses. Token yang tanda tangannya sah tetapi muatannya rusak tetap tidak boleh lolos.
  4. Baris 48-50 (kedaluwarsa): klaim exp dibandingkan dengan waktuSekarang yang dikirim pemanggil, bukan dengan jam yang dibaca sendiri — keputusan yang alasan praktisnya muncul sebentar lagi.

Menerbitkan Token saat Login #

Bagian akhir tanganiLogin mengganti keberhasilan verifikasi dengan token yang siap dipakai:

js src/server.js
  pembatas.catatBerhasil(nama);
  const token = buatToken(
    { sub: pengguna.nama, peran: pengguna.peran, exp: waktu() + umurTokenDetik },
    rahasiaToken,
  );
  return kirimJson(res, 200, { token, jenis: 'Bearer', masaBerlakuDetik: umurTokenDetik });

Klaimnya berisi tiga hal: sub adalah nama pemilik, peran disalin dari penyimpanan sehingga keputusan izin selalu mengikuti data pendaftaran, dan exp dihitung dari waktu() — function jam yang disuntik lewat buatServer, bukan Date.now() yang dipanggil langsung. Berkat suntikan itu, test dapat membekukan waktu lalu majukan satu jam untuk membuktikan token kedaluwarsa tanpa menunggu sama sekali. Response 200 juga memberi tahu client masa berlaku token dalam detik sehingga jadwal pembaruan token bisa dihitung di sisi client.

Middleware wajibMasuk dan Rute Terlindungi #

Pemeriksaan token dikumpulkan dalam satu function yang dipanggil di awal setiap handler terlindungi:

js src/server.js
function bacaTokenBearer(req) {
  const otorisasi = req.headers.authorization ?? '';
  const [skema, token] = otorisasi.split(' ');
  if (skema === undefined || skema.toLowerCase() !== 'bearer' || !token) {
    return null;
  }
  return token;
}

function wajibMasuk(req, res, konteks) {
  const token = bacaTokenBearer(req);
  if (token === null) {
    res.setHeader('WWW-Authenticate', 'Bearer realm="layanan-auth"');
    kirimError(res, 401, 'WAJIB_MASUK',
      'Kirim token lewat header Authorization: Bearer');
    return null;
  }
  const hasil = verifikasiToken(token, konteks.rahasiaToken, konteks.waktu());
  if (!hasil.valid) {
    res.setHeader('WWW-Authenticate',
      'Bearer realm="layanan-auth", error="invalid_token"');
    kirimError(res, 401, 'TOKEN_TIDAK_VALID', 'Token tidak valid atau kedaluwarsa');
    return null;
  }
  return hasil.claims;
}

Dua keputusan layak diperhatikan. Pertama, header Authorization diurai longgar: Bearer ditoleransi dalam huruf kecil maupun besar, dan header yang hilang ditangani sama seperti skema yang salah. Kedua, kedua jenis kegagalan — token tidak ada dan token tidak valid — sama-sama menjawab 401 dengan header WWW-Authenticate seperti kesepakatan protokol HTTP; nilai error="invalid_token" memberi tahu client bahwa token yang dikirim perlu diambil ulang.

Satu hal sengaja disamakan: verifikasiToken sebenarnya mengembalikan alasan yang presisi — tanda tangan tidak cocok, payload rusak, atau kedaluwarsa — tetapi response HTTP menyatukan semuanya menjadi satu pesan generik. Alasan yang detail tetap hidup di unit test; pembawa token yang tidak sah tidak perlu tahu bagian mana yang salah.

Dua handler terlindungi tinggal memanggil gerbang itu lalu bekerja dengan claims:

js src/server.js
function tanganiAku(konteks) {
  const { req, res } = konteks;
  const claims = wajibMasuk(req, res, konteks);
  if (claims === null) return;
  return kirimJson(res, 200, { nama: claims.sub, peran: claims.peran });
}

function tanganiAdmin(konteks) {
  const { req, res, penyimpanan } = konteks;
  const claims = wajibMasuk(req, res, konteks);
  if (claims === null) return;
  if (claims.peran !== 'admin') {
    return kirimError(res, 403, 'PERAN_TIDAK_CUKUP',
      'Endpoint ini hanya untuk admin');
  }
  const pengguna = penyimpanan.daftar();
  return kirimJson(res, 200, { jumlah: pengguna.length, pengguna });
}

tanganiAku pada baris 103-108 hanya menyalin klaim ke response — profil dibaca dari token, bukan dari database. tanganiAdmin pada baris 110-120 menambahkan gerbang kedua: klaim peran harus admin, dan yang gagal dijawab 403 — bukan 401 — karena menambah token yang sama tidak akan mengubah hasilnya. Pesan penolakan pun hanya menyebut persyaratan endpoint, bukan daftar peran si penanya.

Menguji Token #

Delapan test unit menguji token dari semua sisi: struktur, roundtrip, pemalsuan payload, kunci yang berbeda, kedaluwarsa di dalam dan tepat pada batas exp, payload yang bukan JSON walau tanda tangannya sah, dan klaim exp yang hilang:

bash
node --test tests/token.test.js

Output yang Dihasilkan:

text
▶ token HS256
  ✔ struktur tiga bagian dan claims terbaca
  ✔ token sah lolos verifikasi
  ✔ payload yang diubah menyalahi tanda tangan
  ✔ rahasia berbeda menolak token
  ✔ token kedaluwarsa ditolak, termasuk tepat di batas exp
  ✔ struktur selain tiga bagian ditolak
  ✔ payload bukan JSON ditolak walau tanda tangan sah
  ✔ klaim exp yang hilang ditolak
✔ token HS256
ℹ tests 8
ℹ suites 1
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0

Token dan peran kini lengkap: login menerbitkan bukti bertanda tangan, /aku dan /admin memeriksa bukti itu dengan dua gerbang yang berbeda jawabannya. Yang tersisa adalah memastikan layanan ini tetap tegak saat dihadapkan pada tebakan massal dan konfigurasi yang ceroboh — pekerjaan pengerasan di Bagian 4.