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 penggunaGerbang authenticationGerbang authorizationRespons akhir
Tamu (tanpa sesi)gagal — belum dikenaltidak diperiksa302 ke /login
Masuk sebagai editorlolosgagal — peran kurang403 Akses Ditolak
Masuk sebagai adminlolosloloshalaman 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:

sql
-- 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
<?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:

text
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=ya

Perhatikan 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
<?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
<?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:

text
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
<?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
<?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
<?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:

text
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      : null

Tujuh 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
<?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:

text
tamu   : status=302  Location=/login  body=0 byte
editor : status=403  Location=(tidak ada)  body=193 byte
admin  : null — lanjut ke AdminController

Tepat 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
<?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
<?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
<?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:

bash
$ php buat-admin.php
text
Admin 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
bash
# terminal 1 — dari direktori root proyek
$ php -S 127.0.0.1:8095 public/index.php

Tamu 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):

bash
# terminal 2
$ curl -sS -i http://127.0.0.1:8095/admin
text
HTTP/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-8

Formulir login menyambut di /login — terbungkus layout publik, dua input, tanpa galat:

bash
$ curl -sS http://127.0.0.1:8095/login | grep -E '<title>|<h2>|<label|<input'
text
    <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:

bash
$ 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@'
text
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>
bash
$ curl -sS -X POST -d "[email protected]" -d "sandi=apapun" \
    http://127.0.0.1:8095/login | grep 'galat'
text
    <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):

bash
$ 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'
text
HTTP/1.1 303 See Other
Location: /admin

Dengan cookie itu (-b kuki.txt), gerbang terbuka — dan seluruh CRUD Bagian 4 tetap bekerja di baliknya:

bash
$ curl -sS -b kuki.txt http://127.0.0.1:8095/admin | grep -E '<title>|<h1>'
text
    <title>Admin — Daftar Artikel</title>
        <h1>Daftar Artikel (3)</h1>
bash
$ 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'
text
HTTP/1.1 303 See Other
Location: /admin?flash=simpan&id=4

Keluar lalu mencoba lagi membuktikan sesi benar-benar mati:

bash
$ curl -sS -i -b kuki.txt -X POST http://127.0.0.1:8095/logout | grep -E 'HTTP/|^Location'
text
HTTP/1.1 303 See Other
Location: /
bash
$ curl -sS -i -b kuki.txt http://127.0.0.1:8095/admin | grep -E 'HTTP/|^Location'
text
HTTP/1.1 302 Found
Location: /login

Terakhir, gerbang authorization. Tanam satu pengguna editor (satu panggilan create() lewat php -r, sama seperti buat-admin.php), masuk sebagai dia, lalu minta /admin:

bash
$ 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'
text
HTTP/1.1 303 See Other
Location: /admin
bash
$ curl -sS -i -b kuki.txt http://127.0.0.1:8095/admin | grep -E 'HTTP/|Content-Type'
text
HTTP/1.1 403 Forbidden
Content-Type: text/html; charset=UTF-8
bash
$ curl -sS -b kuki.txt http://127.0.0.1:8095/admin
text
<!DOCTYPE html><html lang="id"><body><h1>403 — Akses Ditolak</h1><p>Halaman ini hanya untuk pengguna dengan peran &quot;admin&quot;.</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 data users: cariEmail() untuk login, cariId() untuk check(), create() yang meng-hash sandi sendiri, hitung() untuk skrip seed.
  • src/Auth.php — login() dengan password_verify + regenerasi id sesi, check() yang selalu membaca data segar, requireRole() sebagai predicate authorization, logout(), dan jaga() 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 ke AdminController.
  • database/schema.sql — tabel users dengan email UNIQUE, peran, dan password_hash; migrasi berjalan lewat buat-admin.php untuk 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):

bash
# 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.php

Tiga 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
<?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
<?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
<?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>