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:
// 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:
- 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.
- Baris 8-12 (
buatRekamanSandi): salt segar diacak untuk setiap pendaftaran lewatrandomBytes— dua pengguna bersandi sama tidak akan pernah menghasilkan rekaman yang sama.scryptSyncmengolah pasangan sandi-salt menjadi 32 byte, lalu keduanya digabung menjadi satu stringsalt:hashsiap disimpan ke kolomhash_sandi. - 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.
- Baris 18-21 (pemisahan rekaman): string diurai kembali menjadi salt dan hash tersimpan; rekaman tanpa pemisah
:ditolak lebih dulu. - 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 karenatimingSafeEqualmelemparRangeErrorsaat 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:
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:
- Baris 17-21 (normalisasi):
namadipangkas ujungnya,sandidibiarkan 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. - 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.
- Baris 32-37 (aturan peran): nilai peran harus anggota
PERAN_VALID; nilai kosong berarti memakai bawaanpengguna.
Handler Daftar dan Login #
tanganiDaftar merangkai validasi, hashing, dan penyimpanan menjadi satu jalur dengan status code yang tegas:
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:
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:
node --test tests/validasi.test.js tests/sandi.test.jsOutput yang Dihasilkan:
▶ 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 0Pendaftaran 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.