Registrasi adalah pintu masuk pertama sistem autentikasi: seluruh password hash, session, dan token yang kita bahas pada chapter berikutnya berawal dari data yang disimpan saat pendaftaran. Kebocoran kecil di tahap ini — password disimpan mentah, email tidak divalidasi, pesan error yang membocorkan daftar pengguna — merusak keamanan seluruh bangunan di atasnya. Pada chapter ini kita membangun layanan registrasi utuh di atas SQLite in-memory: memvalidasi email dan username, meng-hash password, menyimpan pengguna baru, dan menangani email duplikat dengan pesan yang ramah. Seluruh contoh dapat dijalankan langsung tanpa server database terpisah.

Alur Registrasi Pengguna #

Alur registrasi yang aman berjalan dalam empat langkah berurutan. Pertama, aplikasi memvalidasi dan menormalisasi input: format email diperiksa, username dicocokkan dengan aturan penamaan, panjang password dicek terhadap batas minimum. Kedua, password di-hash dengan password_hash() — teks aslinya tidak pernah disimpan, sebagaimana dibahas tuntas pada chapter Password Hashing . Ketiga, aplikasi menyisipkan baris baru ke tabel users memakai prepared statement. Keempat, aplikasi menanggapi hasilnya: keberhasilan diarahkan ke halaman login, sedangkan kegagalan mengembalikan pesan error yang dapat ditindaklanjuti pengguna.

Ringkasan keempat langkah tersebut beserta penanganan kegagalannya:

LangkahTindakanPenanganan kegagalan
1. Validasifilter_var() untuk email, aturan username, panjang passwordkumpulkan pesan error, tampilkan kembali form
2. Hashingpassword_hash($password, PASSWORD_DEFAULT)biarkan exception menggulung — hampir mustahil gagal
3. PenyimpananINSERT INTO users ... via prepared statementtangkap pelanggaran UNIQUE sebagai error ramah
4. Responsarahkan ke login, kirim email verifikasijangan bocorkan detail teknis ke pengguna

Validasi Email dengan filter_var #

PHP menyediakan fungsi bawaan filter_var() dengan filter FILTER_VALIDATE_EMAIL yang memeriksa sintaks alamat email menurut aturan yang cukup ketat. Fungsi tersebut mengembalikan string email bila valid, atau false bila tidak — sehingga pemeriksaannya harus memakai perbandingan ketat terhadap false, bukan sekadar truthy check. Mari kita uji beberapa bentuk input yang umum ditemui di form registrasi:

php
<?php
$daftarEmail = [
    '[email protected]',
    'budi@example',
    'budi@@example.com',
    '[email protected]',
    '',
];

foreach ($daftarEmail as $email) {
    $label = $email === '' ? '(string kosong)' : $email;
    $hasil = filter_var($email, FILTER_VALIDATE_EMAIL);
    echo $label . ' => ' . ($hasil === false ? 'TIDAK VALID' : 'VALID') . "\n";
}

Output yang Dihasilkan:

text
[email protected] => VALID
budi@example => TIDAK VALID
budi@@example.com => TIDAK VALID
[email protected] => VALID
(string kosong) => TIDAK VALID

Output membuktikan dua hal: domain tanpa TLD seperti budi@example ditolak, begitu pula simbol @ ganda. Perhatikan bahwa filter_var() memeriksa sintaks, bukan eksistensi — [email protected] dinyatakan valid meskipun server example.com tidak menerima email. Konfirmasi bahwa email benar-benar milik pendaftar hanya bisa dilakukan lewat email verifikasi, yang kita bahas pada bagian akhir chapter ini.

Aturan Penulisan Username #

Username akan tampil di URL, disebut di halaman profil, dan dipakai pencarian — karena itu aturannya perlu ditetapkan sejak awal dan diterapkan konsisten. Charset yang sempit (huruf, angka, titik, garis bawah) menghindari kebingungan karakter kembar seperti rn vs m, memudahkan pencocokan, dan menutup celah injeksi pada konteks tampilan. Fungsi berikut mengembalikan pesan error pertama yang dilanggar, atau null bila username lolos semua aturan:

php
<?php
function validateUsername(string $username): ?string
{
    if (strlen($username) < 3) {
        return 'Username minimal 3 karakter';
    }
    if (strlen($username) > 30) {
        return 'Username maksimal 30 karakter';
    }
    if (!preg_match('/^[a-zA-Z0-9_.]+$/', $username)) {
        return 'Username hanya boleh berisi huruf, angka, titik, dan garis bawah';
    }
    if (str_starts_with($username, '.') || str_ends_with($username, '.')) {
        return 'Username tidak boleh diawali atau diakhiri titik';
    }
    return null;
}

