Penyimpanan dari Bagian 1 menunggu satu jenis isi: rekaman salt:hash pada kolom hash_sandi. Pada bagian ini kita mengisi seluruh jalur yang menghasilkan dan memakai rekaman itu — endpoint POST /daftar untuk membuatnya dan POST /login untuk memeriksanya. Mekanisme scrypt, alasan salt, dan arti perbandingan waktu konstan sudah dibahas tuntas pada Chapter Password Hashing dengan scrypt; di sini kita langsung menerapkannya sebagai modul kecil yang dipanggil handler. Dua keputusan baru juga muncul di tingkat HTTP: validasi menolak data sebelum menyentuh crypto, dan pesan kegagalan login dibuat seragam supaya response tidak membocorkan bagian mana yang salah.

Hash scrypt dalam Rekaman salt:hash #

Seluruh urusan sandi hidup di src/sandi.js — dua function, tanpa state, tanpa bergantung pada HTTP:

js src/sandi.js
// Hash sandi dengan scrypt: rekaman "salt:hash" dan verifikasi waktu konstan.

import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';

const PANJANG_SALT = 16; // byte -> 32 karakter hex
const PANJANG_HASH = 32; // byte -> 64 karakter hex

export function buatRekamanSandi(sandi) {
  const salt = randomBytes(PANJANG_SALT).toString('hex');
  const hash = scryptSync(sandi, salt, PANJANG_HASH).toString('hex');
  return `${salt}:${hash}`;
}

export function verifikasiSandi(sandi, rekaman) {
  if (typeof rekaman !== 'string') {
    return false;
  }
  const [salt, hashHex] = rekaman.split(':');
  if (!salt || !hashHex) {
    return false;
  }
  const hashBaru = scryptSync(sandi, salt, PANJANG_HASH);
  const hashTersimpan = Buffer.from(hashHex, 'hex');
  return hashBaru.length === hashTersimpan.length
    && timingSafeEqual(hashBaru, hashTersimpan);
}

Mari kita bedah baris-per-barisnya:

  1. Baris 5-6 (dua konstanta): salt 16 byte menjadi 32 karakter hex, hash 32 byte menjadi 64 karakter hex. Angka ini tetap di satu tempat sehingga pembuat dan pemeriksa rekaman tidak bisa berbeda pendapat.
  2. Baris 8-12 (buatRekamanSandi): salt segar diacak untuk setiap pendaftaran lewat randomBytes — dua pengguna bersandi sama tidak akan pernah menghasilkan rekaman yang sama. scryptSync mengolah pasangan sandi-salt menjadi 32 byte, lalu keduanya digabung menjadi satu string salt:hash siap disimpan ke kolom hash_sandi.
  3. Baris 14-17 (pengingkaran masukan): rekaman yang bukan string langsung dinilai salah tanpa melempar error — verifikasi tidak pernah boleh meruntuhkan proses hanya karena datanya cacat.
  4. Baris 18-21 (pemisahan rekaman): string diurai kembali menjadi salt dan hash tersimpan; rekaman tanpa pemisah : ditolak lebih dulu.
  5. Baris 22-25 (inti verifikasi): sandi masukan diolah ulang dengan salt yang sama, lalu dibandingkan memakai timingSafeEqual — bukan === yang bisa berhenti di byte pertama yang berbeda. Pemeriksaan panjang lebih dulu wajib karena timingSafeEqual melempar RangeError saat kedua buffer beda panjang.

Validasi Pendaftaran #

Endpoint menerima JSON apa pun dari jaringan, jadi field-nya diperiksa sebelum dipakai. Function validasiDaftar di src/server.js mengumpulkan seluruh galat sekaligus alih-alih berhenti di yang pertama — pengguna langsung tahu semua yang perlu diperbaiki:

js src/server.js
export function validasiDaftar(body) {
  const galat = [];
  const nama = typeof body?.nama === 'string' ? body.nama.trim() : '';
  const sandi = typeof body?.sandi === 'string' ? body.sandi : '';
  const peran = typeof body?.peran === 'string' && body.peran !== ''
    ? body.peran
    : 'pengguna';

  if (nama.length < 3 || nama.length > 30 || !/^[a-zA-Z0-9_.-]+$/.test(nama)) {
    galat.push({
      field: 'nama',
      pesan: 'nama harus 3-30 karakter dari huruf, angka, titik, garis bawah, atau tanda hubung',
    });
  }
  if (sandi.length < 8 || sandi.length > 128) {
    galat.push({ field: 'sandi', pesan: 'sandi harus 8-128 karakter' });
  }
  if (!PERAN_VALID.includes(peran)) {
    galat.push({
      field: 'peran',
      pesan: `peran harus salah satu dari: ${PERAN_VALID.join(', ')}`,
    });
  }
  return { nilai: { nama, sandi, peran }, galat };
}

