Layanan autentikasi yang berfungsi belum tentu layanan yang tahan ditekan. Bagian ini memasang lapisan pertahanan yang menerapkan langsung materi Section Security: pembatas percobaan login berbasis jendela tetap dari Chapter Rate Limiting, pesan galat yang tidak memberi informasi berlebih, serta pengelolaan kunci dari Chapter Manajemen Secret. Tantangan teknisnya ada satu: pertahanan bergantung pada waktu — jendela pembatas dan kedaluwarsa token sama-sama membaca jam — padahal test harus deterministik. Jawabannya sudah kita siapkan sejak Bagian 3: jam adalah dependensi yang disuntik, sehingga seluruh perilaku berbasis waktu bisa diuji dengan jam beku yang dimajukan sesuka test.

Pembatas Percobaan Login #

Tebakan sandi massal tidak butuh kecerdikan — cukup volume. src/pembatas.js menghitung kegagalan login per nama dalam jendela waktu tetap:

js src/pembatas.js
// Rate limiter login: jendela tetap per nama, jam disuntik agar teruji.

export function buatPembatasLogin(opsi = {}) {
  const batas = opsi.batas ?? 5;
  const jendelaDetik = opsi.jendelaDetik ?? 60;
  const waktu = opsi.waktu ?? (() => Math.floor(Date.now() / 1000));
  const catatan = new Map(); // nama -> { jendela, gagal }

  function jendelaKini() {
    return Math.floor(waktu() / jendelaDetik);
  }

  function sisaDetik() {
    return jendelaDetik - (waktu() % jendelaDetik);
  }

  return {
    boleh(nama) {
      const c = catatan.get(nama);
      if (c === undefined || c.jendela !== jendelaKini() || c.gagal < batas) {
        return { diizinkan: true, sisaDetik: sisaDetik() };
      }
      return { diizinkan: false, sisaDetik: sisaDetik() };
    },
    catatGagal(nama) {
      const c = catatan.get(nama);
      if (c === undefined || c.jendela !== jendelaKini()) {
        catatan.set(nama, { jendela: jendelaKini(), gagal: 1 });
        return;
      }
      c.gagal += 1;
    },
    catatBerhasil(nama) {
      catatan.delete(nama);
    },
  };
}

Mari kita bedah baris-per-barisnya:

  1. Baris 4-7 (konfigurasi): batas, panjang jendela, dan function waktu semuanya bisa disuntik. Default waktu memakai Date.now() untuk pemakaian nyata; test menggantinya dengan jam beku.
  2. Baris 9-15 (aritmetika jendela): jendelaKini() membagi detik dengan panjang jendela — dua panggilan dalam menit yang sama menghasilkan nomor jendela yang sama. sisaDetik() menghitung jarak ke jendela berikutnya untuk header Retry-After.
  3. Baris 18-24 (boleh): percobaan diizinkan bila nama belum tercatat, catatannya berasal dari jendela lama, atau hitungannya masih di bawah batas. Nama yang berbeda tidak saling memengaruhi.
  4. Baris 25-32 (catatGagal): kegagalan pertama pada sebuah jendela membuat catatan baru; kegagalan berikutnya menambah hitungan lama. Pergantian jendela otomatis memulai dari satu lagi.
  5. Baris 33-35 (catatBerhasil): login yang berhasil menghapus catatan — pengguna sah tidak terkunci oleh kegagalan lampau yang sudah ditebus.

Gerbang ini terpasang paling depan di tanganiLogin, sebelum cariNama dipanggil: percobaan yang sudah melewati batas dijawab 429 beserta header Retry-After , dan penghitungan berlaku juga untuk nama yang tidak terdaftar — kalau hanya nama terdaftar yang dihitung, penanya bisa membedakan mana nama yang ada.

Pesan Generik dan Jam yang Teruji #