$kandidat = ['budi', 'budi_santoso', 'budi.santoso', 'bu', 'budi-santoso', '.budi'];
foreach ($kandidat as $username) {
    $error = validateUsername($username);
    echo $username . ' => ' . ($error ?? 'VALID') . "\n";
}

Output yang Dihasilkan:

text
budi => VALID
budi_santoso => VALID
budi.santoso => VALID
bu => Username minimal 3 karakter
budi-santoso => Username hanya boleh berisi huruf, angka, titik, dan garis bawah
.budi => Username tidak boleh diawali atau diakhiri titik

Pemeriksaan panjang dilakukan lebih dulu karena paling murah; regex ^[a-zA-Z0-9_.]+$ memeriksa charset; dan aturan posisi titik mencegah username seperti .budi yang tampak aneh di URL. Batas 3 sampai 30 karakter adalah pilihan desain produk yang lazim — silakan disesuaikan, tetapi batas bawah wajib ada agar pengguna tidak mengklaim username satu karakter seperti -_. Tanda hubung sengaja tidak diizinkan pada contoh ini agar aturan tetap sederhana; beberapa aplikasi memilih mengizinkannya.

Layanan Registrasi dengan SQLite #

Sekarang kita rakit seluruh komponen menjadi satu layanan registrasi. Skema tabel memakai constraint UNIQUE pada kolom email — inilah penjaga terakhir yang benar-benar menjamin tidak ada email ganda, meskipun kode aplikasi bocor mengalami bug. Seluruh logika dibungkus fungsi registerUser() yang menerima koneksi PDO, sehingga mudah diuji dengan SQLite in-memory seperti ini:

php
<?php
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

$pdo->exec('CREATE TABLE users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    username TEXT NOT NULL,
    email TEXT NOT NULL UNIQUE,
    password_hash TEXT NOT NULL,
    created_at TEXT NOT NULL
)');

function registerUser(PDO $pdo, string $username, string $email, string $password): array
{
    $email = strtolower(trim($email));
    $errors = [];

    if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
        $errors[] = 'Format email tidak valid';
    }
    if (strlen($password) < 8) {
        $errors[] = 'Password minimal 8 karakter';
    }
    if ($errors !== []) {
        return ['sukses' => false, 'errors' => $errors];
    }

    // Hash SELALU dihitung sebelum percobaan insert, apa pun hasilnya,
    // agar durasi respons tidak membocorkan status email (timing-safe)
    $hash = password_hash($password, PASSWORD_DEFAULT);

    try {
        $pdo->prepare('INSERT INTO users (username, email, password_hash, created_at)
                       VALUES (?, ?, ?, ?)')
            ->execute([$username, $email, $hash, '2026-01-15 08:00:00']);
        return ['sukses' => true, 'user_id' => (int) $pdo->lastInsertId()];
    } catch (PDOException $e) {
        if ($e->getCode() === '23000') {
            return ['sukses' => false,
                    'errors' => ['Email sudah terdaftar. Silakan login atau gunakan email lain.']];
        }
        throw $e;
    }
}

$hasil = registerUser($pdo, 'budi', '[email protected]', 'rahasia-kuat-123');
echo 'Registrasi budi: ' . var_export($hasil['sukses'], true) . "\n";
echo 'user_id baru: ' . $hasil['user_id'] . "\n";

$hasil = registerUser($pdo, 'budi2', '[email protected]', 'rahasia-kuat-456');
echo 'Email duplikat: ' . var_export($hasil['sukses'], true) . "\n";
echo 'Pesan: ' . $hasil['errors'][0] . "\n";

$hasil = registerUser($pdo, 'siti', '[email protected]', 'pendek');
echo 'Password pendek: ' . var_export($hasil['sukses'], true) . "\n";
echo 'Pesan: ' . $hasil['errors'][0] . "\n";

// Fakta struktural hash tersimpan - hash mentah tidak pernah dicetak
$stmt = $pdo->prepare('SELECT email, password_hash FROM users WHERE email = ?');
$stmt->execute(['[email protected]']);
$row = $stmt->fetch(PDO::FETCH_ASSOC);

