Registrasi mengisi tabel users; login adalah gerbang yang memeriksa setiap orang yang mengaku salah satu penggunanya. Di sinilah keputusan keamanan paling banyak salah kaprah: pesan galat yang membedakan “email tidak terdaftar” dari “kata sandi salah”, waktu respons yang membocorkan hal yang sama, dan session id yang tidak pernah diganti setelah login. Pada bagian ini kita membangun AuthService dengan tiga operasi — attemptLogin(), login(), dan logout() — lalu membuktikan ketiganya dari CLI murni memakai session id tetap, tanpa satu pun web server dinyalakan.
Alur Login dan Pesan Galat yang Generik #
Mari kita tetapkan aturan mainnya sebelum menulis kode. Alur login pada attemptLogin() harus memenuhi tiga persyaratan:
- Pesan galat generik. Kegagalan karena email tidak dikenal dan kegagalan karena kata sandi salah harus menghasilkan respons yang tidak bisa dibedakan dari luar. Pesan “Email atau kata sandi salah.” adalah satu-satunya jawaban untuk keduanya.
- Waktu respons yang sama. Bukan hanya teks pesannya — durasi eksekusinya pun harus seragam. Tanpa perlakuan khusus, email yang tidak dikenal selesai dalam kurang dari satu milidetik (sekadar query yang tidak menemukan baris), sedangkan kata sandi yang salah menempuh
password_verify()bcrypt selama ±200 milidetik. Penyerang cukup mengukur durasi untuk memetakan email mana saja yang terdaftar — teknik yang disebut user enumeration. - Session id baru setiap login sukses. Sebelum identitas pengguna dicatat, session id wajib diganti dan session lama dihapus. Tanpa ini, skenario session fixation menjadi mungkin: penyerang menanam satu session id yang sudah dia ketahui (misalnya lewat tautan), korban login dengan id tersebut, dan penyerang memakai id yang sama untuk masuk sebagai korban.
Jawaban atas persyaratan kedua ada pada hash bcrypt tiruan. Ketika email tidak ditemukan, attemptLogin() tetap menjalankan password_verify() terhadap sebuah hash konstanta yang bukan milik siapa pun, sehingga kedua jalur kegagalan sama-sama menempuh satu kali verifikasi bcrypt. Keduanya mengembalikan null — bukan penjelasan mengapa — dan pemanggil-lah yang menampilkan pesan generik yang sama.
Class AuthService #
Berikut isi lengkap src/AuthService.php sebagaimana tersedia pada berkas sumber di bagian bawah halaman:
<?php
declare(strict_types=1);
/**
* Layanan autentikasi: percobaan login, login, logout, dan
* pengguna yang sedang login.
*
* Class ini bekerja di atas session PHP. Panggil session_start()
* terlebih dahulu (sebelum output apa pun dikirim) sebelum
* memakai login() atau logout().
*/
final class AuthService
{
/**
* Hash bcrypt konstan untuk email yang tidak dikenal.
*
* Saat email tidak ada di database, password_verify() tetap
* dijalankan terhadap hash ini sehingga waktu respons kasus
* "email tidak dikenal" sama dengan kasus "kata sandi salah"
* dan penyerang tidak bisa memetakan email mana yang terdaftar.
*/
private const DUMMY_HASH = '$2y$12$PfL7jPnppPaiqPxco4SiIeksYy7QV6l03gbbMPYkmKiwUvJJ8ErSC';
public function __construct(private UserRepository $users)
{
}
/**
* Coba masuk dengan pasangan email dan kata sandi.
*
* Mengembalikan baris pengguna bila berhasil, null bila gagal.
* Penyebab kegagalan sengaja tidak dibedakan: email yang tidak
* dikenal dan kata sandi yang salah diperlakukan sama persis
* supaya pesan galat tidak membocorkan email mana yang terdaftar.
*
* @return array<string, mixed>|null
*/
public function attemptLogin(string $email, string $password): ?array
{
$user = $this->users->cariEmail($email);
if ($user === null) {
// Email tidak dikenal: tetap jalankan verify terhadap
// hash tiruan agar durasi responsnya sama.
password_verify($password, self::DUMMY_HASH);
return null;
}
if (!password_verify($password, $user['password_hash'])) {
return null;
}
return $user;
}
/**
* Catat keberhasilan login ke session.
*
* session_regenerate_id(true) mengganti session id dan menghapus
* session lama - pertahanan terhadap session fixation. Setelah itu
* identitas pengguna disimpan hanya sebagai user_id.
*
* @param array<string, mixed> $user baris pengguna hasil attemptLogin()
*/
public function login(array $user): void
{
if (session_status() !== PHP_SESSION_ACTIVE) {
throw new RuntimeException(
'session_start() harus dipanggil sebelum login().'
);
}
session_regenerate_id(true);
$_SESSION['user_id'] = (int) $user['id'];
}
/**
* Keluar: hapus seluruh data session, lalu hancurkan session-nya.
*/
public function logout(): void
{
$_SESSION = [];
if (session_status() === PHP_SESSION_ACTIVE) {
session_destroy();
}
}
/**
* Pengguna yang sedang login, dibaca ulang dari database;
* null bila tidak ada sesi login yang sah.
*
* @return array<string, mixed>|null
*/
public function currentUser(): ?array
{
$id = $_SESSION['user_id'] ?? null;
if ($id === null) {
return null;
}
return $this->users->cariId((int) $id);
}
}Keputusan-keputusan penting pada class tersebut perlu dibedah satu per satu:
- Konstanta
DUMMY_HASHmenyimpan satu hash bcrypt asli milik tidak siapa-siapa. Nilainya dihasilkan sekali denganpassword_hash()lalu ditempel sebagai konstanta, sama seperti seed pada Bagian 1 — yang penting adalah strukturnya yang sah sehinggapassword_verify()menempuh kerja bcrypt penuh, bukan hasilnya. attemptLogin()mengembalikan?array: baris pengguna bila berhasil,nulluntuk semua jenis kegagalan. Tidak ada kode galat, tidak ada pesan — pemanggil tidak mungkin tidak sengaja membocorkan penyebab kegagalannya, karena memang tidak diberitahu. Pencarian email memakaicariEmail(), yang berkatCOLLATE NOCASEtetap menemukan baris meski pengguna mengetik kapital berbeda saat login.login()memeriksasession_status()lebih dulu dan melemparRuntimeExceptionbila session belum aktif. Tanpa pemeriksaan ini,session_regenerate_id()hanya mengembalikanfalsediam-diam dan demo kita akan berjalan dengan session id yang tidak pernah berganti — kegagalan senyap yang sulit dilacak.session_regenerate_id(true)adalah inti pertahanan fixation: argumenttruememerintahkan PHP menghapus data session lama setelah id baru dibuat, sehingga id lama menjadi kosong dan tidak bisa dipakai siapa pun. Baru setelah itu$_SESSION['user_id']diisi — dan hanyauser_id, bukan seluruh baris pengguna.logout()menghapus$_SESSIONterlebih dahulu, baru memanggilsession_destroy(). Urutan ini penting:session_destroy()hanya menghapus data di penyimpanan, sedangkan array$_SESSIONdi memori tetap berisi nilai lama hingga akhir request; tanpa pengosongan eksplisit, sisa request masih bisa membaca$_SESSION['user_id'].currentUser()memuat ulang data pengguna dari database lewatcariId()pada setiap panggilan, alih-alih menyimpan seluruh baris pengguna di session. Session hanya menyimpan identitas (user_id); data yang bisa berubah — nama, status verifikasi, dan kelak peran — selalu segar dari sumber kebenarannya. Bilauser_iddi session menunjuk baris yang sudah dihapus,cariId()mengembalikannulldan sesi otomatis dianggap tidak sah.
Walkthrough Login dan Logout #
Sekarang kita jalankan alur lengkapnya. Dua hal tentang cara demo ini ditulis perlu dijelaskan lebih dulu, karena keduanya menyimulasikan perilaku web di dalam CLI. Pertama, browser sungguhan menerima session id dari cookie; CLI tidak punya cookie, jadi kita menancapkan id tetap lewat session_id('demo-sesi-login-001') sebelum session_start() — ibarat satu browser yang membawa cookie yang sama. Kedua, seluruh operasi session dikerjakan lebih dulu dan hasilnya baru dicetak di akhir, meniru aturan web bahwa session_start() harus dipanggil sebelum output apa pun dikirim. Fungsi cobaLogin() di skrip berikut berperan sebagai controller mini: panggil attemptLogin(), cetak pesan generik bila gagal, catat login bila berhasil.
<?php
require __DIR__ . '/../bagian-2-registrasi-dan-password-hashing/src/UserRepository.php';
require __DIR__ . '/src/AuthService.php';
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-user-schema/schema.sql'));
$auth = new AuthService(new UserRepository($pdo));
function cobaLogin(AuthService $auth, string $email, string $sandi): string
{
$user = $auth->attemptLogin($email, $sandi);
if ($user === null) {
return 'Email atau kata sandi salah.';
}
$auth->login($user);
return "Login sukses: {$user['nama']} <{$user['email']}>";
}
// Mulai dari keadaan bersih dengan id sesi tetap agar demo deterministik.
session_id('demo-sesi-login-001');
session_start();
$_SESSION = [];
session_destroy();
session_id('demo-sesi-login-001');
session_start();
$pesan1 = cobaLogin($auth, '[email protected]', 'sandi-salah-2026');
$pesan2 = cobaLogin($auth, '[email protected]', 'sandi-salah-2026');
$pesan3 = cobaLogin($auth, '[email protected]', 'sandi-budi-2026');
$sesudahLogin = $auth->currentUser();
$auth->logout();
$sesudahLogout = $auth->currentUser();
$sesiAktif = session_status() === PHP_SESSION_ACTIVE;
echo "Percobaan 1 (email tidak dikenal) : $pesan1\n";
echo "Percobaan 2 (kata sandi salah) : $pesan2\n";
echo "Percobaan 3 (kombinasi benar) : $pesan3\n";
echo "\ncurrentUser() setelah login : {$sesudahLogin['nama']} (id {$sesudahLogin['id']})\n";
echo 'currentUser() setelah logout : ' . var_export($sesudahLogout, true) . "\n";
echo 'Session masih aktif : ' . ($sesiAktif ? 'ya' : 'tidak') . "\n";Output yang Dihasilkan:
Percobaan 1 (email tidak dikenal) : Email atau kata sandi salah.
Percobaan 2 (kata sandi salah) : Email atau kata sandi salah.
Percobaan 3 (kombinasi benar) : Login sukses: Budi Santoso <[email protected]>
currentUser() setelah login : Budi Santoso (id 1)
currentUser() setelah logout : NULL
Session masih aktif : tidakPercobaan 1 dan 2 menghasilkan teks yang persis sama — padahal penyebabnya berbeda, dan itulah inti pesan generik. Percobaan 3 masuk sebagai Budi, dan currentUser() mengembalikan baris lengkapnya karena user_id di session menunjuk id 1. Setelah logout(), currentUser() bernilai NULL dan session sudah tidak aktif: kedua jejak login bersih, tidak ada sisa user_id yang bisa dipakai request berikutnya. Blok pembersihan di awal skrip ($_SESSION = [] lalu session_destroy()) memastikan demo selalu mulai dari nol meskipun file session berjudul sama tertinggal dari eksekusi sebelumnya.
Membuktikan Regenerasi Session Id #
Percobaan berikut memerankan skenario session fixation secara utuh. “Penyerang” memaksa satu session id yang dia ketahui, korban login pada sesi itu, lalu kita periksa dua hal: apakah id berganti, dan apakah id lama masih menyimpan status login. Untuk mensimulasikan request HTTP terpisah, skrip menutup sesi dengan session_write_close() lalu membukanya kembali dengan id tertentu — persis seperti dua request yang membawa cookie berbeda:
<?php
require __DIR__ . '/../bagian-2-registrasi-dan-password-hashing/src/UserRepository.php';
require __DIR__ . '/src/AuthService.php';
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-user-schema/schema.sql'));
$auth = new AuthService(new UserRepository($pdo));
// "Penyerang" memaksa satu id sesi yang dia ketahui (session fixation).
$idSerangan = 'demo-sesi-fiksasi-777';
session_id($idSerangan);
session_start();
$_SESSION = [];
session_destroy();
session_id($idSerangan);
session_start();
$user = $auth->attemptLogin('[email protected]', 'sandi-siti-2026');
$auth->login($user);
$idSesiBaru = session_id();
// Request berikutnya membawa id lama milik "penyerang".
session_write_close();
$_SESSION = [];
session_id($idSerangan);
session_start();
$idLamaBerisiLogin = isset($_SESSION['user_id']);
// Request yang sah membawa id baru hasil regenerasi.
session_write_close();
$_SESSION = [];
session_id($idSesiBaru);
session_start();
$penggunaSesiBaru = $auth->currentUser();
echo "Login sukses sebagai : {$user['nama']}\n";
echo 'Session id berubah setelah login : ' . ($idSerangan !== $idSesiBaru ? 'ya' : 'tidak') . "\n";
echo 'Id lama masih menyimpan login : ' . ($idLamaBerisiLogin ? 'ya' : 'tidak') . "\n";
echo "Id baru tetap sah : {$penggunaSesiBaru['nama']} (id {$penggunaSesiBaru['id']})\n";Output yang Dihasilkan:
Login sukses sebagai : Siti Nurhaliza
Session id berubah setelah login : ya
Id lama masih menyimpan login : tidak
Id baru tetap sah : Siti Nurhaliza (id 2)Empat baris output tersebut adalah bukti lengkap pertahanan fixation. Session id berubah dari yang ditanam penyerang ke id baru yang dibuat PHP — kita membandingkan keduanya dengan !== dan hanya boleh melihat hasil boolean-nya. Id lama tidak lagi menyimpan login, karena argument true pada session_regenerate_id(true) menghapus data session lama sekaligus; penyerang yang kembali membawa id lamanya mendapat sesi kosong. Sementara itu, request yang sah membawa id baru dan tetap diterima sebagai Siti. Materi lengkap tentang serangan dan pertahanan ini ada pada chapter Session Fixation
dan Session Regeneration
.
Menyamakan Waktu Respons untuk Email Tak Dikenal #
Pesan generik dan regenerasi id bisa dilihat dari output; pertahanan waktu respons hanya bisa dibuktikan dengan pengukuran. Skrip berikut mengukur durasi dua kasus kegagalan memakai hrtime(), dan karena angkanya bergantung mesin serta bervariasi antar eksekusi, blok ini tidak diverifikasi otomatis — jalankan beberapa kali dan bandingkan kisarannya:
<?php
require __DIR__ . '/../bagian-2-registrasi-dan-password-hashing/src/UserRepository.php';
require __DIR__ . '/src/AuthService.php';
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-user-schema/schema.sql'));
$auth = new AuthService(new UserRepository($pdo));
function ukurMilidetik(callable $proses): float
{
$mulai = hrtime(true);
$proses();
return (hrtime(true) - $mulai) / 1e6;
}
$kasus1 = ukurMilidetik(
static fn (): ?array => $auth->attemptLogin('[email protected]', 'sandi-salah-2026')
);
$kasus2 = ukurMilidetik(
static fn (): ?array => $auth->attemptLogin('[email protected]', 'sandi-salah-2026')
);
printf("Email tidak dikenal : %.1f ms\n", $kasus1);
printf("Kata sandi salah : %.1f ms\n", $kasus2);Pada mesin pengujian, kedua kasus mendarat di kisaran yang sama — sekitar 200 milidetik — karena keduanya menempuh tepat satu kali password_verify() bcrypt. Untuk memahami seberapa besar jurangnya tanpa pertahanan ini, penulis mengukur cariEmail() untuk email yang tidak ada tanpa diikuti verify: kurang dari 0,01 milidetik. Perbedaan lebih dari sepuluh ribu kali lipat itu berarti sinyal waktu hampir seterang pesan error eksplisit — jauh lebih dari cukup bagi penyerang untuk memilah email mana yang terdaftar dari jarak jauh.
Cara Menjalankan Contoh #
Contoh pada bagian ini membutuhkan schema.sql dari Bagian 1, class dari Bagian 2, dan AuthService.php dari bagian ini. Susun foldernya sebagai berikut:
sistem-autentikasi/
├── bagian-1-user-schema/
│ └── schema.sql
├── bagian-2-registrasi-dan-password-hashing/
│ └── src/
│ ├── UserRepository.php
│ └── RegistrationService.php
└── bagian-3-login-dan-session/
├── src/
│ └── AuthService.php
└── uji-login.phpSimpan salah satu walkthrough di atas sebagai uji-login.php di dalam folder bagian-3-login-dan-session, lalu jalankan:
php uji-login.phpSeluruh contoh memakai :memory: dan id sesi tetap, jadi hasilnya sama persis berapa pun kali dijalankan, dan tidak ada berkas yang tersisa. Pada aplikasi web sungguhan, blok session_id('...') digantikan cookie yang dikirim browser, sementara urutan kerjanya tetap sama: session_start() paling awal, baru operasi attemptLogin(), login(), dan logout() — materi chapter Sessions
membahas sisi web-nya secara penuh.
Apa yang Berubah #
Dibandingkan Bagian 2 yang berhenti di registrasi, proyek kini memiliki gerbang masuk yang utuh:
- Berkas
src/AuthService.phpbaru berisi class final dengan empat operasi:attemptLogin(),login(),logout(), dancurrentUser()di atasUserRepositoryyang disuntikkan. - Kegagalan login tidak lagi bisa dibedakan: pesan generik untuk pengguna,
nulluntuk pemanggil, danDUMMY_HASHmenyamakan waktu respons antara email tak dikenal dan kata sandi salah. - Setiap login sukses mengganti session id dan menghapus session lama melalui
session_regenerate_id(true);logout()mengosongkan$_SESSIONsebelum menghancurkan session. currentUser()memuat data segar dari database berdasarkanuser_iddi session, sehingga perubahan data pengguna berlaku seketika tanpa login ulang.
Ringkasan #
Login yang benar ternyata lebih banyak tentang apa yang tidak disampaikan: tidak ada pembedaan penyebab kegagalan, tidak ada perbedaan durasi respons, dan tidak ada session id yang dipakai lebih lama dari yang diperlukan. AuthService mewujudkan ketiganya dengan DUMMY_HASH, pesan generik, dan regenerasi id yang dibuktikan lewat boolean — bukan nilai id — dalam demo ber-id tetap. Pengguna kini bisa mendaftar, masuk, dan keluar dengan aman, tetapi sistem masih memperlakukan semua pengguna sama: belum ada perbedaan antara admin dan pengguna biasa. Bagian 4 menambahkan peran (role) dan permission ke atas fondasi ini.
Referensi #
- session_regenerate_id() — php.net
- Session security — php.net
- password_verify() — php.net
- OWASP Session Management Cheat Sheet
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
src/AuthService.php
Unduh berkas<?php
declare(strict_types=1);
/**
* Layanan autentikasi: percobaan login, login, logout, dan
* pengguna yang sedang login.
*
* Class ini bekerja di atas session PHP. Panggil session_start()
* terlebih dahulu (sebelum output apa pun dikirim) sebelum
* memakai login() atau logout().
*/
final class AuthService
{
/**
* Hash bcrypt konstan untuk email yang tidak dikenal.
*
* Saat email tidak ada di database, password_verify() tetap
* dijalankan terhadap hash ini sehingga waktu respons kasus
* "email tidak dikenal" sama dengan kasus "kata sandi salah"
* dan penyerang tidak bisa memetakan email mana yang terdaftar.
*/
private const DUMMY_HASH = '$2y$12$PfL7jPnppPaiqPxco4SiIeksYy7QV6l03gbbMPYkmKiwUvJJ8ErSC';
public function __construct(private UserRepository $users)
{
}
/**
* Coba masuk dengan pasangan email dan kata sandi.
*
* Mengembalikan baris pengguna bila berhasil, null bila gagal.
* Penyebab kegagalan sengaja tidak dibedakan: email yang tidak
* dikenal dan kata sandi yang salah diperlakukan sama persis
* supaya pesan galat tidak membocorkan email mana yang terdaftar.
*
* @return array<string, mixed>|null
*/
public function attemptLogin(string $email, string $password): ?array
{
$user = $this->users->cariEmail($email);
if ($user === null) {
// Email tidak dikenal: tetap jalankan verify terhadap
// hash tiruan agar durasi responsnya sama.
password_verify($password, self::DUMMY_HASH);
return null;
}
if (!password_verify($password, $user['password_hash'])) {
return null;
}
return $user;
}
/**
* Catat keberhasilan login ke session.
*
* session_regenerate_id(true) mengganti session id dan menghapus
* session lama - pertahanan terhadap session fixation. Setelah itu
* identitas pengguna disimpan hanya sebagai user_id.
*
* @param array<string, mixed> $user baris pengguna hasil attemptLogin()
*/
public function login(array $user): void
{
if (session_status() !== PHP_SESSION_ACTIVE) {
throw new RuntimeException(
'session_start() harus dipanggil sebelum login().'
);
}
session_regenerate_id(true);
$_SESSION['user_id'] = (int) $user['id'];
}
/**
* Keluar: hapus seluruh data session, lalu hancurkan session-nya.
*/
public function logout(): void
{
$_SESSION = [];
if (session_status() === PHP_SESSION_ACTIVE) {
session_destroy();
}
}
/**
* Pengguna yang sedang login, dibaca ulang dari database;
* null bila tidak ada sesi login yang sah.
*
* @return array<string, mixed>|null
*/
public function currentUser(): ?array
{
$id = $_SESSION['user_id'] ?? null;
if ($id === null) {
return null;
}
return $this->users->cariId((int) $id);
}
}