Memindahkan password member dari CI3
Ini ujian paling terlihat dari migrasi ini. Kalau ribuan member harus reset password di hari peluncuran, migrasinya gagal di mata mereka — apa pun kualitas teknisnya.
Intisari
- Hash
password_hash()PHP adalah bcrypt standar — Node bisa memverifikasinya tanpa masalah. - Awalan hash memberitahu algoritmanya:
$2y$bcrypt,$argon2id$Argon2. - Hash warisan MD5/SHA1 tidak bisa dipakai lagi, tapi bisa ditingkatkan saat login berikutnya.
- Simpan kolom
algoritmasupaya kamu tahu berapa banyak akun yang masih memakai hash lama. - Perbandingan hash wajib waktu-tetap.
===membocorkan informasi lewat waktu eksekusi.
Langkah nol: kenali isinya
SELECT
CASE
WHEN password LIKE '$2y$%' THEN 'bcrypt (php)'
WHEN password LIKE '$2a$%' THEN 'bcrypt (lama)'
WHEN password LIKE '$argon2id$%' THEN 'argon2id'
WHEN CHAR_LENGTH(password) = 32 THEN 'md5 — BAHAYA'
WHEN CHAR_LENGTH(password) = 40 THEN 'sha1 — BAHAYA'
WHEN CHAR_LENGTH(password) = 64 THEN 'sha256 — BAHAYA'
ELSE 'tidak dikenal'
END AS jenis,
COUNT(*) AS jumlah
FROM member
GROUP BY jenis
ORDER BY jumlah DESC;
Jalankan ini sebelum menulis satu baris kode auth. Hasilnya menentukan seberapa besar pekerjaannya — dan seberapa buruk keadaan keamananmu sekarang.
Kalau ada baris MD5 atau SHA1 di hasilnya, itu temuan keamanan, bukan sekadar utang teknis. Hash tanpa salt dan tanpa peregangan bisa dibalik dengan rainbow table dalam hitungan detik. Kalau database itu pernah bocor — dan kamu tidak selalu tahu — password member sudah diketahui orang. Selesaikan peningkatannya, lalu pertimbangkan memaksa reset untuk akun yang belum login dalam waktu lama.
Memverifikasi bcrypt PHP di Node
pnpm add bcryptjs
pnpm add -D @types/bcryptjs
// src/lib/sandi.ts
import bcrypt from "bcryptjs";
import { createHash, timingSafeEqual } from "node:crypto";
const BIAYA = 12;
export async function verifikasi(sandi: string, hash: string): Promise<boolean> {
// bcrypt dari PHP password_hash() — kompatibel penuh
if (hash.startsWith("$2y$") || hash.startsWith("$2a$") || hash.startsWith("$2b$")) {
return bcrypt.compare(sandi, hash);
}
// Warisan CI3: md5 polos
if (/^[a-f0-9]{32}$/i.test(hash)) {
return samaWaktuTetap(createHash("md5").update(sandi).digest("hex"), hash);
}
// Warisan: sha1
if (/^[a-f0-9]{40}$/i.test(hash)) {
return samaWaktuTetap(createHash("sha1").update(sandi).digest("hex"), hash);
}
return false;
}
function samaWaktuTetap(a: string, b: string): boolean {
const ba = Buffer.from(a, "utf8");
const bb = Buffer.from(b, "utf8");
if (ba.length !== bb.length) return false;
return timingSafeEqual(ba, bb);
}
export function butuhDitingkatkan(hash: string): boolean {
if (!hash.startsWith("$2")) return true; // bukan bcrypt
const biaya = Number(hash.split("$")[2]);
return Number.isNaN(biaya) || biaya < BIAYA; // bcrypt terlalu lemah
}
export const buatHash = (sandi: string) => bcrypt.hash(sandi, BIAYA);
$2y$ adalah penanda milik PHP, dan itu bukan masalah. Ia menandai perbaikan bug
bcrypt PHP tahun 2011; formatnya identik dengan $2b$. bcryptjs menerimanya apa
adanya. Kalau kamu memakai pustaka lain yang menolak $2y$, mengganti dua karakter awal jadi
$2b$ aman — tapi lebih baik pakai pustaka yang tidak perlu diakali.
Meningkatkan diam-diam saat login
// src/lib/auth.ts
export async function masuk(email: string, sandi: string, ip: string) {
const m = await dbBaca
.selectFrom("member")
.select(["id", "nama", "email", "password", "status"])
.where("email", "=", email.toLowerCase().trim())
.executeTakeFirst();
// Selalu jalankan hash palsu supaya waktu responsnya seragam —
// tanpa ini, email yang terdaftar bisa dibedakan dari yang tidak.
if (!m) {
await bcrypt.compare(sandi, "$2b$12$" + "x".repeat(53));
return null;
}
if (!(await verifikasi(sandi, m.password))) return null;
if (m.status !== "aktif") return null;
// Sandi benar — dan sekarang kita punya sandi polosnya di memori.
// Ini SATU-SATUNYA kesempatan meningkatkan hash-nya.
if (butuhDitingkatkan(m.password)) {
const baru = await buatHash(sandi);
await dbTulis
.updateTable("member")
.set({ password: baru, algoritma: "bcrypt12", sandi_diubah_pada: new Date() })
.where("id", "=", m.id)
.execute();
}
return { id: m.id, nama: m.nama, email: m.email };
}
Member tidak melihat apa pun. Ia login seperti biasa; hash-nya diam-diam naik kelas. Setelah beberapa bulan, sebagian besar akun aktif sudah memakai bcrypt yang kuat — tanpa satu pun email "silakan reset password Anda" yang selalu berujung tiket dukungan.
Pemanggilan bcrypt.compare palsu untuk email yang tidak ada itu bukan paranoia.
Tanpa itu, permintaan dengan email tidak terdaftar dijawab dalam 2 ms sementara email terdaftar butuh
200 ms. Selisih itu cukup untuk menyusun daftar email member portalmu secara otomatis — dan daftar itu
punya nilai jual.
Memantau kemajuannya
SELECT
algoritma,
COUNT(*) AS jumlah,
SUM(terakhir_login >= DATE_SUB(NOW(), INTERVAL 90 DAY)) AS aktif_90h
FROM member
GROUP BY algoritma;
Kolom aktif_90h yang menentukan rencanamu. Akun dengan hash lemah yang masih aktif
akan naik kelas sendiri. Akun dengan hash lemah yang sudah dua tahun tidak login adalah kandidat untuk
dinonaktifkan — dan itu keputusan yang lebih aman daripada membiarkannya menunggu.
Yang harus ikut dimigrasikan
| Hal | Tindakan |
|---|---|
| Token "ingat saya" CI3 | Batalkan semua. Formatnya berbeda dan tidak layak diakali |
| Token reset password yang belum dipakai | Batalkan; kirim ulang kalau diminta |
| Token verifikasi email | Bisa dipertahankan kalau formatnya sekadar string acak |
Sesi ci_sessions yang aktif | Materi berikutnya |
| Login pihak ketiga (Google, Facebook) | ID provider-nya tetap sama; petakan ulang saja |
Kebijakan sandi baru
| Aturan | Nilai | Kenapa |
|---|---|---|
| Panjang minimum | 12 karakter | Panjang mengalahkan kerumitan |
| Panjang maksimum | Minimal 64 | Jangan potong; bcrypt sendiri berhenti di 72 byte |
| Wajib simbol & angka | Tidak | Mendorong pola tertebak seperti Sandi123! |
| Cek daftar sandi bocor | Ya | Jauh lebih efektif daripada aturan komposisi |
| Kedaluwarsa berkala | Tidak | Sudah tidak direkomendasikan; hasilnya sandi lebih lemah |
Batas 72 byte bcrypt itu jebakan nyata. bcrypt mengabaikan byte setelah ke-72 secara diam-diam. Untuk sandi berisi karakter non-ASCII — yang di UTF-8 bisa 3–4 byte per karakter — batas itu tercapai jauh lebih cepat dari yang kamu kira. Kalau ini mengganggumu, pakai Argon2id untuk hash baru; ia tidak punya batas itu, dan verifikasi bcrypt lama tetap bisa berjalan berdampingan.
Latihan: jalankan kueri inventaris di atas pada salinan database-mu dan catat hasilnya. Tulis
verifikasi() dan butuhDitingkatkan(), lalu buat uji yang membuktikan: hash
$2y$ dari PHP terverifikasi benar di Node, hash MD5 lama juga terverifikasi, dan setelah
login sekali hash-nya berubah jadi bcrypt biaya 12 di database.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.