Registrasi menyiapkan kredensial; login adalah proses membuktikan bahwa pengguna adalah pemilik kredensial tersebut. Alurnya tampak sederhana — cari user, verifikasi password, buat session — tetapi detail penanganan kegagalannya menentukan apakah aplikasi kita bocor informasi atau tidak. Pada chapter ini kita bangun fungsi attemptLogin() di atas SQLite, menegaskan pesan error yang seragam untuk semua jenis kegagalan, lalu menutupnya dengan logout satu perangkat dan logout semua perangkat. Kita juga membahas konsep rate limiting sebagai rem terakhir melawan percobaan menebak password.
Alur Login: Cari, Verifikasi, Buat Session #
Login yang benar selalu menempuh tiga langkah. Pertama, aplikasi mencari baris pengguna berdasarkan email — biasanya lewat kolom berindeks UNIQUE hasil chapter Registrasi Pengguna
. Kedua, aplikasi memanggil password_verify() untuk membandingkan password mentah dari form dengan hash tersimpan; fungsi inilah satu-satunya cara yang benar, bukan perbandingan string biasa ataupun meng-hash input lalu membandingkan hash (salt acak membuat hasil hash selalu berbeda). Ketiga, bila verifikasi lolos, aplikasi membuat session untuk request-request berikutnya; bila gagal, aplikasi mengembalikan satu pesan error yang sama untuk semua penyebab.
Urutan ketiganya penting untuk keamanan. Pencarian user dan verifikasi password boleh gagal kapan pun, tetapi respons ke pengguna tidak boleh membedakan penyebab kegagalan itu. Kita lihat wujudnya langsung pada kode berikut.
Verifikasi Kredensial dengan SQLite #
Fungsi attemptLogin() di bawah ini menerima koneksi PDO lalu menjalankan tiga langkah tadi. Perhatikan hash dummy yang diverifikasi pada jalur “email tidak dikenal” — fungsinya akan kita bahas setelah output:
<?php
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL
)');
$pdo->prepare('INSERT INTO users (email, password_hash) VALUES (?, ?)')
->execute(['[email protected]', password_hash('rahasia-kuat-123', PASSWORD_DEFAULT)]);
// Hash dummy untuk menyeragamkan durasi jalur "email tidak dikenal"
$dummyHash = password_hash('password-yang-tidak-dipakai', PASSWORD_DEFAULT);
function attemptLogin(PDO $pdo, string $email, string $password, string $dummyHash): array
{
$stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE email = ?');
$stmt->execute([$email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if ($user === false) {
password_verify($password, $dummyHash); // "bakar" durasi yang sama
return ['sukses' => false, 'pesan' => 'Email atau password salah.'];
}
if (!password_verify($password, $user['password_hash'])) {
return ['sukses' => false, 'pesan' => 'Email atau password salah.'];
}
return ['sukses' => true, 'user_id' => (int) $user['id']];
}
$benar = attemptLogin($pdo, '[email protected]', 'rahasia-kuat-123', $dummyHash);
echo 'Kredensial benar: ' . var_export($benar['sukses'], true) . "\n";
echo 'user_id: ' . $benar['user_id'] . "\n";
$salah = attemptLogin($pdo, '[email protected]', 'password-salah', $dummyHash);
$asing = attemptLogin($pdo, '[email protected]', 'rahasia-kuat-123', $dummyHash);
echo 'Password salah: ' . var_export($salah['sukses'], true) . "\n";
echo 'Email tidak terdaftar: ' . var_export($asing['sukses'], true) . "\n";
echo 'Pesan kedua jalur identik: ' . var_export($salah['pesan'] === $asing['pesan'], true) . "\n";Output yang Dihasilkan:
Kredensial benar: true
user_id: 1
Password salah: false
Email tidak terdaftar: false
Pesan kedua jalur identik: trueMari kita bedah perilaku fungsi tersebut:
- Pencarian user (baris 3-5 dalam fungsi): prepared statement dengan placeholder
?mencegah SQL injection sebagaimana dibahas pada chapter database. Hasilnyafalsebila email tidak ditemukan. - Jalur email tidak dikenal: fungsi tetap memanggil
password_verify()terhadap hash dummy sebelum mengembalikan error. Tanpa baris ini, jalur “email tidak dikenal” puluhan milidetik lebih cepat daripada jalur “password salah” — dan penyerang bisa memetakan daftar email terdaftar hanya dengan mengukur waktu respons. - Pesan identik: baris terakhir output membuktikan
Email atau password salah.dikembalikan untuk kedua jenis kegagalan.
Menyimpan Status Login di Session #
Setelah kredensial lolos, aplikasi harus “mengingat” status login lintas request — tugas yang diselesaikan session, sebagaimana dibahas pada chapter Sessions
. Praktik terbaiknya: simpan identitas minimal (user_id saja), dan panggil session_regenerate_id(true) tepat setelah login sukses agar ID session lama — yang mungkin sudah bocor atau ditanam penyerang — tidak berlaku lagi di dunia setelah login:
<?php
$pdo = new PDO('sqlite::memory:');
$pdo->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT, password_hash TEXT)');
$pdo->prepare('INSERT INTO users (id, email, password_hash) VALUES (?, ?, ?)')
->execute([42, '[email protected]', password_hash('rahasia-kuat-123', PASSWORD_DEFAULT)]);
$dir = sys_get_temp_dir() . '/php-kursus-sesi';
if (!is_dir($dir)) {
mkdir($dir);
}
session_save_path($dir);
session_id('demo-login-flow');
@unlink($dir . '/sess_demo-login-flow');
$log = [];
// "Request 1": pemrosesan form login
session_start();
$stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE email = ?');
$stmt->execute(['[email protected]']);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if ($user !== false && password_verify('rahasia-kuat-123', $user['password_hash'])) {
session_regenerate_id(true); // cegah session fixation
$_SESSION['user_id'] = (int) $user['id'];
$log[] = 'Login sukses: user_id ' . $_SESSION['user_id'] . ' disimpan di session';
} else {
$log[] = 'Login gagal';
}
session_write_close();
// "Request 2": halaman mana pun setelah login
session_start();
$log[] = 'Request 2 membaca user_id: ' . ($_SESSION['user_id'] ?? 'tidak ada');
$log[] = 'Sudah login: ' . var_export(isset($_SESSION['user_id']), true);
session_write_close();
echo implode("\n", $log), "\n";Output yang Dihasilkan:
Login sukses: user_id 42 disimpan di session
Request 2 membaca user_id: 42
Sudah login: trueRequest kedua tidak menyentuh password sama sekali — cukup membaca $_SESSION yang dimuat dari penyimpanan berdasarkan ID session di cookie browser. Demo memakai session_id() tetap dan pola log tertunda agar bisa dijalankan deterministik di CLI; mekanisme session_regenerate_id() sendiri sudah kita bedah pada chapter Session Regeneration
. Alur lengkapnya — resolusi current user hingga guard per halaman — menjadi fokus chapter Session-Based Authentication
.
Sketsa handler di aplikasi nyata memperlihatkan di mana semua bagian ini menempel, sekaligus titik integrasi remember-me:
<?php
// Sketsa handler POST /login pada aplikasi web nyata
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$email = strtolower(trim($_POST['email'] ?? ''));
$password = $_POST['password'] ?? '';
$hasil = attemptLogin($pdo, $email, $password, $dummyHash);
if ($hasil['sukses']) {
session_regenerate_id(true);
$_SESSION['user_id'] = $hasil['user_id'];
if (isset($_POST['remember'])) {
// Terbitkan token remember-me dan kirim sebagai cookie
// berumur panjang - dibahas pada chapter Remember-Me
}
header('Location: /dashboard');
exit;
}
// $hasil['pesan'] ("Email atau password salah.") dirender ulang di form
}Logout dengan session_unset dan session_destroy #
Logout harus membersihkan dua sisi: data $_SESSION di memori request berjalan dan data tersimpan di server. session_unset() mengosongkan array; session_destroy() menghapus file session dari penyimpanan. Keduanya dipanggil berurutan seperti berikut:
<?php
$dir = sys_get_temp_dir() . '/php-kursus-sesi';
if (!is_dir($dir)) {
mkdir($dir);
}
session_save_path($dir);
session_id('demo-logout-flow');
@unlink($dir . '/sess_demo-logout-flow');
$log = [];
// Login singkat untuk mengisi session demo
session_start();
$_SESSION['user_id'] = 42;
$_SESSION['keranjang'] = ['buku' => 1];
session_write_close();
// "Request logout"
session_start();
$log[] = 'Sebelum logout: ' . count($_SESSION) . ' elemen session';
session_unset();
$log[] = 'Setelah session_unset(): ' . count($_SESSION) . ' elemen';
session_destroy();
$log[] = 'File session di server masih ada: ' . var_export(file_exists($dir . '/sess_demo-logout-flow'), true);
echo implode("\n", $log), "\n";Output yang Dihasilkan:
Sebelum logout: 2 elemen session
Setelah session_unset(): 0 elemen
File session di server masih ada: falseOutput menunjukkan pembagian tugas yang tegas: elemen session berubah dari 2 ke 0 oleh session_unset(), lalu file penyimpanannya lenyap oleh session_destroy(). Perlu dicatat bahwa session_destroy() tidak menghapus cookie PHPSESSID di browser, sehingga logout yang tuntas juga menimpa cookie tersebut dengan kedaluwarsa di masa lalu.
Logout dari Semua Perangkat #
“Logout di semua perangkat” menuntut kemampuan mencabut session yang hidup di browser lain — sesuatu yang tidak bisa dilakukan session_destroy() karena fungsi tersebut hanya menjangkau session milik request yang sedang berjalan. Solusi umumnya adalah menyimpan batas waktu pada data user: kolom sessions_invalid_before berisi stempel waktu, dan setiap request membandingkan waktu login session dengan stempel tersebut. Session yang login-nya lebih tua dari batas ditolak dan dihancurkan:
<?php
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('CREATE TABLE users (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL,
sessions_invalid_before TEXT NOT NULL DEFAULT \'1970-01-01 00:00:00\'
)');
$pdo->prepare('INSERT INTO users (id, email) VALUES (?, ?)')->execute([42, '[email protected]']);
$dir = sys_get_temp_dir() . '/php-kursus-sesi';
if (!is_dir($dir)) {
mkdir($dir);
}
session_save_path($dir);
session_id('demo-logout-all');
@unlink($dir . '/sess_demo-logout-all');
$log = [];
// "Request 1" (misal: laptop): login pukul 08:00
session_start();
session_regenerate_id(true);
$_SESSION['user_id'] = 42;
$_SESSION['login_pada'] = '2026-01-15 08:00:00';
session_write_close();
// Diperiksa pada setiap request: apakah session masih lebih baru dari batas?
function sessionMasihSah(PDO $pdo, int $userId, string $loginPada): bool
{
$stmt = $pdo->prepare('SELECT sessions_invalid_before FROM users WHERE id = ?');
$stmt->execute([$userId]);
$invalidBefore = $stmt->fetchColumn();
return $loginPada > $invalidBefore;
}
// "Request 2": masih sah
session_start();
$log[] = 'Sebelum logout semua: ' . var_export(sessionMasihSah($pdo, 42, $_SESSION['login_pada']), true);
session_write_close();
// Pengguna memilih "logout dari semua perangkat" pukul 10:00
$pdo->prepare('UPDATE users SET sessions_invalid_before = ? WHERE id = ?')
->execute(['2026-01-15 10:00:00', 42]);
// "Request 3" di laptop: session pukul 08:00 kini lebih tua dari batas
session_start();
if (!sessionMasihSah($pdo, 42, $_SESSION['login_pada'] ?? '')) {
session_unset();
session_destroy();
$log[] = 'Sesudah logout semua: session ditolak, dipaksa login ulang';
} else {
$log[] = 'Sesudah logout semua: session masih sah';
}
echo implode("\n", $log), "\n";Output yang Dihasilkan:
Sebelum logout semua: true
Sesudah logout semua: session ditolak, dipaksa login ulangPerbandingan string berformat Y-m-d H:i:s berlaku benar karena urutan leksikografisnya sama dengan urutan kronologis. Mekanisme yang sama persis dipakai saat password diganti atau di-reset — semua session lama dinyatakan tidak sah sekaligus — sehingga pola ini akan muncul lagi pada chapter Password Reset
. Untuk pencabutan yang lebih granular (per perangkat, dengan daftar perangkat yang bisa dilihat pengguna), aplikasi besar biasanya memindahkan penyimpanan session ke database atau Redis.
Rate Limiting Percobaan Login #
Fungsi attemptLogin() yang kita tulis tidak membedakan penyerang dari pengguna lupa password — keduanya diberi kesempatan mencoba tanpa batas. Rate limiting menutup celah itu dengan mencatat setiap percobaan gagal, lalu menolak percobaan berikutnya bila sudah melewati ambang dalam jendela waktu tertentu. Skema tabelnya sederhana:
CREATE TABLE login_attempts (
email TEXT NOT NULL,
ip_address TEXT NOT NULL,
attempted_at TEXT NOT NULL
);
CREATE INDEX idx_login_attempts_email ON login_attempts (email, attempted_at);Alur pemeriksaannya berjalan sebelum pencarian user: hitung baris untuk pasangan email dan IP dalam 15 menit terakhir; bila jumlahnya sudah lima atau lebih, kembalikan pesan “Terlalu banyak percobaan. Coba lagi dalam 15 menit.” tanpa menyentuh tabel users sama sekali. Penempatan di depan pencarian membuat jalur yang diblokir juga tidak membocorkan keberadaan email lewat timing. Praktik lanjutan yang umum: penundaan progresif antar percobaan, pemblokiran sementara per akun dan per IP sekaligus, CAPTCHA setelah beberapa kegagalan, dan pembersihan baris lama secara berkala. Kombinasi rate limiting dan hash yang kuat dari chapter Password Hashing
membuat menebak password menjadi usaha yang lambat dan bising.
Ringkasan #
Login menempuh tiga langkah — cari user berdasarkan email, verifikasi dengan password_verify(), buat session dengan session_regenerate_id(true) — dan mengembalikan satu pesan error generik untuk semua jenis kegagalan, ditambah hash dummy agar timing juga tidak membocorkan apa pun. Logout membersihkan $_SESSION dan penyimpanan server, sementara logout semua perangkat menandai batas waktu sessions_invalid_before pada data user. Rate limiting di depan alur login menjadi rem terakhir. Bab berikutnya membangun di atas session tersebut secara penuh: resolusi current user dan guard autentikasi pada chapter Session-Based Authentication
.