Tiga keputusan pada potongan di atas:

  1. Baris 17-21 (normalisasi): nama dipangkas ujungnya, sandi dibiarkan apa adanya — spasi bisa jadi bagian sandi yang sah. Field yang hilang atau bertipe lain dinormalisasi menjadi string kosong, bukan error; penilaiannya diserahkan ke aturan.
  2. Baris 23-28 (aturan nama): regex membatasi karakter nama ke huruf, angka, titik, garis bawah, dan tanda hubung — kombinasi yang aman ditampilkan dan tidak menyisipkan karakter kendali.
  3. Baris 32-37 (aturan peran): nilai peran harus anggota PERAN_VALID; nilai kosong berarti memakai bawaan pengguna.

Handler Daftar dan Login #

tanganiDaftar merangkai validasi, hashing, dan penyimpanan menjadi satu jalur dengan status code yang tegas:

js src/server.js
async function tanganiDaftar(konteks) {
  const { req, res, penyimpanan } = konteks;
  let body;
  try {
    body = await bacaBodyJson(req);
  } catch {
    return kirimError(res, 400, 'JSON_TIDAK_VALID', 'Body permintaan bukan JSON valid');
  }

  const { nilai, galat } = validasiDaftar(body);
  if (galat.length > 0) {
    return kirimError(res, 422, 'VALIDASI_GAGAL',
      'Data pendaftaran tidak lolos validasi', galat);
  }

  const rekaman = buatRekamanSandi(nilai.sandi);
  try {
    penyimpanan.tambah(nilai.nama, rekaman, nilai.peran);
  } catch (err) {
    if (galatNamaTerpakai(err)) {
      return kirimError(res, 409, 'NAMA_TERPAKAI',
        `Nama ${nilai.nama} sudah terdaftar`);
    }
    throw err;
  }
  return kirimJson(res, 201, { nama: nilai.nama, peran: nilai.peran });
}

Alurnya berlapis: body yang bukan JSON dijawab 400 pada baris 44-48, data yang tidak lolos validasi dijawab 422 beserta detail per field pada baris 50-54, lalu sandi diubah menjadi rekaman pada baris 56 — dari titik ini sandi asli tidak dipakai lagi. Insert yang menabrak constraint UNIQUE ditangkap pada baris 59-64 dan dijawab 409; error lain diteruskan ke penangan error terpusat. Response 201 pada baris 66 hanya memuat nama dan peran — bukti bahwa pendaftaran berhasil tidak perlu membocorkan apa pun tentang sandi.

Jalur login memakai separuh awal tanganiLogin:

js src/server.js
  const nama = typeof body?.nama === 'string' ? body.nama.trim() : '';
  const sandi = typeof body?.sandi === 'string' ? body.sandi : '';

  // Gerbang pembatas diperiksa lebih dulu, sebelum menyentuh penyimpanan.
  const status = pembatas.boleh(nama);
  if (!status.diizinkan) {
    res.setHeader('Retry-After', String(status.sisaDetik));
    return kirimError(res, 429, 'TERLALU_BANYAK_PERCOBAAN',
      'Terlalu banyak percobaan login; tunggu sebentar');
  }

  const pengguna = nama !== '' ? penyimpanan.cariNama(nama) : null;
  const cocok = pengguna !== null && verifikasiSandi(sandi, pengguna.hash_sandi);
  if (!cocok) {
    pembatas.catatGagal(nama);
    return kirimError(res, 401, 'LOGIN_GAGAL', 'Nama atau sandi salah');
  }

Baris 88-93 adalah inti keputusannya: cariNama dan verifikasiSandi digabung menjadi satu boolean cocok, sehingga nama yang tidak ditemukan dan sandi yang salah menghasilkan response yang persis sama — status 401 dengan pesan Nama atau sandi salah. Penanya yang mencoba menebak nama terdaftar tidak mendapat konfirmasi apa pun. Baris 80-86 adalah gerbang pembatas percobaan login yang dibahas tuntas di Bagian 4; di sini cukup dipahami bahwa gerbang itu berdiri paling depan, dan setiap kegagalan dicatat lewat catatGagal pada baris 91.

Menguji Hash dan Validasi #

Sembilan test unit mengunci perilaku kedua lapis ini — format rekaman, verifikasi benar dan salah, keunikan salt, ketahanan terhadap rekaman rusak, serta seluruh cabang validasi:

bash
node --test tests/validasi.test.js tests/sandi.test.js

Output yang Dihasilkan:

text
▶ 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
▶ 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 9
ℹ suites 2
ℹ pass 9
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0

Pendaftaran dan login kini utuh: data mentah ditolak lebih dulu, sandi disimpan sebagai hash bersalt, dan kegagalan login tidak membocorkan bagiannya. Namun login yang berhasil pun belum menghasilkan apa pun — pengguna tidak bisa membawa “sudah masuk” ke permintaan berikutnya. Untuk itu kita perlu token yang ditandatangani, topik Bagian 3.