Dua keputusan dari bagian-bagian sebelumnya kini berbuah: response login yang salah tidak membedakan nama tak dikenal dari sandi salah, dan response token yang ditolak tidak membedakan tanda tangan rusak dari kedaluwarsa. Keduanya menyulitkan pemetaan nama terdaftar maupun uji coba token secara massal. Kedaluwarsa sendiri bukan sekadar klaim — buatServer menyuntikkan waktu() ke verifikasi, dan integration test membuktikan efeknya: token yang diterbitkan pada jam beku ditolak setelah test memajukan jam melewati exp.

Kerentanan sisa justru ada di konfigurasi, maka seluruh dependensi sensitif dikumpulkan di satu tempat:

js src/server.js
export function buatServer(opsi = {}) {
  const penyimpanan = opsi.penyimpanan
    ?? buatPenyimpananPengguna(opsi.db ?? buatDb());
  const waktu = opsi.waktu ?? (() => Math.floor(Date.now() / 1000));
  const pembatas = opsi.pembatas
    ?? buatPembatasLogin({ waktu, batas: opsi.batasLogin });
  const rahasiaToken = opsi.rahasiaToken ?? RAHASIA_DEMO;
  const umurTokenDetik = opsi.umurTokenDetik ?? UMUR_TOKEN_DEMO;

Baris 158-159 memasang penyimpanan — baik connection yang sudah ada maupun database segar; baris 160-162 mengikat jam yang sama ke server dan pembatasnya sehingga keduanya selalu sepakat; baris 163-164 menyimpan rahasia token dan umurnya. Nilai bawaan RAHASIA_DEMO ada agar percobaan cepat tetap mudah, dan justru karena itu titik masuk produksi menolak menyala tanpa rahasia sungguhan:

js src/cli.js
const rahasiaToken = process.env.RAHASIA_TOKEN;
if (!rahasiaToken) {
  console.error('Environment variable RAHASIA_TOKEN wajib diisi sebelum server dinyalakan');
  process.exit(1);
}

const port = Number(process.env.PORT ?? 3000);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
  console.error(`PORT tidak valid: ${process.env.PORT}`);
  process.exit(1);
}

mkdirSync('data', { recursive: true });
const db = buatDb(join('data', 'auth.db'));
const server = buatServer({ db, rahasiaToken });

Rahasia dibaca dari process.env pada baris 8 — tidak ada kunci yang ditulis di dalam kode, dan server berhenti lebih dulu daripada menyala dengan kunci lemah. Validasi PORT mengikuti pola proyek REST API sebelumnya, lalu baris 20-22 mengalihkan penyimpanan ke berkas data/auth.db dan meneruskan rahasianya ke pabrik server.

bash
node src/cli.js

Output yang Dihasilkan:

text
Environment variable RAHASIA_TOKEN wajib diisi sebelum server dinyalakan
kode keluar: 1
bash
RAHASIA_TOKEN=contoh-rahasia PORT=abc node src/cli.js

Output yang Dihasilkan:

text
PORT tidak valid: abc
kode keluar: 1

Kedua kegagalan di atas adalah perilaku yang diinginkan: aplikasi memilih berhenti dengan pesan jelas alih-alih berjalan dengan konfigurasi yang tidak layak.

Menguji Pembatas dan Seluruh Layanan #

Empat test unit mengunci aritmetika pembatas — di bawah batas, melewati batas dengan sisa waktu, reset setelah berhasil, dan pergantian jendela:

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

Output yang Dihasilkan:

text
▶ pembatas login jendela tetap
  ✔ percobaan di bawah batas selalu diizinkan
  ✔ melewati batas dalam satu jendela ditolak dengan sisa waktu
  ✔ login berhasil menghapus hitungan
  ✔ jendela bergeser menghapus hitungan lama
✔ pembatas login jendela tetap
ℹ tests 4
ℹ suites 1
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0

Integration test kemudian mengunci keseluruhan perilaku dari luar: 42 test yang mencakup pendaftaran sampai 429, dijalankan dengan server nyata pada port ephemeral:

bash
node --test

Output yang Dihasilkan:

