Lima bagian sebelumnya menghasilkan banyak janji: pesan login yang tidak membocorkan email terdaftar, session id yang berganti setelah login, token reset yang hanya hidup satu jam dan sekali pakai, serta izin yang menolak secara default. Janji-janji itu kita buktikan satu per satu lewat demo yang dibaca dengan mata — dan di situlah letak masalahnya: pembacaan manual tidak berulang sendiri setiap kali kode berubah. Sifat keamanan harus diuji, bukan diasumsikan; kode yang hari ini aman bisa diam-diam menjadi tidak aman besok karena satu refactor yang tidak sengaja membuang session_regenerate_id(). Pada bagian penutup ini kita menghadapkan seluruh janji itu ke meja pengadilan: delapan test PHPUnit yang selesai dalam sekitar dua setengah detik, masing-masing dengan database :memory: segar, jam yang dibekukan, dan sudut pandang seorang penyerang.
Sifat Keamanan yang Layak Diuji #
Pengujian berpikiman penyerang dimulai dari satu pertanyaan: apa yang bisa diketahui atau dilakukan pihak yang tidak berhak? Materi lengkap tentang ancaman autentikasi ada pada chapter Authentication Security ; tabel berikut memetakan setiap sifat yang kita klaim ke cara mengujinya:
| Sifat yang dijanjikan | Ancaman yang ditutup | Uji di suite |
|---|---|---|
| Pesan galat login generik | Enumerasi email (menebak alamat terdaftar) | Dua kegagalan beda sebab, assertSame pesannya |
| Session id beregenerasi saat login | Session fixation | Boolean “id berubah” + id lama kosong, id baru sah |
| Kata sandi ter-hash dan terverifikasi | Kebocoran database (hash, bukan sandi) | Roundtrip password_verify() dan attemptLogin() |
| Token reset sekali pakai | Pemakaian ulang tautan bekas | Pemakaian kedua melempar exception, token terhapus |
| Token reset berkedaluwarsa satu jam | Tautan tua yang dicuri | Jam beku dimajukan 61 menit, sandi tidak berubah |
| RBAC menolak secara default | Aksi liar, peran palsu, salah ketik | Matriks versi string dan gerbang requireRole() |
Dua baris pertama menutup kebocoran informasi; tiga baris berikutnya menutup pemakaian ulang kredensial; baris terakhir menutup eskalasi hak. Perhatikan bahwa tidak ada satu baris pun yang menguji “kode berjalan tanpa error” — semuanya menguji bahwa penolakan terjadi tepat di tempat yang dijanjikan. Struktur fixture-nya pun bagian dari desain: setUp() membangun database :memory: baru per test lewat schema.sql Bagian 1 ditambah kedua migrasi (kolom role dari Bagian 4, tabel password_resets dari Bagian 5), sehingga setiap test berangkat dari keadaan aplikasi yang telah melewati lima bagian — persis seperti produksi.
tests/bootstrap.php — Memuat Class Antar Bagian #
Sebelum test pertama bisa berjalan, PHPUnit perlu tahu di mana class proyek hidup. Pada proyek nyata jawabannya satu baris: require 'vendor/autoload.php' setelah Composer memetakan namespace ke folder src/. Pada bundle kursus ini class justru tersebar di folder src/ milik masing-masing bagian agar setiap halaman bagian mandiri, sehingga berkas test harus menyeberang antar folder bagian yang bersaudara di bawah sistem-autentikasi/. Berikut isi lengkap tests/bootstrap.php sebagaimana tersedia pada berkas sumber di bagian bawah halaman:
<?php
declare(strict_types=1);
/**
* Bootstrap PHPUnit untuk suite keamanan sistem autentikasi.
*
* Tugas berkas ini satu: memuat seluruh class proyek SEKALI sebelum
* test pertama berjalan, lewat path relatif antar folder bagian.
*
* Pada proyek nyata, class hidup dalam satu folder src/ dengan
* namespace dan autoloader Composer, sehingga berkas semacam ini
* cukup memanggil require 'vendor/autoload.php'. Pada bundle kursus
* ini, class justru tersebar di folder src/ milik masing-masing
* bagian agar setiap halaman bagian mandiri - folder bagian
* bersaudara di bawah sistem-autentikasi/, maka dari tests/ kita
* naik dua tingkat ('/../..') lalu masuk ke bagian tujuan.
* require_once menjaga berkas yang sama tidak dimuat dua kali bila
* suite diperluas dengan test file lain.
*/
require_once __DIR__ . '/../../bagian-2-registrasi-dan-password-hashing/src/UserRepository.php';
require_once __DIR__ . '/../../bagian-2-registrasi-dan-password-hashing/src/RegistrationService.php';
require_once __DIR__ . '/../../bagian-3-login-dan-session/src/AuthService.php';
require_once __DIR__ . '/../../bagian-4-role-dan-permissions/src/AccessControl.php';
require_once __DIR__ . '/../../bagian-5-password-reset/src/PasswordResetService.php';
/**
* Path absolut ke schema.sql Bagian 1 - satu-satunya sumber struktur
* database. setUp() tiap test membaca dan mengeksekusi berkas ini
* pada koneksi :memory: yang baru, lalu menjalankan kedua migrasi
* (kolom role dari Bagian 4, tabel password_resets dan kolom
* penanda dari Bagian 5) sehingga keadaan schema selalu identik
* dengan aplikasi yang telah melewati lima bagian.
*/
const SCHEMA_AUTENTIKASI = __DIR__ . '/../../bagian-1-user-schema/schema.sql';Dua keputusan pada berkas tersebut layak dijelaskan. Path __DIR__ . '/../../...' naik dua tingkat dari tests/ — satu tingkat ke folder bagian ini, satu tingkat lagi ke sistem-autentikasi/ — lalu turun ke bagian tujuan; pola require antar bagian yang sama dengan demo-demo sebelumnya, hanya dari kedalaman folder yang berbeda. Konstanta SCHEMA_AUTENTIKASI memusatkan lokasi schema di satu tempat, sehingga bila struktur folder berubah, hanya bootstrap yang perlu disesuaikan — bukan setiap test. Pemisahan bootstrap dari berkas test sendiri memisahkan urusan wiring (memuat class) dari urusan perilaku (menguji janji); PHPUnit menyediakan titik sambungnya lewat opsi --bootstrap.
AuthSecurityTest — Delapan Test dari Sudut Penyerang #
Satu berkas test berisi class AuthSecurityTest dengan delapan method test* — setiap baris pada tabel di atas punya penjaganya, ditambah satu test untuk penolakan registrasi duplikat. Berikut isi lengkap tests/AuthSecurityTest.php sebagaimana tersedia pada berkas sumber di bagian bawah halaman:
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Suite keamanan sistem autentikasi: pesan galat generik login,
* regenerasi session id, token reset sekali pakai dan kedaluwarsa,
* sampai RBAC yang menolak secara default - semuanya diuji dari
* sudut pandang penyerang, bukan dari sudut pandang penulis kode.
*
* Class dimuat oleh tests/bootstrap.php, bukan oleh berkas ini;
* jalankan dengan:
* php phpunit.phar --no-progress tests/AuthSecurityTest.php --bootstrap tests/bootstrap.php
*/
final class AuthSecurityTest extends TestCase
{
private PDO $pdo;
private UserRepository $users;
private RegistrationService $registrasi;
private AuthService $auth;
private AccessControl $akses;
protected function setUp(): void
{
// Database :memory: baru untuk setiap test: terisolasi, cepat,
// dan schema.sql Bagian 1 selalu menghasilkan fixture identik
// (Budi, Siti, Dewi dengan kata sandi demo masing-masing).
$this->pdo = new PDO('sqlite::memory:');
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->pdo->exec((string) file_get_contents(SCHEMA_AUTENTIKASI));
// Keadaan schema akhir proyek: kolom role (Bagian 4) serta
// tabel password_resets dan kolom penanda (Bagian 5). Keduanya
// idempoten - persis seperti saat aplikasi sungguhan bersiap.
AccessControl::pasangKolomPeran($this->pdo);
PasswordResetService::pasangSkema($this->pdo);
$this->users = new UserRepository($this->pdo);
$this->registrasi = new RegistrationService($this->users);
$this->auth = new AuthService($this->users);
$this->akses = new AccessControl($this->pdo);
}
protected function tearDown(): void
{
// Session adalah state global proses: pastikan test berikutnya
// mulai dari keadaan mati, apa pun nasib test yang sedang ini.
if (session_status() === PHP_SESSION_ACTIVE) {
$_SESSION = [];
session_destroy();
}
}
public function testRegistrasiDuplikatDitolakDenganPesanRamah(): void
{
try {
$this->registrasi->register('[email protected]', 'sandi-palsu-2026', 'Budi Palsu');
$this->fail('register() seharusnya melempar EmailSudahDipakaiException.');
} catch (EmailSudahDipakaiException $e) {
// Pesan untuk manusia, bukan jejak internal database.
$this->assertSame(
'Email [email protected] sudah terdaftar. Gunakan email lain atau masuk dengan email ini.',
$e->getMessage()
);
}
// Penolakan tidak meninggalkan jejak: jumlah baris tetap tiga.
$this->assertSame(3, $this->jumlahPengguna());
}
public function testLoginGagalMengembalikanPesanGenerikYangIdentik(): void
{
// Dua sebab kegagalan yang berbeda nasib di dalam: email yang
// tidak terdaftar, dan kata sandi yang salah untuk email sah.
$userEmailHantu = $this->auth->attemptLogin('[email protected]', 'sandi-coba-2026');
$userKataSandiSalah = $this->auth->attemptLogin('[email protected]', 'sandi-salah-2026');
// API tidak membedakan keduanya: sama-sama null tanpa penjelasan.
$this->assertNull($userEmailHantu);
$this->assertNull($userKataSandiSalah);
// Controller menerjemahkan null menjadi SATU pesan generik -
// pola cobaLogin() pada Bagian 3. assertSame membandingkan
// byte per byte: tidak boleh berbeda satu spasi pun.
$pesan = fn (?array $user): string => $user === null
? 'Email atau kata sandi salah.'
: 'Login sukses.';
$this->assertSame('Email atau kata sandi salah.', $pesan($userEmailHantu));
$this->assertSame($pesan($userEmailHantu), $pesan($userKataSandiSalah));
}
public function testLoginMeregenerasiSessionIdDanMembuangSesiLama(): void
{
// Penyerang menancapkan id sesi yang dia ketahui sebelum
// korban login - skenario session fixation pada Bagian 3.
$idSerangan = 'test-sesi-fiksasi-006';
$this->mulaiSesiBersih($idSerangan);
$user = $this->auth->attemptLogin('[email protected]', 'sandi-siti-2026');
$this->assertNotNull($user);
$this->auth->login($user);
$idBaru = session_id();
// Hanya fakta struktural yang diuji: id berubah atau tidak.
// Nilai id tidak pernah dicetak ke laporan test - itu
// kredensial, bukan data diagnostik.
$idBerubah = $idBaru !== $idSerangan;
$this->assertTrue($idBerubah);
$this->assertSame(2, $_SESSION['user_id'] ?? null);
// Request berikutnya membawa id serangan: file sesinya sudah
// dihapus session_regenerate_id(true), jadi tidak berisi login.
$this->gantiSesi($idSerangan);
$this->assertArrayNotHasKey('user_id', $_SESSION);
// Request sah membawa id baru: masih login sebagai Siti.
$this->gantiSesi($idBaru);
$this->assertSame(2, $_SESSION['user_id'] ?? null);
$this->assertSame('Siti Nurhaliza', $this->auth->currentUser()['nama']);
}
public function testKataSandiBaruBisaMasukDanYangSalahDitolak(): void
{
$ratna = $this->registrasi->register(
'[email protected]',
'sandi-ratna-2026',
'Ratna Wijaya'
);
// Registrasi menormalkan email ke huruf kecil.
$this->assertSame('[email protected]', $ratna['email']);
// Hash yang tersimpan memang memverifikasi sandi yang benar.
$this->assertTrue(password_verify('sandi-ratna-2026', (string) $ratna['password_hash']));
$this->assertFalse(password_verify('sandi-ratna-2027', (string) $ratna['password_hash']));
// Roundtrip lewat login: sandi benar masuk, sandi lain ditolak.
$masuk = $this->auth->attemptLogin('[email protected]', 'sandi-ratna-2026');
$this->assertNotNull($masuk);
$this->assertSame('Ratna Wijaya', $masuk['nama']);
$this->assertNull($this->auth->attemptLogin('[email protected]', 'sandi-salah-2026'));
}
public function testTokenResetSekaliPakaiDanKataSandiBerganti(): void
{
$jam = new JamBeku('2026-02-02 08:00:00');
$reset = new PasswordResetService($this->pdo, $this->users, $jam->now(...));
// Pukul 08:00 Siti meminta reset; pukul 08:05 token dipakai.
$token = $reset->mintaReset('[email protected]');
$this->assertSame(1, $reset->jumlahTokenTersimpan());
$jam->maju('PT5M');
$reset->jalankanReset($token, 'rahasia-baru-2026');
// Token terhapus: pemakaian ulang menemukan baris yang tiada.
$this->assertSame(0, $reset->jumlahTokenTersimpan());
try {
$reset->jalankanReset($token, 'sudi-dipakai-ulang-2026');
$this->fail('Token bekas seharusnya melempar TokenResetTidakSahException.');
} catch (TokenResetTidakSahException $e) {
$this->assertSame('Token reset tidak dikenal atau sudah dipakai.', $e->getMessage());
}
// Sandi lama mati, sandi baru hidup - dibuktikan dari luar
// lewat attemptLogin(), bukan lewat janji class-nya sendiri.
$this->assertNull($this->auth->attemptLogin('[email protected]', 'sandi-siti-2026'));
$masuk = $this->auth->attemptLogin('[email protected]', 'rahasia-baru-2026');
$this->assertNotNull($masuk);
$this->assertSame(2, $masuk['id']);
}
public function testTokenResetKedaluwarsaDitolakDanSandiTidakBerubah(): void
{
$jam = new JamBeku('2026-02-02 12:00:00');
$reset = new PasswordResetService($this->pdo, $this->users, $jam->now(...));
// Token diminta pukul 12:00, berlaku sampai 13:00.
$token = $reset->mintaReset('[email protected]');
// Tautan baru dibuka pukul 13:01 - lewat satu menit dari batas.
$jam->maju('PT61M');
try {
$reset->jalankanReset($token, 'sandi-pengganti-2026');
$this->fail('Token kedaluwarsa seharusnya melempar TokenResetKedaluwarsaException.');
} catch (TokenResetKedaluwarsaException $e) {
$this->assertSame('Token reset melewati masa berlaku satu jam.', $e->getMessage());
}
// Transaction memastikan tidak ada setengah pekerjaan: sandi
// lama Dewi tetap berlaku, dan baris token sengaja dibiarkan
// agar penolakan tidak membocorkan keberadaan token (Bagian 5).
$this->assertNotNull($this->auth->attemptLogin('[email protected]', 'sandi-dewi-2026'));
$this->assertSame(1, $reset->jumlahTokenTersimpan());
}
public function testRbacMenolakAksiDanPeranYangTidakDikenal(): void
{
// Matriks versi string: aksi dan peran apa adanya, tanpa
// menyentuh database - sumber kebenaran izin diuji langsung.
$this->assertTrue($this->akses->can('admin', 'pengguna.hapus'));
$this->assertFalse($this->akses->can('user', 'pengguna.hapus'));
$this->assertFalse($this->akses->can('editor', 'pengguna.ubahPeran'));
// Aksi salah ketik ditolak bahkan untuk admin, dan peran yang
// tidak dikenal ditolak untuk aksi apa pun: deny by default.
$this->assertFalse($this->akses->can('admin', 'pengguna.hapuss'));
$this->assertFalse($this->akses->can('superuser', 'pengguna.hapus'));
// Versi int: seluruh akun seed memangku peran default 'user'.
$this->assertFalse($this->akses->can(1, 'log.audit.lihat'));
$this->assertTrue($this->akses->can(1, 'profil.lihat'));
// Id yang tidak dikenal tidak punya peran, jadi ditolak.
$this->assertFalse($this->akses->can(999, 'profil.lihat'));
}
public function testGerbangPeranMenolakYangTidakBerhak(): void
{
// Akun seed memangku peran 'user': gerbang admin menolaknya.
try {
$this->akses->requireRole(1, 'admin');
$this->fail('requireRole() seharusnya melempar UnauthorizedException untuk user.');
} catch (UnauthorizedException $e) {
$this->assertSame('Akses ditolak: peran tidak mencukupi.', $e->getMessage());
}
// Budi mencoba mengangkat dirinya sendiri menjadi admin -
// ditolak paling awal, sebelum validasi peran maupun target.
try {
$this->akses->ubahPeran(1, 1, 'admin');
$this->fail('ubahPeran() oleh user seharusnya melempar UnauthorizedException.');
} catch (UnauthorizedException $e) {
$this->assertSame('Hanya admin yang boleh mengubah peran pengguna.', $e->getMessage());
}
// Bootstrap admin pertama: satu UPDATE langsung di luar sistem
// izin (pola Bagian 4), lalu gerbang yang sama terbuka.
$this->pdo->exec("UPDATE users SET role = 'admin' WHERE email = '[email protected]'");
$this->akses->requireRole(1, 'admin');
$this->assertTrue($this->akses->can(1, 'pengguna.hapus'));
// Admin mengangkat Siti menjadi editor: boleh pada aksi baru
// itu, tetap tidak boleh pada aksi khusus admin.
$siti = $this->akses->ubahPeran(1, 2, 'editor');
$this->assertSame('editor', $siti['role']);
$this->assertTrue($this->akses->can(2, 'log.audit.lihat'));
$this->assertFalse($this->akses->can(2, 'pengguna.hapus'));
}
/**
* Mulai session dengan id tetap dari keadaan yang dijamin bersih:
* sisa file ber-id sama dari eksekusi sebelumnya dihancurkan
* lebih dulu sehingga hasil test tidak bergantung pada riwayat.
*/
private function mulaiSesiBersih(string $idSesi): void
{
if (session_status() === PHP_SESSION_ACTIVE) {
$_SESSION = [];
session_destroy();
}
session_id($idSesi);
session_start();
$_SESSION = [];
session_destroy();
session_id($idSesi);
session_start();
}
/**
* Ganti session aktif ke id lain - meniru request berikutnya yang
* membawa cookie ber-id berbeda.
*/
private function gantiSesi(string $idSesi): void
{
if (session_status() === PHP_SESSION_ACTIVE) {
session_write_close();
}
$_SESSION = [];
session_id($idSesi);
session_start();
}
private function jumlahPengguna(): int
{
return (int) $this->pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();
}
}Dua penolakan pertama: pesan yang tidak membocorkan #
Tiga keputusan pada dua test pertama adalah inti gaya suite ini. Pertama, assertSame() untuk pesan, bukan assertEquals() — dua pesan “mirip” tidak cukup, karena penyerang membandingkan respons byte per byte, maka test pun membandingkan byte per byte; selisih satu spasi, satu kapital, atau string kosong versus null semuanya gagal. Kedua, pola try/catch + $this->fail() ketika keadaan setelah penolakan ikut diperiksa: expectException() menghentikan test tepat saat exception terlempar, sedangkan pola try/catch memungkinkan test berlanjut — setelah menangkap EmailSudahDipakaiException, test masih menghitung jumlah baris pengguna untuk membuktikan penolakan tidak meninggalkan jejak setengah jadi. Ketiga, pesan exception yang diuji adalah pesan untuk pengguna, bukan jejak internal database. Materi lengkap tentang memilih assertion ada di chapter Assertions
.
Regenerasi session tanpa membocorkan id #
Test session fixation adalah satu-satunya test yang menyentuh state di luar database, dan karenanya punya disiplin tersendiri. Tidak ada satu baris pun yang mencetak atau membandingkan isi session_id() sebagai teks — assertion hanya menagih boolean $idBerubah, karena session id adalah kredensial dan laporan test yang gagal pun tidak boleh menyerahkannya ke layar. Helper gantiSesi() meniru request HTTP berikutnya: session_write_close() menulis data session ke file dan menutupnya, lalu session_id() lain ditanam seolah-olah browser membawa cookie yang berbeda. Karena session_regenerate_id(true) telah menghapus file ber-id serangan, request ber-id itu menemukan sesi kosong — itulah dua assertion tengah — sementara id baru masih menyimpan user_id, dan currentUser() membuktikannya dengan membaca ulang dari database. Helper mulaiSesiBersih() menghancurkan sisa file ber-id sama dari eksekusi sebelumnya, sehingga test ini menghasilkan kesimpulan yang sama berapa pun kali dijalankan; tearDown() memastikan keadaan itu tidak bocor ke test berikutnya.
Token sekali pakai dan kedaluwarsa dengan jam beku #
Dua test token menolak berpuas diri dengan exception saja, dan keduanya berdiri di atas jam yang disuntikkan: masa berlaku satu jam mustahil diuji dengan jam sungguhan, tetapi dengan JamBeku kita cukup memajukan jarum 61 menit dalam satu baris. Test sekali pakai membuktikan efek sampingnya dari luar — kata sandi lama Siti mati dan kata sandi baru hidup, dilihat lewat attemptLogin(), bukan lewat klaim class bahwa updateHash() dipanggil. Test kedaluwarsa memeriksa keutuhan transaction setelah kegagalan: kata sandi Dewi tidak berubah, dan baris token justru dibiarkan hidup — keputusan sadar dari Bagian 5, karena menghapus baris saat menolak akan memberi penyerang cara membedakan “token pernah ada” dari “token tidak pernah ada”. Nilai token sendiri tidak pernah muncul di assertion maupun laporan; yang diperiksa hanyalah jumlah barisnya.
RBAC menolak secara default #
Dua test terakhir menjaga matriks izin dari dua sisi. Test matriks menguji can() versi string — tabel izin apa adanya, tanpa menyentuh database — termasuk kasus-kasus yang tidak ada di data seed, karena justru di sanalah penolakan default diuji: aksi salah ketik ditolak bahkan untuk admin, peran yang tidak dikenal ditolak untuk aksi apa pun, dan id pengguna yang tidak ada tidak punya peran. Test gerbang lalu menguji requireRole() dan ubahPeran() dari empat posisi: pengguna biasa ditolak dengan pesan yang sama, promosi diri sendiri ditolak paling awal (sebelum validasi peran maupun keberadaan target), admin pertama lolos, dan target yang dinaikkan perannya memperoleh hak barunya tanpa memperoleh hak khusus admin.
Baris UPDATE users SET role = 'admin' pada test gerbang adalah teka-teki chicken-and-egg dari Bagian 4: sistem izin tidak bisa mengangkat admin pertamanya sendiri, karena mengangkat admin membutuhkan admin. Satu UPDATE langsung di luar sistem izin menyelesaikannya — di produksi dijalankan sekali oleh pemasang aplikasi; di test, dijalankan di tengah method untuk membuktikan bahwa gerbang yang tadinya menolak kini terbuka bagi id yang sama. Test kelima yang tidak mendapat seksi sendiri, testKataSandiBaruBisaMasukDanYangSalahDitolak, menguji roundtrip kata sandi: mendaftarkan akun baru, memastikan email dinormalkan ke huruf kecil, membuktikan hash tersimpan memverifikasi sandi yang benar (password_verify() true) dan menolak sandi yang salah (false), lalu menutup dengan attemptLogin() dari luar.
Suite dalam PHP Polos #
Sebelum memanggil PHPUnit, mari bongkar dulu bahwa test hanyalah kode PHP biasa: bandingkan hasil dengan harapan, berteriak bila tidak cocok. Harness berikut menjalankan inti suite memakai if dan echo saja — simpan sebagai uji-keamanan.php di dalam folder bagian ini, lalu jalankan dengan php uji-keamanan.php:
<?php
require __DIR__ . '/../bagian-2-registrasi-dan-password-hashing/src/UserRepository.php';
require __DIR__ . '/../bagian-2-registrasi-dan-password-hashing/src/RegistrationService.php';
require __DIR__ . '/../bagian-3-login-dan-session/src/AuthService.php';
require __DIR__ . '/../bagian-4-role-dan-permissions/src/AccessControl.php';
require __DIR__ . '/../bagian-5-password-reset/src/PasswordResetService.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'));
AccessControl::pasangKolomPeran($pdo);
PasswordResetService::pasangSkema($pdo);
// Assertion polos: kondisi di parameter pertama, nama perilaku di kedua.
function cek(bool $kondisi, string $nama): void
{
echo $kondisi ? 'LULUS' : 'GAGAL';
echo " - $nama\n";
}
$users = new UserRepository($pdo);
$auth = new AuthService($users);
$akses = new AccessControl($pdo);
$jam = new JamBeku('2026-02-03 09:00:00');
$reset = new PasswordResetService($pdo, $users, $jam->now(...));
// == Enumerasi: dua sebab kegagalan login, satu pesan ==
$hantu = $auth->attemptLogin('[email protected]', 'sandi-coba-2026');
$salah = $auth->attemptLogin('[email protected]', 'sandi-salah-2026');
$pesan = fn (?array $user): string => $user === null
? 'Email atau kata sandi salah.'
: 'Login sukses.';
// == Session fixation: seluruh operasi session dikerjakan SEBELUM echo ==
session_id('demo-sesi-qa-011');
session_start();
$_SESSION = [];
session_destroy();
session_id('demo-sesi-qa-011');
session_start();
$siti = $auth->attemptLogin('[email protected]', 'sandi-siti-2026');
$auth->login($siti);
$idBaru = session_id();
$idBerubah = $idBaru !== 'demo-sesi-qa-011';
session_write_close();
$_SESSION = [];
session_id('demo-sesi-qa-011');
session_start();
$idLamaBerisiLogin = isset($_SESSION['user_id']);
session_write_close();
$_SESSION = [];
session_id($idBaru);
session_start();
$sesiBaruSah = $auth->currentUser() !== null;
$_SESSION = [];
session_destroy();
// == Token sekali pakai dan kedaluwarsa (jam beku) ==
$token = $reset->mintaReset('[email protected]');
$jam->maju('PT5M');
$reset->jalankanReset($token, 'rahasia-baru-2026');
$tokenHabis = $reset->jumlahTokenTersimpan() === 0;
$tokenLagi = $reset->mintaReset('[email protected]');
$jam->maju('PT61M');
$tokenLewatJamDitolak = false;
$sandiLamaUtuh = false;
try {
$reset->jalankanReset($tokenLagi, 'sandi-pengganti-2026');
} catch (TokenResetKedaluwarsaException $e) {
$tokenLewatJamDitolak = true;
$sandiLamaUtuh = $auth->attemptLogin('[email protected]', 'sandi-budi-2026') !== null;
}
// == RBAC: penolakan sebagai keadaan default ==
$userTidakBolehHapus = $akses->can('user', 'pengguna.hapus') === false;
$aksiSalahKetikDitolak = $akses->can('admin', 'pengguna.hapuss') === false;
$peranAsingDitolak = $akses->can('superuser', 'pengguna.hapus') === false;
// == Baru sekarang mencetak hasil ==
echo "== Enumerasi login ==\n";
cek($hantu === null && $salah === null, 'kedua percobaan gagal dengan null');
cek($pesan($hantu) === $pesan($salah), 'pesan untuk kedua sebab identik');
echo "\n== Session fixation ==\n";
cek($idBerubah, 'session id berubah setelah login');
cek(!$idLamaBerisiLogin, 'id lama tidak lagi menyimpan login');
cek($sesiBaruSah, 'id baru tetap membawa login sah');
echo "\n== Token reset ==\n";
cek($tokenHabis, 'token terhapus setelah dipakai (sekali pakai)');
try {
$reset->jalankanReset($token, 'sudi-dipakai-ulang-2026');
cek(false, 'token bekas ditolak');
} catch (TokenResetTidakSahException $e) {
cek(true, 'token bekas ditolak');
}
cek($tokenLewatJamDitolak, 'token lewat satu jam ditolak');
cek($sandiLamaUtuh, 'kata sandi lama tetap berlaku setelah penolakan');
echo "\n== RBAC ==\n";
cek($userTidakBolehHapus, 'peran user tidak bisa menghapus pengguna');
cek($aksiSalahKetikDitolak, 'aksi salah ketik ditolak untuk admin');
cek($peranAsingDitolak, 'peran tidak dikenal ditolak');Output yang Dihasilkan:
== Enumerasi login ==
LULUS - kedua percobaan gagal dengan null
LULUS - pesan untuk kedua sebab identik
== Session fixation ==
LULUS - session id berubah setelah login
LULUS - id lama tidak lagi menyimpan login
LULUS - id baru tetap membawa login sah
== Token reset ==
LULUS - token terhapus setelah dipakai (sekali pakai)
LULUS - token bekas ditolak
LULUS - token lewat satu jam ditolak
LULUS - kata sandi lama tetap berlaku setelah penolakan
== RBAC ==
LULUS - peran user tidak bisa menghapus pengguna
LULUS - aksi salah ketik ditolak untuk admin
LULUS - peran tidak dikenal ditolakDua belas baris LULUS itu setara dengan dua belas assertion hijau, dan sama seperti di suite, seluruh operasi session dikerjakan sebelum echo pertama. PHPUnit menaungi test dari masalah ini lewat output buffering, tetapi skrip polos tidak punya naungan — begitu output terkirim, session_start() dan session_regenerate_id() menolak bekerja. Itulah mengapa harness menghitung semua boolean lebih dulu ($idBerubah, $idLamaBerisiLogin, $sesiBaruSah) dan mencetaknya belakangan; nilai session id maupun token tidak pernah disentuh echo.
Menjalankan Suite dengan PHPUnit #
Saatnya versi sungguhannya. Dari folder bagian-6-security-testing, arahkan PHPUnit ke berkas test beserta bootstrap-nya — cara memasang PHPUnit sendiri dibahas pada chapter Menginstal PHPUnit
:
php phpunit.phar --no-progress tests/AuthSecurityTest.php --bootstrap tests/bootstrap.phpKami menjalankan perintah tersebut pada lingkungan bagian ini (PHP 8.4.23, PHPUnit 12.5.39) dan berikut output lengkapnya:
PHPUnit 12.5.39 by Sebastian Bergmann and contributors.
Runtime: PHP 8.4.23
Time: 00:02.509, Memory: 28.05 MB
OK (8 tests, 41 assertions)Baris OK (8 tests, 41 assertions) adalah ringkasan yang paling sering Anda baca: delapan test dijalankan, empat puluh satu assertion dievaluasi, semuanya lulus, exit code 0. Angka Time sekitar dua setengah detik — lebih lambat dari suite proyek lain karena suite ini jujur menjalankan bcrypt (hash dan verify) belasan kali, dan bcrypt memang sengaja mahal; itu harga keamanan, bukan pemborosan. Angka Time dan Memory bervariasi antar mesin dan antar jalan, jadi jangan dikhawatirkan; opsi --no-progress kita pakai agar output stabil tanpa animasi progress bar. Dengan --testdox, PHPUnit juga mencetak nama test sebagai kalimat perilaku — Registrasi duplikat ditolak dengan pesan ramah, Login gagal mengembalikan pesan generik yang identik — nama method yang kita pilih memang ditulis untuk dibaca seperti kontrak.
Filsafat Pengujian Berpikiman Penyerang #
Empat prinsip membedakan pengujian keamanan dari pengujian fungsional biasa, dan keempatnya terlihat di suite ini. Pertama, uji janjinya, bukan implementasinya: kata sandi yang berganti dibuktikan lewat attemptLogin() dari luar, bukan dengan mengintip apakah updateHash() terpanggil — implementasi boleh berubah selama janji di depan publik tetap. Kedua, rahasia bukan data test: hash, token, dan session id tidak pernah dicetak, dibandingkan sebagai teks, atau dimasukkan ke laporan; assertion hanya menagih fakta struktural — berubah atau tidak, ada atau tidak, berapa jumlahnya. Test yang gagal pun tidak boleh membocorkan kredensial ke layar CI.
Ketiga, determinisme adalah prasyarat, bukan bonus: jam dibekukan lewat JamBeku, session memakai id tetap dengan blok pembersihan, dan database dilahirkan ulang per test. Test keamanan yang hasilnya “kadang-kadang” akan diabaikan orang — dan test yang diabaikan sama berbahayanya dengan tidak ada test. Keempat, test ditulis untuk masa depan: suite bukan seremoni penutup proyek, melainkan penjaga regression yang berdiri di antara refactor berikutnya dan janji-janji yang sudah diucapkan. Setiap kali seseorang menyentuh AuthService atau PasswordResetService, delapan test ini mengulang seluruh argumen keamanan proyek dalam hitungan detik. Pola umum menulis test PHPUnit dibahas tuntas pada chapter Menulis PHPUnit Tests
, dan strategi fixture database ringan pada Database Testing
.
Rekap Proyek: Enam Bagian #
Proyek sistem autentikasi tuntas. Enam bagian membentuk satu narasi dari data sampai penjaganya:
| Bagian | Artefak utama | Penjaga di suite ini |
|---|---|---|
| 1. User Schema | schema.sql: tabel users, constraint UNIQUE NOCASE, seed | Fixture :memory: yang selalu identik di setUp() |
| 2. Registrasi dan Hashing | UserRepository, RegistrationService | Test duplikat ramah + roundtrip password_verify() |
| 3. Login dan Session | AuthService dengan DUMMY_HASH dan regenerasi | Test pesan generik identik + regenerasi session id |
| 4. Role dan Permissions | AccessControl dengan matriks izin | Test deny-by-default + gerbang requireRole() |
| 5. Password Reset | PasswordResetService dan JamBeku | Test token sekali pakai + kedaluwarsa |
| 6. Security Testing | tests/ — suite ini | Berjaga atas kelima baris di atasnya |
Tidak ada satu baris pun dari class produksi Bagian 1-5 yang berubah untuk kepentingan pengujian — semua yang dibutuhkan suite ternyata sudah tersedia sejak awal: PDO yang disuntikkan, schema idempoten, waktu sebagai closure, dan penolakan yang diekspresikan sebagai exception bermakna. Itu bukan kebetulan melainkan konsekuensi dari desain yang berorientasi perilaku, dan inilah pelajaran terbesar proyek ini: kode yang mudah diuji hampir selalu kode yang batasnya jelas.
Ringkasan #
Delapan test, empat puluh satu assertion, sekitar dua setengah detik: seluruh janji keamanan yang kita bangun selama lima bagian kini diperiksa ulang setiap kali kode berubah. Sistem autentikasi ini lengkap sebagai fondasi aplikasi nyata — schema berconstraint, registrasi berhash, session beregenerasi, izin terpusat, reset ber-token, dan suite berjaga. Dengan selesainya proyek ini, Anda siap memasuki proyek berikutnya yang menuntut fondasi tersebut: Aplikasi Keuangan Pribadi , tempat autentikasi, transaksi database, dan validasi bertemu dalam satu aplikasi utuh.
Referensi #
- PHPUnit 12.5 — dokumentasi resmi
- Assertions — Course PHP
- Authentication Security — Course PHP
- OWASP Cheat Sheet Series
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
tests/AuthSecurityTest.php
Unduh berkas<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Suite keamanan sistem autentikasi: pesan galat generik login,
* regenerasi session id, token reset sekali pakai dan kedaluwarsa,
* sampai RBAC yang menolak secara default - semuanya diuji dari
* sudut pandang penyerang, bukan dari sudut pandang penulis kode.
*
* Class dimuat oleh tests/bootstrap.php, bukan oleh berkas ini;
* jalankan dengan:
* php phpunit.phar --no-progress tests/AuthSecurityTest.php --bootstrap tests/bootstrap.php
*/
final class AuthSecurityTest extends TestCase
{
private PDO $pdo;
private UserRepository $users;
private RegistrationService $registrasi;
private AuthService $auth;
private AccessControl $akses;
protected function setUp(): void
{
// Database :memory: baru untuk setiap test: terisolasi, cepat,
// dan schema.sql Bagian 1 selalu menghasilkan fixture identik
// (Budi, Siti, Dewi dengan kata sandi demo masing-masing).
$this->pdo = new PDO('sqlite::memory:');
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->pdo->exec((string) file_get_contents(SCHEMA_AUTENTIKASI));
// Keadaan schema akhir proyek: kolom role (Bagian 4) serta
// tabel password_resets dan kolom penanda (Bagian 5). Keduanya
// idempoten - persis seperti saat aplikasi sungguhan bersiap.
AccessControl::pasangKolomPeran($this->pdo);
PasswordResetService::pasangSkema($this->pdo);
$this->users = new UserRepository($this->pdo);
$this->registrasi = new RegistrationService($this->users);
$this->auth = new AuthService($this->users);
$this->akses = new AccessControl($this->pdo);
}
protected function tearDown(): void
{
// Session adalah state global proses: pastikan test berikutnya
// mulai dari keadaan mati, apa pun nasib test yang sedang ini.
if (session_status() === PHP_SESSION_ACTIVE) {
$_SESSION = [];
session_destroy();
}
}
public function testRegistrasiDuplikatDitolakDenganPesanRamah(): void
{
try {
$this->registrasi->register('[email protected]', 'sandi-palsu-2026', 'Budi Palsu');
$this->fail('register() seharusnya melempar EmailSudahDipakaiException.');
} catch (EmailSudahDipakaiException $e) {
// Pesan untuk manusia, bukan jejak internal database.
$this->assertSame(
'Email [email protected] sudah terdaftar. Gunakan email lain atau masuk dengan email ini.',
$e->getMessage()
);
}
// Penolakan tidak meninggalkan jejak: jumlah baris tetap tiga.
$this->assertSame(3, $this->jumlahPengguna());
}
public function testLoginGagalMengembalikanPesanGenerikYangIdentik(): void
{
// Dua sebab kegagalan yang berbeda nasib di dalam: email yang
// tidak terdaftar, dan kata sandi yang salah untuk email sah.
$userEmailHantu = $this->auth->attemptLogin('[email protected]', 'sandi-coba-2026');
$userKataSandiSalah = $this->auth->attemptLogin('[email protected]', 'sandi-salah-2026');
// API tidak membedakan keduanya: sama-sama null tanpa penjelasan.
$this->assertNull($userEmailHantu);
$this->assertNull($userKataSandiSalah);
// Controller menerjemahkan null menjadi SATU pesan generik -
// pola cobaLogin() pada Bagian 3. assertSame membandingkan
// byte per byte: tidak boleh berbeda satu spasi pun.
$pesan = fn (?array $user): string => $user === null
? 'Email atau kata sandi salah.'
: 'Login sukses.';
$this->assertSame('Email atau kata sandi salah.', $pesan($userEmailHantu));
$this->assertSame($pesan($userEmailHantu), $pesan($userKataSandiSalah));
}
public function testLoginMeregenerasiSessionIdDanMembuangSesiLama(): void
{
// Penyerang menancapkan id sesi yang dia ketahui sebelum
// korban login - skenario session fixation pada Bagian 3.
$idSerangan = 'test-sesi-fiksasi-006';
$this->mulaiSesiBersih($idSerangan);
$user = $this->auth->attemptLogin('[email protected]', 'sandi-siti-2026');
$this->assertNotNull($user);
$this->auth->login($user);
$idBaru = session_id();
// Hanya fakta struktural yang diuji: id berubah atau tidak.
// Nilai id tidak pernah dicetak ke laporan test - itu
// kredensial, bukan data diagnostik.
$idBerubah = $idBaru !== $idSerangan;
$this->assertTrue($idBerubah);
$this->assertSame(2, $_SESSION['user_id'] ?? null);
// Request berikutnya membawa id serangan: file sesinya sudah
// dihapus session_regenerate_id(true), jadi tidak berisi login.
$this->gantiSesi($idSerangan);
$this->assertArrayNotHasKey('user_id', $_SESSION);
// Request sah membawa id baru: masih login sebagai Siti.
$this->gantiSesi($idBaru);
$this->assertSame(2, $_SESSION['user_id'] ?? null);
$this->assertSame('Siti Nurhaliza', $this->auth->currentUser()['nama']);
}
public function testKataSandiBaruBisaMasukDanYangSalahDitolak(): void
{
$ratna = $this->registrasi->register(
'[email protected]',
'sandi-ratna-2026',
'Ratna Wijaya'
);
// Registrasi menormalkan email ke huruf kecil.
$this->assertSame('[email protected]', $ratna['email']);
// Hash yang tersimpan memang memverifikasi sandi yang benar.
$this->assertTrue(password_verify('sandi-ratna-2026', (string) $ratna['password_hash']));
$this->assertFalse(password_verify('sandi-ratna-2027', (string) $ratna['password_hash']));
// Roundtrip lewat login: sandi benar masuk, sandi lain ditolak.
$masuk = $this->auth->attemptLogin('[email protected]', 'sandi-ratna-2026');
$this->assertNotNull($masuk);
$this->assertSame('Ratna Wijaya', $masuk['nama']);
$this->assertNull($this->auth->attemptLogin('[email protected]', 'sandi-salah-2026'));
}
public function testTokenResetSekaliPakaiDanKataSandiBerganti(): void
{
$jam = new JamBeku('2026-02-02 08:00:00');
$reset = new PasswordResetService($this->pdo, $this->users, $jam->now(...));
// Pukul 08:00 Siti meminta reset; pukul 08:05 token dipakai.
$token = $reset->mintaReset('[email protected]');
$this->assertSame(1, $reset->jumlahTokenTersimpan());
$jam->maju('PT5M');
$reset->jalankanReset($token, 'rahasia-baru-2026');
// Token terhapus: pemakaian ulang menemukan baris yang tiada.
$this->assertSame(0, $reset->jumlahTokenTersimpan());
try {
$reset->jalankanReset($token, 'sudi-dipakai-ulang-2026');
$this->fail('Token bekas seharusnya melempar TokenResetTidakSahException.');
} catch (TokenResetTidakSahException $e) {
$this->assertSame('Token reset tidak dikenal atau sudah dipakai.', $e->getMessage());
}
// Sandi lama mati, sandi baru hidup - dibuktikan dari luar
// lewat attemptLogin(), bukan lewat janji class-nya sendiri.
$this->assertNull($this->auth->attemptLogin('[email protected]', 'sandi-siti-2026'));
$masuk = $this->auth->attemptLogin('[email protected]', 'rahasia-baru-2026');
$this->assertNotNull($masuk);
$this->assertSame(2, $masuk['id']);
}
public function testTokenResetKedaluwarsaDitolakDanSandiTidakBerubah(): void
{
$jam = new JamBeku('2026-02-02 12:00:00');
$reset = new PasswordResetService($this->pdo, $this->users, $jam->now(...));
// Token diminta pukul 12:00, berlaku sampai 13:00.
$token = $reset->mintaReset('[email protected]');
// Tautan baru dibuka pukul 13:01 - lewat satu menit dari batas.
$jam->maju('PT61M');
try {
$reset->jalankanReset($token, 'sandi-pengganti-2026');
$this->fail('Token kedaluwarsa seharusnya melempar TokenResetKedaluwarsaException.');
} catch (TokenResetKedaluwarsaException $e) {
$this->assertSame('Token reset melewati masa berlaku satu jam.', $e->getMessage());
}
// Transaction memastikan tidak ada setengah pekerjaan: sandi
// lama Dewi tetap berlaku, dan baris token sengaja dibiarkan
// agar penolakan tidak membocorkan keberadaan token (Bagian 5).
$this->assertNotNull($this->auth->attemptLogin('[email protected]', 'sandi-dewi-2026'));
$this->assertSame(1, $reset->jumlahTokenTersimpan());
}
public function testRbacMenolakAksiDanPeranYangTidakDikenal(): void
{
// Matriks versi string: aksi dan peran apa adanya, tanpa
// menyentuh database - sumber kebenaran izin diuji langsung.
$this->assertTrue($this->akses->can('admin', 'pengguna.hapus'));
$this->assertFalse($this->akses->can('user', 'pengguna.hapus'));
$this->assertFalse($this->akses->can('editor', 'pengguna.ubahPeran'));
// Aksi salah ketik ditolak bahkan untuk admin, dan peran yang
// tidak dikenal ditolak untuk aksi apa pun: deny by default.
$this->assertFalse($this->akses->can('admin', 'pengguna.hapuss'));
$this->assertFalse($this->akses->can('superuser', 'pengguna.hapus'));
// Versi int: seluruh akun seed memangku peran default 'user'.
$this->assertFalse($this->akses->can(1, 'log.audit.lihat'));
$this->assertTrue($this->akses->can(1, 'profil.lihat'));
// Id yang tidak dikenal tidak punya peran, jadi ditolak.
$this->assertFalse($this->akses->can(999, 'profil.lihat'));
}
public function testGerbangPeranMenolakYangTidakBerhak(): void
{
// Akun seed memangku peran 'user': gerbang admin menolaknya.
try {
$this->akses->requireRole(1, 'admin');
$this->fail('requireRole() seharusnya melempar UnauthorizedException untuk user.');
} catch (UnauthorizedException $e) {
$this->assertSame('Akses ditolak: peran tidak mencukupi.', $e->getMessage());
}
// Budi mencoba mengangkat dirinya sendiri menjadi admin -
// ditolak paling awal, sebelum validasi peran maupun target.
try {
$this->akses->ubahPeran(1, 1, 'admin');
$this->fail('ubahPeran() oleh user seharusnya melempar UnauthorizedException.');
} catch (UnauthorizedException $e) {
$this->assertSame('Hanya admin yang boleh mengubah peran pengguna.', $e->getMessage());
}
// Bootstrap admin pertama: satu UPDATE langsung di luar sistem
// izin (pola Bagian 4), lalu gerbang yang sama terbuka.
$this->pdo->exec("UPDATE users SET role = 'admin' WHERE email = '[email protected]'");
$this->akses->requireRole(1, 'admin');
$this->assertTrue($this->akses->can(1, 'pengguna.hapus'));
// Admin mengangkat Siti menjadi editor: boleh pada aksi baru
// itu, tetap tidak boleh pada aksi khusus admin.
$siti = $this->akses->ubahPeran(1, 2, 'editor');
$this->assertSame('editor', $siti['role']);
$this->assertTrue($this->akses->can(2, 'log.audit.lihat'));
$this->assertFalse($this->akses->can(2, 'pengguna.hapus'));
}
/**
* Mulai session dengan id tetap dari keadaan yang dijamin bersih:
* sisa file ber-id sama dari eksekusi sebelumnya dihancurkan
* lebih dulu sehingga hasil test tidak bergantung pada riwayat.
*/
private function mulaiSesiBersih(string $idSesi): void
{
if (session_status() === PHP_SESSION_ACTIVE) {
$_SESSION = [];
session_destroy();
}
session_id($idSesi);
session_start();
$_SESSION = [];
session_destroy();
session_id($idSesi);
session_start();
}
/**
* Ganti session aktif ke id lain - meniru request berikutnya yang
* membawa cookie ber-id berbeda.
*/
private function gantiSesi(string $idSesi): void
{
if (session_status() === PHP_SESSION_ACTIVE) {
session_write_close();
}
$_SESSION = [];
session_id($idSesi);
session_start();
}
private function jumlahPengguna(): int
{
return (int) $this->pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();
}
}
tests/bootstrap.php
Unduh berkas<?php
declare(strict_types=1);
/**
* Bootstrap PHPUnit untuk suite keamanan sistem autentikasi.
*
* Tugas berkas ini satu: memuat seluruh class proyek SEKALI sebelum
* test pertama berjalan, lewat path relatif antar folder bagian.
*
* Pada proyek nyata, class hidup dalam satu folder src/ dengan
* namespace dan autoloader Composer, sehingga berkas semacam ini
* cukup memanggil require 'vendor/autoload.php'. Pada bundle kursus
* ini, class justru tersebar di folder src/ milik masing-masing
* bagian agar setiap halaman bagian mandiri - folder bagian
* bersaudara di bawah sistem-autentikasi/, maka dari tests/ kita
* naik dua tingkat ('/../..') lalu masuk ke bagian tujuan.
* require_once menjaga berkas yang sama tidak dimuat dua kali bila
* suite diperluas dengan test file lain.
*/
require_once __DIR__ . '/../../bagian-2-registrasi-dan-password-hashing/src/UserRepository.php';
require_once __DIR__ . '/../../bagian-2-registrasi-dan-password-hashing/src/RegistrationService.php';
require_once __DIR__ . '/../../bagian-3-login-dan-session/src/AuthService.php';
require_once __DIR__ . '/../../bagian-4-role-dan-permissions/src/AccessControl.php';
require_once __DIR__ . '/../../bagian-5-password-reset/src/PasswordResetService.php';
/**
* Path absolut ke schema.sql Bagian 1 - satu-satunya sumber struktur
* database. setUp() tiap test membaca dan mengeksekusi berkas ini
* pada koneksi :memory: yang baru, lalu menjalankan kedua migrasi
* (kolom role dari Bagian 4, tabel password_resets dan kolom
* penanda dari Bagian 5) sehingga keadaan schema selalu identik
* dengan aplikasi yang telah melewati lima bagian.
*/
const SCHEMA_AUTENTIKASI = __DIR__ . '/../../bagian-1-user-schema/schema.sql';