← Semua pembelajaran / Astro Nol → Portal Berita
Fase 5 · Membership, Auth & Paywall

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.

Sumber asli owasp.org Artikel Rangkuman ~9 menit baca

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 algoritma supaya 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

HalTindakan
Token "ingat saya" CI3Batalkan semua. Formatnya berbeda dan tidak layak diakali
Token reset password yang belum dipakaiBatalkan; kirim ulang kalau diminta
Token verifikasi emailBisa dipertahankan kalau formatnya sekadar string acak
Sesi ci_sessions yang aktifMateri berikutnya
Login pihak ketiga (Google, Facebook)ID provider-nya tetap sama; petakan ulang saja

Kebijakan sandi baru

AturanNilaiKenapa
Panjang minimum12 karakterPanjang mengalahkan kerumitan
Panjang maksimumMinimal 64Jangan potong; bcrypt sendiri berhenti di 72 byte
Wajib simbol & angkaTidakMendorong pola tertebak seperti Sandi123!
Cek daftar sandi bocorYaJauh lebih efektif daripada aturan komposisi
Kedaluwarsa berkalaTidakSudah 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.