text
▶ layanan autentikasi ujung ke ujung
  ✔ daftar berhasil menjawab 201 tanpa mengembalikan sandi
  ✔ daftar dengan peran admin diterima
  ✔ nama terpakai menjawab 409
  ✔ validasi menolak data pendek dengan 422 dan detail
  ✔ body bukan JSON menjawab 400
  ✔ login gagal menjawab 401 dengan pesan generik
  ✔ login benar menerbitkan token yang membuka /aku
  ✔ /aku tanpa token menjawab 401 dengan WWW-Authenticate
  ✔ /aku dengan token rusak menjawab 401
  ✔ token yang dipalsukan ditolak di /aku
  ✔ /admin menolak pengguna biasa dengan 403
  ✔ /admin menerima admin tanpa membocorkan hash
  ✔ pembatas menjawab 429 setelah batas terlampaui
  ✔ login berhasil mereset hitungan pembatas
  ✔ token kedaluwarsa ditolak setelah masa berlaku
  ✔ jendela pembatas bergeser mengizinkan percobaan baru
  ✔ endpoint asing menjawab 404 dan method asing menjawab 405
✔ layanan autentikasi ujung ke ujung
▶ pembatas login jendela tetap
  ✔ percobaan di bawah batas selalu diizinkan
  ✔ melewati batas dalam satu jendela ditolak dengan sisa waktu
  ✔ login berhasil menghapus hitungan
  ✔ jendela bergeser menghapus hitungan lama
✔ pembatas login jendela tetap
▶ penyimpanan pengguna
  ✔ tambah lalu cariNama mengembalikan pengguna lengkap
  ✔ cariNama untuk nama asing mengembalikan null
  ✔ nama sama ditolak constraint UNIQUE
  ✔ daftar menampilkan nama dan peran tanpa hash_sandi
✔ penyimpanan pengguna
▶ hash sandi scrypt
  ✔ rekaman berformat salt:hash dengan panjang tetap
  ✔ sandi benar diverifikasi, sandi salah ditolak
  ✔ sandi sama menghasilkan rekaman berbeda karena salt acak
  ✔ rekaman rusak ditolak tanpa melempar error
✔ hash sandi scrypt
▶ 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
▶ validasi pendaftaran
  ✔ data valid dipangkas dan diberi peran bawaan
  ✔ peran admin diterima apa adanya
  ✔ semua galat terkumpul sekaligus
  ✔ nama dengan karakter asing ditolak
  ✔ body tanpa field sama sekali ditolak lengkap
✔ validasi pendaftaran
ℹ tests 42
ℹ suites 6
ℹ pass 42
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0

Daftar Periksa Pengerasan #

Sebagai rangkuman, berikut seluruh lapisan yang dipasang proyek ini beserta lokasinya:

LapisanAturanLokasi
Hash sandiscrypt bersalt acak, dibandingkan waktu konstansrc/sandi.js
Validasi masukandata ditolak sebelum menyentuh crypto dan databasesrc/server.js
Kebenaran namaconstraint UNIQUE di database, bukan cek aplikasisrc/db.js
Tokentanda tangan HMAC, klaim exp, verifikasi berlapissrc/token.js
Pesan generikkegagalan login dan token tidak membedakan alasannyasrc/server.js
Rate limitingkegagalan login dihitung per nama per jendelasrc/pembatas.js
Secretwajib dari environment variable, gagal cepat bila kosongsrc/cli.js

Proyek ini menutup dengan layanan autentikasi yang setiap keputusan keamanannya bisa dibaca dari baris kode: penyimpanan yang jujur, sandi yang tidak pernah tersimpan asli, token yang ditandatangani dan berumur pendek, serta gerbang volume di depan semuanya. Dari sini, materi Section Deployment dan Operations siap mengambil alih — secret yang sama perlu berpindah ke environment terkelola, dan test yang sama layak menjadi gerbang pipeline sebelum layanan dinyalakan di mesin sungguhan.