Panel admin Bagian 4 bekerja sempurna — untuk siapa saja yang menemukan URL-nya. Tidak ada yang memeriksa siapa pengirim request; bot penjelajah yang menemukan /admin/tulis bisa menulis artikel sepuasnya. Pada Bagian 5 ini kita menutup pintu itu dengan dua mekanisme yang sering dicampuradukkan tapi berbeda pertanyaannya: authentication menjawab “siapa Anda” (login dengan email dan kata sandi), sedangkan authorization menjawab “bolehkah Anda ke sini” (hanya peran admin yang boleh membuka panel). Tiga berkas baru masuk: src/UserRepository.php untuk data pengguna, src/Auth.php untuk login/logout plus penjaga rute, dan templates/login.php untuk formulir masuknya.
Authentication dan Authorization: Dua Pertanyaan Berbeda #
Satu request ke /admin kini melewati dua gerbang berurutan. Gerbang pertama bertanya apakah request membawa sesi yang sah milik pengguna yang sudah login; gerbang kedua bertanya apakah pengguna itu berperan admin. Urutannya penting: memeriksa peran sebelum tahu identitasnya tidak masuk akal, dan sebaliknya identitas saja tidak cukup bila halamannya memang khusus. Tabel berikut merangkum ketiganya:
| Keadaan pengguna | Gerbang authentication | Gerbang authorization | Respons akhir |
|---|---|---|---|
| Tamu (tanpa sesi) | gagal — belum dikenal | tidak diperiksa | 302 ke /login |
Masuk sebagai editor | lolos | gagal — peran kurang | 403 Akses Ditolak |
Masuk sebagai admin | lolos | lolos | halaman admin (200) |
Kata sandi tidak pernah disimpan apa adanya. Kolom password_hash menampung hasil password_hash()
— algoritma bcrypt bawaan yang menghasilkan string 60 karakter berisi salt acak plus hasil hash-nya. Verifikasi kemudian memakai password_verify()
, satu-satunya cara benar membandingkan sandi dengan hash-nya; perbandingan === biasa tidak akan pernah cocok karena salt membuat setiap hash unik.
Tabel Pengguna #
Skema database bertambah satu tabel. Bentuknya mengikuti kebiasaan tabel users di framework mana pun — email unik untuk login, peran untuk authorization, dan kolom hash:
-- Tambahan Bagian 5 pada schema.sql: tabel pengguna.
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
nama TEXT NOT NULL,
peran TEXT NOT NULL DEFAULT 'editor',
password_hash TEXT NOT NULL,
dibuat_pada TEXT NOT NULL DEFAULT (datetime('now'))
);Untuk pemasangan baru, blok ini digabungkan ke database/schema.sql sehingga Database::siapkan() membangun keduanya sekaligus. Untuk database lama hasil Bagian 3-4, blok yang sama dijalankan sekali sebagai migrasi — IF NOT EXISTS membuatnya aman diulang. Mari kita buktikan strukturnya di database RAM:
<?php
// Tabel users — tambahan schema Bagian 5, dijalankan di database RAM.
$pdo = new PDO('sqlite::memory:', null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
nama TEXT NOT NULL,
peran TEXT NOT NULL DEFAULT 'editor',
password_hash TEXT NOT NULL,
dibuat_pada TEXT NOT NULL DEFAULT (datetime('now'))
)
SQL);
foreach ($pdo->query('PRAGMA table_info(users)') as $kolom) {
printf(
"%-14s %-8s NOT NULL=%s\n",
$kolom['name'],
$kolom['type'],
$kolom['notnull'] === 1 ? 'ya' : 'tidak'
);
}Output yang Dihasilkan:
id INTEGER NOT NULL=tidak
email TEXT NOT NULL=ya
nama TEXT NOT NULL=ya
peran TEXT NOT NULL=ya
password_hash TEXT NOT NULL=ya
dibuat_pada TEXT NOT NULL=yaPerhatikan keanehan kolom id: SQLite melaporkan NOT NULL=tidak padahal primary key tidak mungkin null — itu tampilan khas PRAGMA table_info untuk INTEGER PRIMARY KEY (alias rowid yang terisi otomatis). Kolom email memegang constraint UNIQUE, sehingga pendaftaran ganda ditolak di tingkat database — sama seperti slug pada Bagian 3.
UserRepository dan Penyamaan Kata Sandi #
Mengikuti pola PostRepository, seluruh akses ke tabel users dipusatkan di satu class. Berikut src/UserRepository.php lengkap:
<?php
declare(strict_types=1);
namespace App;
use PDO;
use PDOException;
/**
* UserRepository — lapisan akses data pengguna (Bagian 5).
*
* Menyediakan pembacaan pengguna untuk Auth (cariEmail, cariId) dan
* pembuatan pengguna pertama. Kata sandi TIDAK PERNAH disimpan mentah:
* kolom password_hash diisi hasil password_hash() di dalam create(),
* sehingga pemanggil tidak pernah menyentuh hash secara langsung.
*/
final class UserRepository
{
public function __construct(private readonly PDO $pdo)
{
}
/**
* Cari satu pengguna berdasarkan email-nya (untuk login).
*
* @return array<string,mixed>|null null bila email tidak terdaftar
*/
public function cariEmail(string $email): ?array
{
$stmt = $this->pdo->prepare('SELECT id, email, nama, peran, password_hash, dibuat_pada
FROM users
WHERE email = ?');
$stmt->execute([$email]);
$user = $stmt->fetch();
return $user === false ? null : $user;
}
/**
* Cari satu pengguna berdasarkan id-nya (untuk check() Auth).
*
* @return array<string,mixed>|null null bila id tidak dikenal
*/
public function cariId(int $id): ?array
{
$stmt = $this->pdo->prepare('SELECT id, email, nama, peran, dibuat_pada
FROM users
WHERE id = ?');
$stmt->execute([$id]);
$user = $stmt->fetch();
return $user === false ? null : $user;
}
/**
* Buat pengguna baru dengan kata sandi yang sudah di-hash.
*
* Hash dibuat DI SINI, bukan di pemanggil: satu-satunya pihak yang
* tahu cara menyimpan kata sandi adalah lapisan data pengguna.
*
* @return int id pengguna yang baru dibuat
*
* @throws PDOException bila email sudah terdaftar (constraint UNIQUE)
*/
public function create(string $email, string $sandi, string $nama, string $peran = 'editor'): int
{
$hash = password_hash($sandi, PASSWORD_DEFAULT);
$stmt = $this->pdo->prepare('INSERT INTO users (email, nama, peran, password_hash)
VALUES (?, ?, ?, ?)');
$stmt->execute([$email, $nama, $peran, $hash]);
return (int) $this->pdo->lastInsertId();
}
/**
* Jumlah pengguna terdaftar — dipakai skrip seed untuk memutuskan
* apakah admin pertama perlu dibuat.
*/
public function hitung(): int
{
return (int) $this->pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();
}
}Perbedaan penting dari PostRepository: method create() menerima sandi mentah sebagai parameter lalu meng-hash-nya sendiri sebelum menyentuh SQL. Pemanggil karenanya tidak pernah memegang hash — dan tidak ada kesempatan hash tersimpan di variabel yang bocor ke log. Menumbuhkan admin pertama pun tinggal satu panggilan:
<?php
require __DIR__ . '/src/UserRepository.php';
use App\UserRepository;
$pdo = new PDO('sqlite::memory:', null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
nama TEXT NOT NULL,
peran TEXT NOT NULL DEFAULT 'editor',
password_hash TEXT NOT NULL,
dibuat_pada TEXT NOT NULL DEFAULT (datetime('now'))
)
SQL);
$users = new UserRepository($pdo);
$idAdmin = $users->create('[email protected]', 'sandi-admin-2026', 'Budi Admin', 'admin');
$idEditor = $users->create('[email protected]', 'sandi-editor-2026', 'Siti Editor');
echo "create() admin -> id ", $idAdmin, " (peran admin)\n";
echo "create() editor -> id ", $idEditor, " (peran bawaan)\n";
echo "hitung() : ", $users->hitung(), " pengguna\n";
// Struktur hash — hash mentah tidak pernah dicetak penuh.
$hash = (string) $users->cariEmail('[email protected]')['password_hash'];
echo 'password_hash : ', strlen($hash), ' karakter, prefiks ', substr($hash, 0, 4), "\n";
echo 'verify benar : ', var_export(password_verify('sandi-admin-2026', $hash), true), "\n";
echo 'verify salah : ', var_export(password_verify('sandi-salah', $hash), true), "\n";
// Email yang sama dua kali -> ditolak constraint UNIQUE di database.
try {
$users->create('[email protected]', 'sandi-lagi', 'Duplikat');
echo "duplikat : lolos (ada yang salah!)\n";
} catch (PDOException $e) {
echo 'duplikat : ditolak (SQLSTATE ', $e->getCode(), ")\n";
}Output yang Dihasilkan:
create() admin -> id 1 (peran admin)
create() editor -> id 2 (peran bawaan)
hitung() : 2 pengguna
password_hash : 60 karakter, prefiks $2y$
verify benar : true
verify salah : false
duplikat : ditolak (SQLSTATE 23000)Prefiks $2y$ menandai bcrypt keluaran PHP, dan panjang tetap 60 karakter — dua fakta struktur itu cukup untuk memastikan kolom terisi benar tanpa pernah menampilkan hash itu sendiri. Sandi sandi-editor-2026 sengaja kita simpan sebagai pengguna berperan editor untuk menguji gerbang authorization nanti.
Kelas Auth: Sesi sebagai Bukti Masuk #
HTTP tidak punya memori antar request — yang menghubungkan request kedua dengan login pada request pertama adalah session
. Setelah kredensial terbukti benar, kita menyimpan satu hal saja ke sesi: user_id. Bukan sandi, bukan hash, bukan seluruh baris pengguna — cukup kunci untuk mengambil data segar dari database pada request berikutnya. Berikut src/Auth.php, bagian pertama:
<?php
declare(strict_types=1);
namespace App;
/**
* Auth — authentication dan authorization berbasis session (Bagian 5).
*
* Dua tanggung jawab yang dipisah tegas:
* - authentication ("siapa Anda"): login/check/logout bekerja dengan
* email + kata sandi + $_SESSION['user_id'];
* - authorization ("bolehkah Anda"): requireRole membaca peran
* pengguna, dan jaga() mengubahnya menjadi keputusan penjaga rute
* gaya middleware — null berarti silakan lanjut, array berarti
* tangkis dengan respons [status, headers, body] itu.
*
* Class ini tidak pernah menyimpan kata sandi di sesi — hanya user_id.
* Data pengguna segar selalu diambil ulang dari UserRepository, sehingga
* perubahan peran berlaku pada request berikutnya tanpa login ulang.
*/
final class Auth
{
public function __construct(private readonly UserRepository $users)
{
}
/**
* Masuk: cocokkan kredensial, regenerasi id sesi, catat user_id.
*
* session_regenerate_id(true) dipanggil tepat setelah kredensial
* terbukti benar — id sesi lama dibuang agar serangan session
* fixation (menanam id sesi yang diketahui penyerang) tidak
* berlanjut menjadi sesi yang sah.
*
* @return array<string,mixed>|null data pengguna bila berhasil, null bila gagal
*/
public function login(string $email, string $sandi): ?array
{
$user = $this->users->cariEmail($email);
if ($user === null || !password_verify($sandi, (string) $user['password_hash'])) {
return null; // alasan gagal sengaja tidak dibedakan
}
$this->mulaiSesi();
session_regenerate_id(true);
$_SESSION['user_id'] = (int) $user['id'];
return $this->users->cariId((int) $user['id']);
}
/**
* Pengguna yang sedang masuk, dilihat dari sesi.
*
* @return array<string,mixed>|null null bila tidak ada yang masuk
*/
public function check(): ?array
{
$this->mulaiSesi();
$id = (int) ($_SESSION['user_id'] ?? 0);
return $id === 0 ? null : $this->users->cariId($id);
}
/**
* Authorization: apakah pengguna saat ini memiliki peran ini?
*
* Tamu (belum login) selalu tidak memiliki peran apa pun.
*/
public function requireRole(string $peran): bool
{
$user = $this->check();
return $user !== null && ($user['peran'] ?? '') === $peran;
}
/**
* Keluar: kosongkan sesi lalu hancurkan berkasnya.
*
* $_SESSION di-nol-kan SEBELUM session_destroy() supaya data tidak
* tinggal di memori proses setelah sesi resmi berakhir.
*/
public function logout(): void
{
$this->mulaiSesi();
$_SESSION = [];
session_destroy();
}
// ... jaga() dan mulaiSesi() — penjaga rute, dibedah berikutnya ...
}Ada empat keputusan yang perlu dibedah. Pertama, login() mengembalikan ?array — null berarti gagal, dan pesan kegagalan dibuat sama untuk “email tak dikenal” maupun “sandi salah”; membedakan keduanya memberi tahu penyerang alamat email mana yang terdaftar. Kedua, session_regenerate_id(true)
dipanggil hanya setelah verifikasi berhasil — menumbangkan id sesi lama beserta kemungkinan id yang ditanam penyerang. Ketiga, check() mengambil ulang data dari cariId() alih-alih menyimpan baris pengguna di sesi, sehingga perubahan peran atau nama berlaku seketika. Keempat, logout() mengosongkan $_SESSION sebelum session_destroy() — menghancurkan berkas sesi saja masih menyisakan data di memori proses request itu.
Bagian kedua class ini adalah bintangnya: jaga(), penjaga rute yang nanti ditalangi di front controller:
<?php
declare(strict_types=1);
namespace App;
final class Auth
{
// ... constructor, login(), check(), requireRole(), logout() ...
/**
* Penjaga rute ala middleware untuk front controller.
*
* return $auth->jaga('admin') ?? $admin->jalankan($request);
*
* - null : pengguna berperan benar — lanjutkan ke handler;
* - [302, ...] : tamu — arahkan ke /login lebih dulu;
* - [403, ...] : sudah masuk tapi perannya kurang — tolak.
*
* @return array{0: int, 1: array<string,string>, 2: string}|null
*/
public function jaga(string $peran): ?array
{
$user = $this->check();
if ($user === null) {
return [302, ['Location' => '/login'], ''];
}
if (($user['peran'] ?? '') !== $peran) {
$pesan = 'Halaman ini hanya untuk pengguna dengan peran "' . $peran . '".';
return [403, ['Content-Type' => 'text/html; charset=UTF-8'],
'<!DOCTYPE html><html lang="id"><body>'
. '<h1>403 — Akses Ditolak</h1>'
. '<p>' . htmlspecialchars($pesan, ENT_QUOTES, 'UTF-8') . '</p>'
. '<p><a href="/">Kembali ke beranda</a></p>'
. '</body></html>'];
}
return null;
}
/** Pastikan sesi aktif — id sesi satu kali perlu dipasang pemanggil. */
private function mulaiSesi(): void
{
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
}
}jaga() mengembalikan null sebagai sinyal “lulus, silakan lanjut”, atau respons tangkisan [status, headers, body] bentuk yang sama persis dengan keluaran dispatch() dan AdminController::jalankan(). Kesamaan bentuk inilah yang membuat pemasangannya di router cukup satu operator ?? — pola yang di dunia framework disebut middleware: lapisan yang membungkus handler, boleh memutuskan request tidak sampai ke handler sama sekali.
Alur Login dari CLI #
Karena Auth hanya mengolah nilai dan sesi (tanpa echo, tanpa header()), seluruh alurnya bisa diuji dari CLI. Satu aturan main: operasi sesi harus selesai sebelum output pertama — session_regenerate_id() menolak bekerja setelah header terkirim. Cuplikan berikut menata semua keputusan dulu, mencetak paling akhir:
<?php
require __DIR__ . '/src/UserRepository.php';
require __DIR__ . '/src/Auth.php';
use App\Auth;
use App\UserRepository;
// Database sementara di RAM dengan tabel users (SQL dari bagian atas halaman).
$pdo = new PDO('sqlite::memory:', null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
nama TEXT NOT NULL,
peran TEXT NOT NULL DEFAULT 'editor',
password_hash TEXT NOT NULL,
dibuat_pada TEXT NOT NULL DEFAULT (datetime('now'))
)
SQL);
$users = new UserRepository($pdo);
$users->create('[email protected]', 'sandi-admin-2026', 'Budi Admin', 'admin');
// Sesi deterministik untuk demo CLI: pasang id tetap SEBELUM sesi mulai.
if (session_status() === PHP_SESSION_ACTIVE) {
$_SESSION = [];
session_destroy();
}
session_id('fixed-bagian5-login');
$auth = new Auth($users);
// --- seluruh operasi sesi selesai sebelum output pertama ---
$tamu = $auth->check();
$berhasil = $auth->login('[email protected]', 'sandi-admin-2026');
$sesudahLogin = $auth->check();
$boleh = $auth->requireRole('admin');
$sandiSalah = $auth->login('[email protected]', 'sandi-salah');
$emailAsing = $auth->login('[email protected]', 'sandi-admin-2026');
$auth->logout();
$sesudahKeluar = $auth->check();
// --- barulah hasil dicetak ---
echo 'check() sebagai tamu : ', $tamu === null ? 'null' : 'ada', "\n";
echo 'login() kredensial benar : ', $berhasil !== null ? 'masuk sebagai ' . $berhasil['nama'] : 'ditolak', "\n";
echo 'check() setelah login : ', $sesudahLogin['email'] ?? '-', ' (peran ', $sesudahLogin['peran'] ?? '-', ")\n";
echo "requireRole('admin') : ", $boleh ? 'true' : 'false', "\n";
echo 'login() sandi salah : ', $sandiSalah === null ? 'null (ditolak)' : 'masuk', "\n";
echo 'login() email tak dikenal : ', $emailAsing === null ? 'null (ditolak)' : 'masuk', "\n";
echo 'check() setelah logout : ', $sesudahKeluar === null ? 'null' : 'masih ada', "\n";Output yang Dihasilkan:
check() sebagai tamu : null
login() kredensial benar : masuk sebagai Budi Admin
check() setelah login : [email protected] (peran admin)
requireRole('admin') : true
login() sandi salah : null (ditolak)
login() email tak dikenal : null (ditolak)
check() setelah logout : nullTujuh baris output itu adalah siklus hidup sesi yang utuh: tamu dikenali null, login berhasil menyimpan identitas, dua kegagalan ditolak dengan jawaban yang sama, dan logout menghapus semuanya. Sekarang keputusan penjaganya — tiga keadaan dari tabel di awal halaman:
<?php
require __DIR__ . '/src/UserRepository.php';
require __DIR__ . '/src/Auth.php';
use App\Auth;
use App\UserRepository;
$pdo = new PDO('sqlite::memory:', null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
nama TEXT NOT NULL,
peran TEXT NOT NULL DEFAULT 'editor',
password_hash TEXT NOT NULL,
dibuat_pada TEXT NOT NULL DEFAULT (datetime('now'))
)
SQL);
$users = new UserRepository($pdo);
$users->create('[email protected]', 'sandi-admin-2026', 'Budi Admin', 'admin');
$users->create('[email protected]', 'sandi-editor-2026', 'Siti Editor');
if (session_status() === PHP_SESSION_ACTIVE) {
$_SESSION = [];
session_destroy();
}
session_id('fixed-bagian5-jaga');
$auth = new Auth($users);
// --- tiga keadaan diuji tanpa output di antaranya ---
$tangkisTamu = $auth->jaga('admin');
$auth->login('[email protected]', 'sandi-editor-2026');
$tangkisEditor = $auth->jaga('admin');
$auth->logout();
$auth->login('[email protected]', 'sandi-admin-2026');
$tangkisAdmin = $auth->jaga('admin');
// --- ringkasan keputusan ---
$wujud = static function (?array $tangkisan): string {
if ($tangkisan === null) {
return 'null — lanjut ke AdminController';
}
[$status, $headers, $body] = $tangkisan;
return sprintf(
'status=%d Location=%s body=%d byte',
$status,
$headers['Location'] ?? '(tidak ada)',
strlen($body)
);
};
echo 'tamu : ', $wujud($tangkisTamu), "\n";
echo 'editor : ', $wujud($tangkisEditor), "\n";
echo 'admin : ', $wujud($tangkisAdmin), "\n";Output yang Dihasilkan:
tamu : status=302 Location=/login body=0 byte
editor : status=403 Location=(tidak ada) body=193 byte
admin : null — lanjut ke AdminControllerTepat seperti tabel di awal: tamu dapat 302 ke /login, editor ditolak 403 dengan halaman penjelasan, dan admin lulus dengan null. Penjaga ini tidak tahu apa pun tentang AdminController — ia bisa dipasang di rute mana pun yang kelak butuh peran berbeda.
Merakit di Front Controller #
Pemasangan di public/index.php memusat di dispatch(). Dua rute baru (/login, /logout) melayani formulir dan pengakhiran sesi, dan cabang admin ditumbuhi penjaga:
<?php
use App\AdminController;
use App\Auth;
use App\PostRepository;
/**
* @param array<string,mixed> $server
* @param array<string,mixed> $get
* @param array<string,mixed> $post
*
* @return array{0: int, 1: array<string,string>, 2: string}
*/
function dispatch(
array $server,
array $get,
array $post,
PostRepository $posts,
AdminController $admin,
Auth $auth,
): array {
$path = (string) (parse_url((string) ($server['REQUEST_URI'] ?? '/'), PHP_URL_PATH) ?: '/');
$path = rtrim($path, '/') ?: '/';
$method = strtoupper((string) ($server['REQUEST_METHOD'] ?? 'GET'));
// --- rute authentication ---
if ($path === '/login') {
if ($method === 'POST') {
$user = $auth->login((string) ($post['email'] ?? ''), (string) ($post['sandi'] ?? ''));
if ($user !== null) {
return [303, ['Location' => '/admin'], ''];
}
return halamanLogin((string) ($post['email'] ?? ''), 'Email atau kata sandi salah.', 422);
}
return halamanLogin('', '');
}
if ($path === '/logout' && $method === 'POST') {
$auth->logout();
return [303, ['Location' => '/'], ''];
}
// --- panel admin: penjaga dulu, controller kemudian ---
if ($path === '/admin' || str_starts_with($path, '/admin/')) {
return $auth->jaga('admin') ?? $admin->jalankan([
'method' => $method,
'path' => $path,
'query' => $get,
'post' => $post,
]);
}
// ... rute publik /, /tentang, /post/{slug} seperti Bagian 2-4 ...
}Tiga detail layak diperhatikan. Login yang gagal merender ulang formulir dengan status 422 sambil mengembalikan email yang barusan diketik (pola old dari Bagian 4) — hanya sandi yang dikosongkan. logout hanya menerima POST lewat tombol kecil di header admin, bukan tautan GET — keluar sesi pun perubahan keadaan. Dan cabang admin menjadi satu baris: jaga('admin') ?? jalankan(...) — penjaga berdiri di depan controller tanpa controller sendiri tahu bahwa ia dijaga.
Handler halamanLogin() tinggal pembungkus render() biasa:
<?php
/** @return array{0: int, 1: array<string,string>, 2: string} */
function halamanLogin(string $email, string $galat, int $status = 200): array
{
$konten = render('login.php', ['email' => $email, 'galat' => $galat]);
return [$status, ['Content-Type' => 'text/html; charset=UTF-8'],
render('layout.php', ['judul' => 'Masuk', 'konten' => $konten])];
}Adapun templates/login.php pada bundle bagian ini adalah formulir dengan dua input dan pesan galat generik — nilainya dibekali ?? '' agar aman dijalankan langsung, dan seluruh output dinamisnya melewati e(). Berkas lengkapnya tersedia di bagian bawah halaman. Sebelum server dinyalakan, admin pertama harus ditanam dulu; skrip CLI buat-admin.php berikut mengeksekusi CREATE TABLE IF NOT EXISTS (migrasi untuk database lama) lalu create() dari repository:
<?php
// buat-admin.php — dari root proyek, jalankan SEKALI sebelum login pertama.
// Inti yang dieksekusi (versi lengkap memuat class via require):
$pdo = App\Database::siapkan();
// Tambahan schema Bagian 5 — idempoten, aman pada database yang sudah ada.
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
nama TEXT NOT NULL,
peran TEXT NOT NULL DEFAULT 'editor',
password_hash TEXT NOT NULL,
dibuat_pada TEXT NOT NULL DEFAULT (datetime('now'))
)
SQL);
$users = new App\UserRepository($pdo);
if ($users->hitung() > 0) {
echo 'Sudah ada ', $users->hitung(), " pengguna — admin pertama tidak dibuat ulang.\n";
exit(0);
}
$id = $users->create('[email protected]', 'sandi-admin-2026', 'Budi Admin', 'admin');
$baris = $users->cariEmail('[email protected]');
echo 'Admin dibuat: id=', $id, ' email=', $baris['email'], ' peran=', $baris['peran'], "\n";
echo 'Kata sandi disimpan sebagai hash bcrypt ', strlen((string) $baris['password_hash']),
" karakter — hash mentah tidak pernah dicetak.\n";Menguji dengan Web Server Bawaan #
Saatnya menjalankan alur sungguhan. Rakit seluruh berkas Bagian 1-5 dalam satu root proyek, buat admin pertama, lalu nyalakan server:
$ php buat-admin.phpAdmin dibuat: id=1 [email protected] peran=admin
Kata sandi disimpan sebagai hash bcrypt 60 karakter — hash mentah tidak pernah dicetak.
Masuk dengan: [email protected] / sandi-admin-2026# terminal 1 — dari direktori root proyek
$ php -S 127.0.0.1:8095 public/index.phpTamu yang mencoba membuka /admin langsung dipinggirkan — perhatikan bahwa sesi untuk tamu pun sudah ditandai cookie PHPSESSID (nilainya acak tiap server; milik Anda akan berbeda, sama seperti baris Date):
# terminal 2
$ curl -sS -i http://127.0.0.1:8095/adminHTTP/1.1 302 Found
Host: 127.0.0.1:8095
Date: Tue, 13 Oct 2026 22:21:26 GMT
Connection: close
X-Powered-By: PHP/8.4.23
Set-Cookie: PHPSESSID=0fad215f7f67d6415b69947159529631; path=/
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Location: /login
Content-type: text/html; charset=UTF-8Formulir login menyambut di /login — terbungkus layout publik, dua input, tanpa galat:
$ curl -sS http://127.0.0.1:8095/login | grep -E '<title>|<h2>|<label|<input' <title>Masuk — Blog PHP</title>
<h2>Masuk ke Panel Admin</h2>
<label for="email">Email</label><br>
<input type="email" id="email" name="email" size="40" value="" required>
<label for="sandi">Kata Sandi</label><br>
<input type="password" id="sandi" name="sandi" required>Kredensial yang salah tidak membocorkan mana yang salah — sandi keliru dan email asing mendapat kalimat yang sama persis, dan email yang diketik tetap terisi di form:
$ curl -sS -i -X POST -d "[email protected]" -d "sandi=sandi-salah" \
http://127.0.0.1:8095/login | grep -E 'HTTP/|galat|value="admin@'HTTP/1.1 422 Unknown Status Code
<p class="galat">Email atau kata sandi salah.</p>
<input type="email" id="email" name="email" size="40" value="[email protected]" required>$ curl -sS -X POST -d "[email protected]" -d "sandi=apapun" \
http://127.0.0.1:8095/login | grep 'galat' <p class="galat">Email atau kata sandi salah.</p>Login yang benar mengikuti pola PRG: 303 menuju /admin, dan cookie sesi disimpan oleh curl lewat -c (web server bawaan PHP menampilkan frasa Unknown Status Code untuk 422 karena tidak mengenalnya; artinya tetap 422):
$ curl -sS -i -c kuki.txt -X POST -d "[email protected]" \
-d "sandi=sandi-admin-2026" http://127.0.0.1:8095/login | grep -E 'HTTP/|^Location'HTTP/1.1 303 See Other
Location: /adminDengan cookie itu (-b kuki.txt), gerbang terbuka — dan seluruh CRUD Bagian 4 tetap bekerja di baliknya:
$ curl -sS -b kuki.txt http://127.0.0.1:8095/admin | grep -E '<title>|<h1>' <title>Admin — Daftar Artikel</title>
<h1>Daftar Artikel (3)</h1>$ curl -sS -i -b kuki.txt -X POST \
--data-urlencode "judul=Autentikasi Itu Penting" \
--data-urlencode "slug=autentikasi-itu-penting" \
--data-urlencode "konten=Panel admin yang terbuka untuk umum adalah kecelakaan yang menunggu waktu." \
--data-urlencode "dibuat_pada=2026-01-25 09:00:00" \
http://127.0.0.1:8095/admin/tulis | grep -E 'HTTP/|^Location'HTTP/1.1 303 See Other
Location: /admin?flash=simpan&id=4Keluar lalu mencoba lagi membuktikan sesi benar-benar mati:
$ curl -sS -i -b kuki.txt -X POST http://127.0.0.1:8095/logout | grep -E 'HTTP/|^Location'HTTP/1.1 303 See Other
Location: /$ curl -sS -i -b kuki.txt http://127.0.0.1:8095/admin | grep -E 'HTTP/|^Location'HTTP/1.1 302 Found
Location: /loginTerakhir, gerbang authorization. Tanam satu pengguna editor (satu panggilan create() lewat php -r, sama seperti buat-admin.php), masuk sebagai dia, lalu minta /admin:
$ curl -sS -i -c kuki.txt -X POST -d "[email protected]" \
-d "sandi=sandi-editor-2026" http://127.0.0.1:8095/login | grep -E 'HTTP/|^Location'HTTP/1.1 303 See Other
Location: /admin$ curl -sS -i -b kuki.txt http://127.0.0.1:8095/admin | grep -E 'HTTP/|Content-Type'HTTP/1.1 403 Forbidden
Content-Type: text/html; charset=UTF-8$ curl -sS -b kuki.txt http://127.0.0.1:8095/admin<!DOCTYPE html><html lang="id"><body><h1>403 — Akses Ditolak</h1><p>Halaman ini hanya untuk pengguna dengan peran "admin".</p><p><a href="/">Kembali ke beranda</a></p></body></html>Editor loginnya sah (303), tapi gerbang kedua menolaknya dengan 403 — bukan redirect ke login, karena masalahnya bukan identitas melainkan peran. Blog publik tentu tetap terbuka baginya (curl -o /dev/null -w "%{http_code}" atas / mengembalikan 200). Matikan server dengan Ctrl+C setelah selesai mencoba.
Apa yang Berubah #
Dibandingkan Bagian 4, tahap ini menambahkan tiga berkas dan satu evolusi front controller:
src/UserRepository.php— akses datausers:cariEmail()untuk login,cariId()untukcheck(),create()yang meng-hash sandi sendiri,hitung()untuk skrip seed.src/Auth.php—login()denganpassword_verify+ regenerasi id sesi,check()yang selalu membaca data segar,requireRole()sebagai predicate authorization,logout(), danjaga()penjaga rute ala middleware.templates/login.php— formulir masuk defensif dengan pesan galat generik dan pola old untuk email.public/index.php— rute/login(GET+POST) dan/logout(POST); cabang/admin...ditumbuhi$auth->jaga('admin') ??sebelum mendelegasikan keAdminController.database/schema.sql— tabelusersdenganemail UNIQUE,peran, danpassword_hash; migrasi berjalan lewatbuat-admin.phpuntuk database lama.
Panel admin kini terkunci, tetapi gerbangnya masih bisa ditembus lewat celah klasik: formulir yang tidak membawa token CSRF bisa diperalat situs jahat untuk mengirim POST atas nama pengguna yang sedang login. Bagian 6 menutup celah itu sekaligus merapikan validasi input menjadi class tersendiri.
Cara Menjalankan #
Dari direktori bagian-5-authentication-dan-authorization/ (folder bundle halaman ini):
# 1. Alur Auth dari CLI murni — salin cuplikan pengujian di atas
# ke berkas sementara lalu jalankan
php uji-auth.php
# 2. Gabungkan dengan berkas Bagian 1-4, buat admin, lalu nyalakan server
php buat-admin.php
php -S 127.0.0.1:8095 public/index.php
curl -sS -i http://127.0.0.1:8095/admin # 302 ke /login
# 3. Pemeriksaan sintaks berkas baru
php -l src/Auth.php && php -l src/UserRepository.php && php -l templates/login.phpTiga berkas pendukung halaman ini — src/Auth.php, src/UserRepository.php, dan templates/login.php — tersedia di bagian bawah halaman. Untuk pendalaman, baca dokumentasi resmi tentang password_hash()
, password_verify()
, keamanan session
, serta session_regenerate_id()
. Dengan pintu yang kini berkunci, kita lanjut mengerasi formulir-adminnya pada Bagian 6.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
src/Auth.php
Unduh berkas<?php
declare(strict_types=1);
namespace App;
/**
* Auth — authentication dan authorization berbasis session (Bagian 5).
*
* Dua tanggung jawab yang dipisah tegas:
* - authentication ("siapa Anda"): login/check/logout bekerja dengan
* email + kata sandi + $_SESSION['user_id'];
* - authorization ("bolehkah Anda"): requireRole membaca peran
* pengguna, dan jaga() mengubahnya menjadi keputusan penjaga rute
* gaya middleware — null berarti silakan lanjut, array berarti
* tanggis dengan respons [status, headers, body] itu.
*
* Class ini tidak pernah menyimpan kata sandi di sesi — hanya user_id.
* Data pengguna segar selalu diambil ulang dari UserRepository, sehingga
* perubahan peran berlaku pada request berikutnya tanpa login ulang.
*/
final class Auth
{
public function __construct(private readonly UserRepository $users)
{
}
/**
* Masuk: cocokkan kredensial, regenerasi id sesi, catat user_id.
*
* session_regenerate_id(true) dipanggil tepat setelah kredensial
* terbukti benar — id sesi lama dibuang agar serangan session
* fixation (menanam id sesi yang diketahui penyerang) tidak
* berlanjut menjadi sesi yang sah.
*
* @return array<string,mixed>|null data pengguna bila berhasil, null bila gagal
*/
public function login(string $email, string $sandi): ?array
{
$user = $this->users->cariEmail($email);
if ($user === null || !password_verify($sandi, (string) $user['password_hash'])) {
return null; // alasan gagal sengaja tidak dibedakan
}
$this->mulaiSesi();
session_regenerate_id(true);
$_SESSION['user_id'] = (int) $user['id'];
return $this->users->cariId((int) $user['id']);
}
/**
* Pengguna yang sedang masuk, dilihat dari sesi.
*
* @return array<string,mixed>|null null bila tidak ada yang masuk
*/
public function check(): ?array
{
$this->mulaiSesi();
$id = (int) ($_SESSION['user_id'] ?? 0);
return $id === 0 ? null : $this->users->cariId($id);
}
/**
* Authorization: apakah pengguna saat ini memiliki peran ini?
*
* Tamu (belum login) selalu tidak memiliki peran apa pun.
*/
public function requireRole(string $peran): bool
{
$user = $this->check();
return $user !== null && ($user['peran'] ?? '') === $peran;
}
/**
* Keluar: kosongkan sesi lalu hancurkan berkasnya.
*
* $_SESSION di-nol-kan SEBELUM session_destroy() supaya data tidak
* tinggal di memori proses setelah sesi resmi berakhir.
*/
public function logout(): void
{
$this->mulaiSesi();
$_SESSION = [];
session_destroy();
}
/**
* Penjaga rute ala middleware untuk front controller.
*
* return $auth->jaga('admin') ?? $admin->jalankan($request);
*
* - null : pengguna berperan benar — lanjutkan ke handler;
* - [302, ...] : tamu — arahkan ke /login lebih dulu;
* - [403, ...] : sudah masuk tapi perannya kurang — tolak.
*
* @return array{0: int, 1: array<string,string>, 2: string}|null
*/
public function jaga(string $peran): ?array
{
$user = $this->check();
if ($user === null) {
return [302, ['Location' => '/login'], ''];
}
if (($user['peran'] ?? '') !== $peran) {
$pesan = 'Halaman ini hanya untuk pengguna dengan peran "' . $peran . '".';
return [403, ['Content-Type' => 'text/html; charset=UTF-8'],
'<!DOCTYPE html><html lang="id"><body>'
. '<h1>403 — Akses Ditolak</h1>'
. '<p>' . htmlspecialchars($pesan, ENT_QUOTES, 'UTF-8') . '</p>'
. '<p><a href="/">Kembali ke beranda</a></p>'
. '</body></html>'];
}
return null;
}
/** Pastikan sesi aktif — id sesi satu kali perlu dipasang pemanggil. */
private function mulaiSesi(): void
{
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
}
}
src/UserRepository.php
Unduh berkas<?php
declare(strict_types=1);
namespace App;
use PDO;
use PDOException;
use RuntimeException;
/**
* UserRepository — lapisan akses data pengguna (Bagian 5).
*
* Menyediakan pembacaan pengguna untuk Auth (cariEmail, cariId) dan
* pembuatan pengguna pertama. Kata sandi TIDAK PERNAH disimpan mentah:
* kolom password_hash diisi hasil password_hash() di dalam create(),
* sehingga pemanggil tidak pernah menyentuh hash secara langsung.
*/
final class UserRepository
{
public function __construct(private readonly PDO $pdo)
{
}
/**
* Cari satu pengguna berdasarkan email-nya (untuk login).
*
* @return array<string,mixed>|null null bila email tidak terdaftar
*/
public function cariEmail(string $email): ?array
{
$stmt = $this->pdo->prepare('SELECT id, email, nama, peran, password_hash, dibuat_pada
FROM users
WHERE email = ?');
$stmt->execute([$email]);
$user = $stmt->fetch();
return $user === false ? null : $user;
}
/**
* Cari satu pengguna berdasarkan id-nya (untuk check() Auth).
*
* @return array<string,mixed>|null null bila id tidak dikenal
*/
public function cariId(int $id): ?array
{
$stmt = $this->pdo->prepare('SELECT id, email, nama, peran, dibuat_pada
FROM users
WHERE id = ?');
$stmt->execute([$id]);
$user = $stmt->fetch();
return $user === false ? null : $user;
}
/**
* Buat pengguna baru dengan kata sandi yang sudah di-hash.
*
* Hash dibuat DI SINI, bukan di pemanggil: satu-satunya pihak yang
* tahu cara menyimpan kata sandi adalah lapisan data pengguna.
*
* @return int id pengguna yang baru dibuat
*
* @throws PDOException bila email sudah terdaftar (constraint UNIQUE)
*/
public function create(string $email, string $sandi, string $nama, string $peran = 'editor'): int
{
$hash = password_hash($sandi, PASSWORD_DEFAULT);
$stmt = $this->pdo->prepare('INSERT INTO users (email, nama, peran, password_hash)
VALUES (?, ?, ?, ?)');
$stmt->execute([$email, $nama, $peran, $hash]);
return (int) $this->pdo->lastInsertId();
}
/**
* Jumlah pengguna terdaftar — dipakai skrip seed untuk memutuskan
* apakah admin pertama perlu dibuat.
*/
public function hitung(): int
{
return (int) $this->pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();
}
}
templates/login.php
Unduh berkas<?php
/**
* templates/login.php — form masuk ke panel admin.
*
* Menerima dua variabel dari pemanggilnya:
* - $email : nilai lama untuk mengisi ulang kolom email setelah login
* gagal (pola old dari Bagian 4),
* - $galat : pesan galat umum ('' bila tidak ada).
*
* Pesan galat sengaja satu kalimat generik — alasan kegagalan login
* (email tak dikenal vs sandi salah) tidak boleh dibedakan, agar
* penyerang tidak bisa memetakan email mana yang terdaftar.
*
* Nilai bawaan ?? membuat berkas ini aman dijalankan langsung.
*/
if (!function_exists('e')) {
/** Escape teks untuk HTML — dipakai di semua template. */
function e(?string $nilai): string
{
return htmlspecialchars($nilai ?? '', ENT_QUOTES, 'UTF-8');
}
}
$email ??= '';
$galat ??= '';
?>
<section class="halaman">
<h2>Masuk ke Panel Admin</h2>
<?php if ($galat !== ''): ?>
<p class="galat"><?= e($galat) ?></p>
<?php endif; ?>
<form method="post" action="/login">
<p>
<label for="email">Email</label><br>
<input type="email" id="email" name="email" size="40" value="<?= e($email) ?>" required>
</p>
<p>
<label for="sandi">Kata Sandi</label><br>
<input type="password" id="sandi" name="sandi" required>
</p>
<p>
<button type="submit">Masuk</button>
</p>
</form>
</section>