echo 'Email tersimpan (ternormalisasi): ' . $row['email'] . "\n";
echo 'Panjang hash tersimpan: ' . strlen($row['password_hash']) . " karakter\n";
echo 'Awalan algoritma: ' . substr($row['password_hash'], 0, 4) . "\n";
echo 'Verifikasi password asli: ' . var_export(password_verify('rahasia-kuat-123', $row['password_hash']), true) . "\n";
echo 'Jumlah user tersimpan: ' . $pdo->query('SELECT COUNT(*) FROM users')->fetchColumn() . "\n";

Output yang Dihasilkan:

text
Registrasi budi: true
user_id baru: 1
Email duplikat: false
Pesan: Email sudah terdaftar. Silakan login atau gunakan email lain.
Password pendek: false
Pesan: Password minimal 8 karakter
Email tersimpan (ternormalisasi): [email protected]
Panjang hash tersimpan: 60 karakter
Awalan algoritma: $2y$
Verifikasi password asli: true
Jumlah user tersimpan: 1

Mari kita bedah bagian-bagian pentingnya:

  1. Normalisasi email (baris pertama fungsi): strtolower(trim($email)) membuat [email protected] tersimpan sebagai [email protected] — terbukti pada output, dan itulah nilai yang berhasil ditemukan kembali oleh kueri terakhir.
  2. Validasi berhenti lebih dulu: bila email atau password tidak lolos, fungsi mengembalikan daftar error tanpa menyentuh database. Pesan untuk password pendek muncul persis dari jalur ini.
  3. Hash sebelum insert: password_hash() memakan waktu puluhan milidetik — sengaja. Karena hash dihitung sebelum percobaan insert, jalur “email baru” dan “email duplikat” menempuh kerja yang hampir sama; pengukur waktu respons tidak bisa membedakan keduanya.
  4. Penanganan PDOException: kode SQLSTATE 23000 berarti pelanggaran constraint integritas — di sini pasti UNIQUE pada email. Fungsi menerjemahkannya menjadi pesan ramah; error database lain tetap dilempar ulang karena bukan skenario yang dipahami fungsi ini.
  5. Fakta struktural, bukan hash mentah: hasil password_hash() berbeda pada setiap eksekusi karena salt acak, jadi contoh hanya mencetak panjangnya (60 karakter untuk bcrypt) dan awalan algoritmanya ($2y$). Verifikasi tetap dibuktikan lewat password_verify() yang mengembalikan true.

Konsep Pengiriman Email Verifikasi #

Sebagian besar aplikasi tidak langsung mempercayai akun baru: email verifikasi dikirim ke alamat yang didaftarkan, dan akun baru aktif setelah tautan di dalamnya diklik. Mekanismenya serupa dengan reset password yang kita bangun pada chapter Password Reset : aplikasi membangkitkan token acak, menyimpan hash token tersebut di database beserta waktu kedaluwarsa, lalu mengirim tautan berisi token mentah ke email pendaftar. Ketika tautan diklik, aplikasi meng-hash token dari URL, mencocokkannya dengan database, dan menandai akun sebagai terverifikasi.

Bentuk emailnya kira-kira seperti ini:

text
Kepada: [email protected]
Subjek: Aktivasi akun Kursus PHP

Halo budi,

Terima kasih sudah mendaftar. Klik tautan berikut untuk
mengaktifkan akun Anda:

https://kursus-php.example/verify-email?token=<token-acak-64-karakter-hex>

Tautan berlaku 24 jam. Jika Anda tidak merasa mendaftar,
abaikan email ini dan tidak ada akun yang dibuat.

Soal pengiriman itu sendiri: PHP memiliki fungsi mail(), tetapi fungsi tersebut memindahkan tanggung jawab ke agen email lokal yang sering tidak tersedia atau langsung masuk folder spam. Aplikasi nyata hampir selalu memakai pustaka seperti Symfony Mailer atau PHPMailer yang berbicara SMTP ke penyedia email khusus, biasanya lewat antrean (queue) agar proses registrasi tidak menunggu jaringan. Detail alur verifikasi — masa berlaku token, token sekali pakai, pencegahan enumeration — kita bahas tuntas pada chapter Email Verification .

Ringkasan #

Registrasi yang aman adalah empat langkah disiplin: validasi dan normalisasi input, hashing password dengan password_hash(), penyimpanan lewat prepared statement dengan UNIQUE constraint, dan respons yang tidak membocorkan informasi berlebih. Constraint database menangkap email ganda meski terjadi balapan, PDOException kode 23000 diterjemahkan menjadi pesan ramah, dan penempatan hashing sebelum insert menjaga durasi respons tetap seragam. Dengan akun pertama di tabel, kita siap membangun pintu masuknya: verifikasi kredensial dan pembuatan session pada chapter Login dan Logout .

Referensi #