API kita sampai di titik ini terbuka untuk siapa saja: siapa pun yang mengetahui alamat server bisa membuat, mengubah, bahkan menghapus tugas tanpa satu pun identitas. Pada Bagian 5 kita menutup pintu itu dengan dua mekanisme yang bekerja berbeda: authentication menjawab “siapa Anda” lewat email, sandi, dan bearer token, sedangkan authorization menjawab “apa yang boleh Anda lakukan” lewat daftar route yang dilindungi. Dua kelas baru menjadi tulang punggungnya — UserRepository menyimpan identitas pengguna dengan kata sandi ter-hash, dan TokenAuth menerbitkan token acak, menyimpan hanya hash SHA-256-nya, lalu memeriksa setiap request yang masuk.
Authentication dan Authorization itu Berbeda #
Dua istilah ini sering tertukar karena sama-sama berhubungan dengan keamanan, tetapi keduanya menjawab pertanyaan yang berbeda. Authentication memverifikasi identitas: apakah email dan sandi ini cocok dengan akun yang terdaftar? Authorization memutuskan hak akses: bolehkan identitas ini menghapus tugas? Sebuah request bisa lulus authentication tetapi gagal authorization — misalnya pengguna sah yang mencoba menghapus tugas milik orang lain. Tabel berikut memetakan keduanya ke mekanisme proyek ini:
| Aspek | Authentication | Authorization |
|---|---|---|
| Pertanyaan | Siapa Anda? | Apa yang boleh Anda lakukan? |
| Mekanisme proyek ini | POST /api/login + bearer token | Pemeriksaan token di depan setiap route tugas |
| Gagal dengan | 401 Unauthorized | 401 (token tidak sah) atau 403 Forbidden (sah tapi dilarang) |
| Disebut juga | Verifikasi identitas | Kontrol akses |
Proyek ini menerapkan authorization di tingkat route: seluruh endpoint /api/tugas menuntut token yang sah, sementara GET /api/kesehatan dan POST /api/login tetap publik. Karena tabel tasks belum memiliki kolom pemilik, semua pengguna terauthentikasi masih berbagi daftar tugas yang sama — menambahkan kolom user_id dan memfilter query repository adalah latihan lanjutan yang dibahas pada chapter authentication vs authorization
dan permission modeling
.
Dua Tabel Baru: users dan api_tokens #
Migrasi Bagian 5 menambahkan dua tabel ke database. Tabel users menyimpan identitas; tabel api_tokens menyimpan kunci substitusi sandi yang bisa dicabut kapan saja:
-- Migrasi Bagian 5 (dijalankan lewat $pdo->exec di front controller)
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
nama TEXT NOT NULL,
password_hash TEXT NOT NULL,
dibuat_pada TEXT NOT NULL
);
CREATE TABLE api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
token_hash TEXT NOT NULL UNIQUE,
kedaluwarsa TEXT NOT NULL,
dibuat_pada TEXT NOT NULL
);Empat keputusan desain penting tersembunyi di sini. Pertama, kolomnya bernama password_hash, bukan password — nama kolom sekaligus peringatan bahwa sandi mentah tidak pernah boleh tinggal di database. Kedua, email diberi constraint UNIQUE sehingga database ikut menolak pendaftaran ganda walau lapisan aplikasi lalai. Ketiga, tabel api_tokens menyimpan token_hash alih-alih token itu sendiri: token dikirim ke klien sekali, yang tersimpan hanyalah hash SHA-256-nya, sehingga kebocoran berkas database tidak otomatis menjadi jurang pemalsuan identitas. Keempat, kolom kedaluwarsa membuat token punya masa hidup — kredensial yang tidak pernah hangat adalah kredensial yang sulit dicabut.
UserRepository: Kata Sandi Tidak Pernah Disimpan Mentah #
UserRepository mirip dengan TaskRepository dari Bagian 3, tetapi memegang data yang jauh lebih sensitif. Method cariEmail() mencari satu baris pengguna, dan buatUser() mendaftarkan pengguna baru dengan sandi yang langsung di-hash password_hash():
<?php
require __DIR__ . '/../bagian-3-database-dan-repository/src/Database.php';
require __DIR__ . '/src/UserRepository.php';
use App\Database;
use App\UserRepository;
$pdo = Database::buat();
Database::migrasi($pdo, __DIR__ . '/../bagian-3-database-dan-repository/database/schema.sql');
// Migrasi Bagian 5: tabel users.
$pdo->exec('CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
nama TEXT NOT NULL,
password_hash TEXT NOT NULL,
dibuat_pada TEXT NOT NULL
)');
$users = new UserRepository($pdo);
$budi = $users->buatUser(' [email protected] ', 'sandi-budi-2026', 'Budi');
printf("buatUser : id=%d email=%s nama=%s\n", $budi['id'], $budi['email'], $budi['nama']);
$hash = $budi['password_hash'];
printf("hash : panjang=%d awalan=%s\n", strlen($hash), substr($hash, 0, 4));
var_dump(password_verify('sandi-budi-2026', $hash));
var_dump(password_verify('sandi-salah', $hash));
$cari = $users->cariEmail('[email protected]');
printf("cariEmail: id=%d email=%s\n", $cari['id'], $cari['email']);
var_dump($users->cariEmail('[email protected]'));
try {
$users->buatUser('[email protected]', 'sandi-lain-2026', 'Budi Kedua');
} catch (PDOException $e) {
echo 'Ditolak database: ', $e->getMessage(), "\n";
}Output yang Dihasilkan:
buatUser : id=1 [email protected] nama=Budi
hash : panjang=60 awalan=$2y$
bool(true)
bool(false)
cariEmail: id=1 [email protected]
NULL
Ditolak database: SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: users.emailMari kita bedah hasil di atas baris per baris:
buatUser()mengembalikan baris lengkap hasil pembacaan ulang, sama seperti pola insert-lalu-baca padaTaskRepository::buat(). Email' [email protected] 'dinormalkan menjadi[email protected]— spasi dibuang dan huruf dikecilkan — sehingga dua orang tidak bisa mendaftar dua akun yang “terlihat berbeda” padahal identik.- Fakta hash sengaja hanya berupa panjang (
60karakter) dan awalan ($2y$, penanda bcrypt): hash yang dihasilkanpassword_hash()berisi salt acak, jadi nilainya berbeda pada setiap eksekusi dan tidak boleh dicetak sebagai output tetap. Yang penting justru dua baris berikutnya. password_verify()membandingkan sandi mentah dengan hash: sandi yang benar menghasilkantrue, sandi yang salahfalse. Verifikasi inilah satu-satunya cara membuktikan sandi — hash tidak bisa “dibalik” menjadi sandi.cariEmail()menemukan baris yang sama lewat email dengan kapital berbeda (normalisasi berlaku dua arah), dan mengembalikanNULLuntuk email yang tidak terdaftar.- Pendaftaran ganda ditolak database dengan
SQLSTATE[23000]— constraintUNIQUEbekerja tepat seperti constraintCHECKstatus tugas pada Bagian 3: jaring pengaman terakhir di balik lapisan aplikasi.
Siklus Hidup Token di TokenAuth #
Mengapa perlu token sama sekali, bukan mengirim email dan sandi pada setiap request? Karena sandi adalah kredensial jangka panjang yang seharusnya hanya dipertaruhkan sekali di endpoint login. Token adalah kredensial jangka pendek yang bisa diberi masa hidup, dicabut, dan diterbitkan ulang tanpa menyentuh sandi. Kelas TokenAuth mengelola siklus itu dengan tiga method: issue(), resolve(), dan revoke().
<?php
require __DIR__ . '/../bagian-3-database-dan-repository/src/Database.php';
require __DIR__ . '/src/UserRepository.php';
require __DIR__ . '/src/TokenAuth.php';
use App\Database;
use App\TokenAuth;
use App\UserRepository;
$pdo = Database::buat();
Database::migrasi($pdo, __DIR__ . '/../bagian-3-database-dan-repository/database/schema.sql');
// Migrasi Bagian 5: tabel users dan api_tokens.
$pdo->exec('CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
nama TEXT NOT NULL,
password_hash TEXT NOT NULL,
dibuat_pada TEXT NOT NULL
)');
$pdo->exec('CREATE TABLE api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
token_hash TEXT NOT NULL UNIQUE,
kedaluwarsa TEXT NOT NULL,
dibuat_pada TEXT NOT NULL
)');
$users = new UserRepository($pdo);
$auth = new TokenAuth($pdo);
$users->buatUser('[email protected]', 'sandi-budi-2026', 'Budi');
// --- Login via pemanggilan langsung (logika yang nanti dijalankan
// --- endpoint POST /api/login): cari email, lalu verifikasi sandi.
$baris = $users->cariEmail('[email protected]');
$login = $baris !== null && password_verify('sandi-budi-2026', $baris['password_hash']);
echo 'login : ', $login ? 'berhasil' : 'gagal', "\n";
// --- Terbitkan token untuk user id 1.
$token = $auth->issue(1);
printf("issue : panjang=%d heksadesimal=%s\n", strlen($token), ctype_xdigit($token) ? 'ya' : 'bukan');
// SHA-256 selalu menghasilkan 64 karakter heksadesimal; ini contoh pada
// string tetap supaya nilainya bisa dicetak:
echo 'sha256 : ', hash('sha256', 'belajar-rest-api-di-php'), "\n";
// Yang tersimpan di database hanyalah hash token, bukan token-nya.
$tersimpan = $pdo->query('SELECT token_hash FROM api_tokens WHERE user_id = 1')->fetch();
printf("tersimpan: hanya-hash=%s\n", var_export(hash('sha256', $token) === $tersimpan['token_hash'], true));
// --- Uraikan token kembali menjadi id pengguna.
printf("resolve : userId=%s\n", var_export($auth->resolve($token), true));
printf("salah : userId=%s\n", var_export($auth->resolve('token-yang-tidak-terdaftar'), true));
// --- Token kedaluwarsa ditolak (waktu ditetapkan di masa lalu).
$lama = $auth->issue(1, '2026-01-15 09:00:00');
printf("expired : userId=%s\n", var_export($auth->resolve($lama), true));
// --- Cabut token (logout): sekali berhasil, kedua kali tidak ada lagi.
var_dump($auth->revoke($token));
var_dump($auth->resolve($token));
var_dump($auth->revoke($token));Output yang Dihasilkan:
login : berhasil
issue : panjang=64 heksadesimal=ya
sha256 : 63b8b290e1da3dd11945b6d4fea1fed164e1b1ece7e9b760d41b6c7490d0edfe
tersimpan: hanya-hash=true
resolve : userId=1
salah : userId=NULL
expired : userId=NULL
bool(true)
NULL
bool(false)Perhatikan alur delapan baris di atas:
- Login di sini adalah pemanggilan langsung —
cariEmail()lalupassword_verify()— persis logika yang akan dibungkus endpointPOST /api/loginsebentar lagi. Memisahkannya sebagai potongan kecil membuat alur mudah diuji tanpa HTTP. issue(1)menaruh token acak 64 karakter heksadesimal (32 byterandom_bytes()) di tangan klien. Nilai token berbeda pada setiap eksekusi, jadi contoh hanya mencetak fakta strukturalnya.- Baris
sha256menunjukkan wujud hash pada string tetap — inilah satu-satunya bentuk hash yang aman dicetak sebagai output tetap. Bandingkan panjangnya dengan token: sama-sama 64 karakter heksadesimal, dan memang begitulahhash('sha256', $token)tersimpan di tabel. hanya-hash=truemembuktikan klaim desain: baris diapi_tokensberisihash('sha256', $token), bukan$token. Siapa pun yang membaca tabel itu tidak bisa memalsukan request.resolve($token)mengembalikan1— token ditukar menjadi id pengguna dalam satu query indeks padatoken_hashyangUNIQUE.- Token asing dan token kedaluwarsa sama-sama ditolak
NULLtanpa membedakan alasannya — detail ini disengaja agar penyerang tidak tahu apakah tebakannya mendekati. revoke()mengembalikantruesekali, laluresolve()langsungNULL, danrevoke()keduafalse— hapus adalah operasi sekali-jadi, sama sepertihapus()pada tugas.
Pemeriksaan kedaluwarsa layak diperhatikan: $baris['kedaluwarsa'] < date('Y-m-d H:i:s') membandingkan dua string, bukan dua objek waktu. Format Y-m-d H:i:s terurut leksikografis identik dengan urutan kronologisnya, jadi perbandingan teks cukup — trik yang sama yang membuat kolom dibuat_pada bisa diurutkan ORDER BY tanpa konversi.
Melindungi Route Tugas #
Sekarang bagian authorization. Method TokenAuth::periksa() membaca header Authorization dari request: bila formatnya Bearer <token> dan token-nya masih hidup, method mengembalikan null dan handler boleh bekerja; bila tidak, method mengembalikan response 401 lengkap dengan header WWW-Authenticate: Bearer. Setiap handler terlindungi cukup memulai dirinya dengan pola yang sama:
<?php
require __DIR__ . '/../bagian-2-routing-dan-request/src/Request.php';
require __DIR__ . '/../bagian-2-routing-dan-request/src/Response.php';
require __DIR__ . '/../bagian-2-routing-dan-request/src/Router.php';
require __DIR__ . '/../bagian-3-database-dan-repository/src/Database.php';
require __DIR__ . '/../bagian-3-database-dan-repository/src/TaskRepository.php';
require __DIR__ . '/../bagian-4-validation-dan-error-responses/src/Validator.php';
require __DIR__ . '/../bagian-4-validation-dan-error-responses/src/ErrorHandler.php';
require __DIR__ . '/src/UserRepository.php';
require __DIR__ . '/src/TokenAuth.php';
use App\Database;
use App\ErrorHandler;
use App\Request;
use App\Response;
use App\Router;
use App\TaskRepository;
use App\TokenAuth;
use App\UserRepository;
use App\Validator;
$pdo = Database::buat();
Database::migrasi($pdo, __DIR__ . '/../bagian-3-database-dan-repository/database/schema.sql');
$pdo->exec('CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
nama TEXT NOT NULL,
password_hash TEXT NOT NULL,
dibuat_pada TEXT NOT NULL
)');
$pdo->exec('CREATE TABLE api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
token_hash TEXT NOT NULL UNIQUE,
kedaluwarsa TEXT NOT NULL,
dibuat_pada TEXT NOT NULL
)');
$repo = new TaskRepository($pdo);
$users = new UserRepository($pdo);
$auth = new TokenAuth($pdo);
$budi = $users->buatUser('[email protected]', 'sandi-budi-2026', 'Budi');
$token = $auth->issue((int) $budi['id']);
$kadalursa = $auth->issue((int) $budi['id'], '2026-01-15 09:00:00');
$router = new Router();
// Route publik: tidak memerlukan token.
$router->add('GET', '/api/kesehatan', static fn (Request $r): Response => Response::json(['status' => 'ok']));
// Route terlindungi: periksa token lebih dulu, tolak bila tidak sah.
$router->add('GET', '/api/tugas', static function (Request $r) use ($repo, $auth): Response {
if (($tolak = $auth->periksa($r)) !== null) {
return $tolak;
}
return Response::json($repo->semua());
});
$router->add('POST', '/api/tugas', static function (Request $r) use ($repo, $auth): Response {
if (($tolak = $auth->periksa($r)) !== null) {
return $tolak;
}
$errors = (new Validator())
->required('judul')
->min('judul', 3)
->max('judul', 100)
->in('status', ['belum', 'proses', 'selesai'])
->validate($r->body());
if ($errors !== []) {
return Response::json(ErrorHandler::payload($errors), 422);
}
$tugas = $repo->buat($r->body());
return Response::json(
['id' => $tugas['id'], 'judul' => $tugas['judul'], 'status' => $tugas['status']],
201,
);
});
function tampilkan(Router $router, array $data): void
{
$response = $router->dispatch(Request::fromArray($data));
printf('%-6s %-13s -> %d', $data['method'], $data['path'], $response->status());
if ($response->body() !== '') {
echo ' ', $response->body();
}
echo "\n";
}
tampilkan($router, ['method' => 'GET', 'path' => '/api/kesehatan']);
tampilkan($router, ['method' => 'GET', 'path' => '/api/tugas']);
tampilkan($router, ['method' => 'GET', 'path' => '/api/tugas', 'headers' => ['Authorization' => 'Bearer token-ngawur']]);
tampilkan($router, ['method' => 'GET', 'path' => '/api/tugas', 'headers' => ['Authorization' => "Bearer $kadalursa"]]);
tampilkan($router, ['method' => 'GET', 'path' => '/api/tugas', 'headers' => ['Authorization' => "Bearer $token"]]);
tampilkan($router, ['method' => 'POST', 'path' => '/api/tugas', 'headers' => ['Authorization' => "Bearer $token"], 'body' => ['judul' => 'Belajar token', 'status' => 'belum']]);
tampilkan($router, ['method' => 'POST', 'path' => '/api/tugas', 'headers' => ['Authorization' => "Bearer $token"], 'body' => ['judul' => 'a', 'status' => 'batal']]);Output yang Dihasilkan:
GET /api/kesehatan -> 200 {"status":"ok"}
GET /api/tugas -> 401 {"error":{"code":"UNAUTHORIZED","message":"Kirim token lewat header Authorization: Bearer <token>."}}
GET /api/tugas -> 401 {"error":{"code":"UNAUTHORIZED","message":"Token tidak dikenal atau sudah kedaluwarsa."}}
GET /api/tugas -> 401 {"error":{"code":"UNAUTHORIZED","message":"Token tidak dikenal atau sudah kedaluwarsa."}}
GET /api/tugas -> 200 [{"id":1,"judul":"Belajar PHP dasar","status":"selesai","dibuat_pada":"2026-01-05 08:00:00"},{"id":2,"judul":"Merancang REST API","status":"proses","dibuat_pada":"2026-01-10 09:30:00"},{"id":3,"judul":"Menulis dokumentasi API","status":"belum","dibuat_pada":"2026-01-12 14:15:00"}]
POST /api/tugas -> 201 {"id":4,"judul":"Belajar token","status":"belum"}
POST /api/tugas -> 422 {"error":{"code":"VALIDATION_ERROR","message":"Data yang dikirim tidak valid.","details":{"judul":["Kolom 'judul' minimal 3 karakter."],"status":["Kolom 'status' harus salah satu dari: belum, proses, selesai."]}}}Tujuh baris dispatch tersebut adalah keseluruhan cerita authorization proyek ini:
| Baris | Permintaan | Hasil | Penyebab |
|---|---|---|---|
| 1 | GET /api/kesehatan | 200 | Route publik — tidak ada pemeriksaan |
| 2 | GET /api/tugas tanpa header | 401 | Header Authorization tidak dikirim |
| 3 | Bearer token-ngawur | 401 | Token tidak terdaftar di database |
| 4 | Token kedaluwarsa | 401 | Terdaftar, tapi kedaluwarsa sudah lewat |
| 5 | Token sah | 200 | Pemeriksaan lolos, handler bekerja |
| 6 | POST dengan token sah | 201 | Perlindungan berlaku juga untuk tulis |
| 7 | POST data sampah + token sah | 422 | Lolos auth, tertahan validasi Bagian 4 |
Urutan baris 6-7 penting: authentication dan validasi adalah dua lapisan yang berbeda dan keduanya harus lolos. Request dengan data sampah tapi tanpa token akan ditolak 401 lebih dulu — tidak ada gunanya memvalidasi input dari klien yang belum terbukti sah. Pola if (($tolak = $auth->periksa($r)) !== null) return $tolak; pada setiap handler adalah bentuk paling sederhana dari apa yang biasa disebut middleware: kode yang berjalan sebelum handler dan bisa memutuskan request tidak jadi sampai ke sana. Framework besar memasangnya lewat pipeline terpisah; kita menempelkannya di baris pertama handler — hasilnya sama, tanpa mesin tambahan.
Front Controller dengan Login dan Logout #
Front controller kini memasang UserRepository dan TokenAuth, menjalankan migrasi dua tabel baru secara idempoten (CREATE TABLE IF NOT EXISTS), dan membagi route menjadi dua kelompok. Endpoint /api/meledak dari Bagian 4 dihapus karena tugas peraganya selesai:
<?php
declare(strict_types=1);
/**
* Front controller REST API Manajemen Tugas — versi Bagian 5.
*
* Seluruh route /api/tugas kini terlindungi bearer token. Route yang tetap
* publik hanya dua: GET /api/kesehatan dan POST /api/login.
*/
$projectRoot = dirname(__DIR__);
if (is_file($projectRoot . '/vendor/autoload.php')) {
require $projectRoot . '/vendor/autoload.php';
} else {
spl_autoload_register(static function (string $class): void {
$prefix = 'App\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$relative = str_replace('\\', '/', substr($class, strlen($prefix)));
$file = dirname(__DIR__) . '/src/' . $relative . '.php';
if (is_file($file)) {
require $file;
}
});
}
use App\Database;
use App\ErrorHandler;
use App\Request;
use App\Response;
use App\Router;
use App\TaskRepository;
use App\TokenAuth;
use App\UserRepository;
use App\Validator;
if (PHP_SAPI === 'cli' && !isset($_SERVER['REQUEST_METHOD'])) {
echo "REST API Manajemen Tugas\n";
echo "Jalankan: TASK_API_DB=/tmp/tasks.sqlite php -S 127.0.0.1:8097 -t public\n";
exit(0);
}
$dev = (getenv('APP_ENV') ?: 'development') === 'development';
$pdo = Database::buat();
$adaTabel = (int) $pdo->query(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'tasks'",
)->fetchColumn();
if ($adaTabel === 0) {
Database::migrasi($pdo, $projectRoot . '/database/schema.sql');
}
// Migrasi Bagian 5: tabel users dan api_tokens (idempoten).
$pdo->exec('CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
nama TEXT NOT NULL,
password_hash TEXT NOT NULL,
dibuat_pada TEXT NOT NULL
)');
$pdo->exec('CREATE TABLE IF NOT EXISTS api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
token_hash TEXT NOT NULL UNIQUE,
kedaluwarsa TEXT NOT NULL,
dibuat_pada TEXT NOT NULL
)');
$repo = new TaskRepository($pdo);
$users = new UserRepository($pdo);
$auth = new TokenAuth($pdo);
$router = new Router();
// ---------------------------------------------------------------------------
// Route publik
// ---------------------------------------------------------------------------
$router->add('GET', '/api/kesehatan', static fn (Request $r): Response => Response::json(['status' => 'ok']));
$router->add('POST', '/api/login', static function (Request $r) use ($users, $auth): Response {
$baris = $users->cariEmail((string) $r->body('email', ''));
if ($baris === null || !password_verify((string) $r->body('sandi', ''), $baris['password_hash'])) {
return Response::json(ErrorHandler::payload([
'code' => 'UNAUTHORIZED',
'message' => 'Email atau sandi salah.',
]), 401);
}
return Response::json(['token' => $auth->issue((int) $baris['id'])]);
});
// ---------------------------------------------------------------------------
// Route terlindungi — semuanya dimulai dari pemeriksaan token
// ---------------------------------------------------------------------------
$router->add('POST', '/api/logout', static function (Request $r) use ($auth): Response {
if (($tolak = $auth->periksa($r)) !== null) {
return $tolak;
}
// periksa() lolos berarti header pasti berformat 'Bearer <token>'.
$auth->revoke(substr((string) $r->header('Authorization'), strlen('Bearer ')));
return Response::noContent();
});
$router->add('GET', '/api/tugas', static function (Request $r) use ($repo, $auth): Response {
if (($tolak = $auth->periksa($r)) !== null) {
return $tolak;
}
return Response::json($repo->semua());
});
$router->add('GET', '/api/tugas/{id}', static function (Request $r, array $params) use ($repo, $auth): Response {
if (($tolak = $auth->periksa($r)) !== null) {
return $tolak;
}
$tugas = $repo->cariId((int) $params['id']);
if ($tugas === null) {
return Response::json(ErrorHandler::payload([
'code' => 'NOT_FOUND',
'message' => sprintf('Tugas dengan id %s tidak ditemukan.', $params['id']),
]), 404);
}
return Response::json($tugas);
});
$router->add('POST', '/api/tugas', static function (Request $r) use ($repo, $auth): Response {
if (($tolak = $auth->periksa($r)) !== null) {
return $tolak;
}
$errors = (new Validator())
->required('judul')
->min('judul', 3)
->max('judul', 100)
->in('status', ['belum', 'proses', 'selesai'])
->validate($r->body());
if ($errors !== []) {
return Response::json(ErrorHandler::payload($errors), 422);
}
return Response::json($repo->buat($r->body()), 201);
});
$router->add('PUT', '/api/tugas/{id}', static function (Request $r, array $params) use ($repo, $auth): Response {
if (($tolak = $auth->periksa($r)) !== null) {
return $tolak;
}
$errors = (new Validator())
->min('judul', 3)
->max('judul', 100)
->in('status', ['belum', 'proses', 'selesai'])
->validate($r->body());
if ($errors !== []) {
return Response::json(ErrorHandler::payload($errors), 422);
}
$tugas = $repo->perbarui((int) $params['id'], $r->body());
if ($tugas === null) {
return Response::json(ErrorHandler::payload([
'code' => 'NOT_FOUND',
'message' => sprintf('Tugas dengan id %s tidak ditemukan.', $params['id']),
]), 404);
}
return Response::json($tugas);
});
$router->add('DELETE', '/api/tugas/{id}', static function (Request $r, array $params) use ($repo, $auth): Response {
if (($tolak = $auth->periksa($r)) !== null) {
return $tolak;
}
if (!$repo->hapus((int) $params['id'])) {
return Response::json(ErrorHandler::payload([
'code' => 'NOT_FOUND',
'message' => sprintf('Tugas dengan id %s tidak ditemukan.', $params['id']),
]), 404);
}
return Response::noContent();
});
try {
$router->dispatch(Request::fromGlobals())->send();
} catch (Throwable $e) {
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo ErrorHandler::toJson($e, 500, $dev);
}Handler login pantas dicermati. Kegagalan login — baik email tak dikenal maupun sandi salah — dijawab pesan yang sama persis: Email atau sandi salah. Tanpa pembedaan itu, penyerang bisa memakai pesan error untuk memetakan alamat email mana yang terdaftar. Endpoint logout memakai periksa() seperti route lain, lalu mencabut token yang terbawa header; substr() yang mengambil token mentahnya aman karena periksa() yang lolos menjamin header berformat Bearer <token>.
Menguji Authentication dengan php -S #
Karena token hidup di database, sesi uji memakai database berkas seperti Bagian 4. Pengguna demo dibuat sekali lewat CLI — masih belum ada endpoint registrasi, dan itu memang di luar cakupan bagian ini:
# terminal 1 — dari direktori root proyek
$ rm -f /tmp/tasks.sqlite
$ TASK_API_DB=/tmp/tasks.sqlite php -S 127.0.0.1:8097 -t public
# terminal 2 — request pertama memicu migrasi tasks + users + api_tokens
$ curl -sS -i http://127.0.0.1:8097/api/kesehatanHTTP/1.1 200 OK
Host: 127.0.0.1:8097
Date: Tue, 13 Oct 2026 22:19:53 GMT
Connection: close
X-Powered-By: PHP/8.4.23
Content-Type: application/json; charset=utf-8
{"status":"ok"}Route tugas tanpa token ditolak 401, lengkap dengan header WWW-Authenticate: Bearer yang memberi tahu klien cara mengauthentikasi ulang:
$ curl -sS -i http://127.0.0.1:8097/api/tugasHTTP/1.1 401 Unauthorized
Host: 127.0.0.1:8097
Date: Tue, 13 Oct 2026 22:20:00 GMT
Connection: close
X-Powered-By: PHP/8.4.23
Content-Type: application/json; charset=utf-8
WWW-Authenticate: Bearer
{"error":{"code":"UNAUTHORIZED","message":"Kirim token lewat header Authorization: Bearer <token>."}}Buat pengguna demo lewat CLI, lalu coba login dengan sandi yang salah:
$ TASK_API_DB=/tmp/tasks.sqlite php -r '
require "src/Database.php";
require "src/UserRepository.php";
$pdo = App\Database::buat();
$pdo->exec("CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
nama TEXT NOT NULL,
password_hash TEXT NOT NULL,
dibuat_pada TEXT NOT NULL
)");
(new App\UserRepository($pdo))->buatUser(
"[email protected]", "sandi-budi-2026", "Budi");
echo "pengguna [email protected] siap\n";'pengguna [email protected] siap$ curl -sS -i -X POST -H 'Content-Type: application/json' \
-d '{"email":"[email protected]","sandi":"sandi-salah"}' \
http://127.0.0.1:8097/api/loginHTTP/1.1 401 Unauthorized
Host: 127.0.0.1:8097
Date: Tue, 13 Oct 2026 22:20:00 GMT
Connection: close
X-Powered-By: PHP/8.4.23
Content-Type: application/json; charset=utf-8
{"error":{"code":"UNAUTHORIZED","message":"Email atau sandi salah."}}Login yang benar menukar sandi dengan token:
$ curl -sS -i -X POST -H 'Content-Type: application/json' \
-d '{"email":"[email protected]","sandi":"sandi-budi-2026"}' \
http://127.0.0.1:8097/api/loginHTTP/1.1 200 OK
Host: 127.0.0.1:8097
Date: Tue, 13 Oct 2026 22:20:47 GMT
Connection: close
X-Powered-By: PHP/8.4.23
Content-Type: application/json; charset=utf-8
{"token":"79a2e78d8883fe1c4626cb26074d8c6ec1f2c3671cd7b8b322f1368d16594396"}Token inilah tiketnya. Sertakan lewat header Authorization, dan route tugas terbuka — untuk baca maupun tulis:
$ TOKEN=79a2e78d8883fe1c4626cb26074d8c6ec1f2c3671cd7b8b322f1368d16594396
$ curl -sS -i -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8097/api/tugasHTTP/1.1 200 OK
Host: 127.0.0.1:8097
Date: Tue, 13 Oct 2026 22:20:56 GMT
Connection: close
X-Powered-By: PHP/8.4.23
Content-Type: application/json; charset=utf-8
[{"id":1,"judul":"Belajar PHP dasar","status":"selesai","dibuat_pada":"2026-01-05 08:00:00"},{"id":2,"judul":"Merancang REST API","status":"proses","dibuat_pada":"2026-01-10 09:30:00"},{"id":3,"judul":"Menulis dokumentasi API","status":"belum","dibuat_pada":"2026-01-12 14:15:00"}]$ curl -sS -i -X POST -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"judul":"Belajar authentication","status":"belum"}' \
http://127.0.0.1:8097/api/tugasHTTP/1.1 201 Created
Host: 127.0.0.1:8097
Date: Tue, 13 Oct 2026 22:20:56 GMT
Connection: close
X-Powered-By: PHP/8.4.23
Content-Type: application/json; charset=utf-8
{"id":4,"judul":"Belajar authentication","status":"belum","dibuat_pada":"2026-10-13 22:20:56"}Terakhir, logout mencabut token — dan request berikutnya dengan token yang sama sudah tidak diakui:
$ curl -sS -i -X POST -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8097/api/logoutHTTP/1.1 204 No Content
Host: 127.0.0.1:8097
Date: Tue, 13 Oct 2026 22:21:02 GMT
Connection: close
X-Powered-By: PHP/8.4.23
Content-type: text/html; charset=UTF-8$ curl -sS -i -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8097/api/tugasHTTP/1.1 401 Unauthorized
Host: 127.0.0.1:8097
Date: Tue, 13 Oct 2026 22:21:02 GMT
Connection: close
X-Powered-By: PHP/8.4.23
Content-Type: application/json; charset=utf-8
WWW-Authenticate: Bearer
{"error":{"code":"UNAUTHORIZED","message":"Token tidak dikenal atau sudah kedaluwarsa."}}Apa yang Berubah #
Dua kelas baru dan satu berkas berubah pada tahap ini:
src/UserRepository.php—cariEmail(email)dengan normalisasi trim + lowercase, danbuatUser(email, sandi, nama)yang meng-hash sandi denganpassword_hash(); email ganda ditolak constraintUNIQUEdatabase.src/TokenAuth.php—issue(userId, kedaluwarsa?)menerbitkan token acak 64 karakter heksadesimal dan menyimpan hanya hash SHA-256-nya;resolve(token)menukar token dengan id pengguna selama belum kedaluwarsa;revoke(token)mencabut;periksa(request)membangun response 401 +WWW-Authenticateuntuk route terlindungi.public/index.php— migrasi idempoten tabelusersdanapi_tokens, endpoint publikPOST /api/login, endpoint terlindungiPOST /api/logout, dan seluruh route tugas kini dijagaperiksa().
API kini menolak penyusup, tetapi semua perilaku barunya baru dibuktikan lewat curl manual. Bagian 6 mengubah bukti itu menjadi test suite PHPUnit yang bisa dijalankan ulang kapan pun: satu perintah, seluruh alur — login, akses terlindungi, validasi, 404 — diperiksa otomatis.
Cara Menjalankan #
Skenario lengkap dari CLI dan dari web server:
# 1. Siklus token dari CLI — salin blok "Siklus Hidup Token di TokenAuth"
# ke berkas contoh-token.php di dalam folder bagian ini, lalu jalankan
php contoh-token.php
# 2. Web server + alur login/logout (dari root proyek)
rm -f /tmp/tasks.sqlite
TASK_API_DB=/tmp/tasks.sqlite php -S 127.0.0.1:8097 -t public
curl -sS -i http://127.0.0.1:8097/api/tugas # 401 tanpa token
# 3. Pemeriksaan sintaks kelas baru
php -l src/UserRepository.php && php -l src/TokenAuth.phpDua berkas pendukung halaman ini — src/TokenAuth.php dan src/UserRepository.php — tersedia di bagian bawah halaman. Untuk pendalaman, baca dokumentasi resmi tentang password_hash()
, password_verify()
, random_bytes()
, serta HTTP authentication di PHP
. Pintu sudah terkunci rapat; saatnya memastikan tidak ada engsel yang lepas — Bagian 6 menyusun test suite untuk seluruh API.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
src/TokenAuth.php
Unduh berkas<?php
declare(strict_types=1);
namespace App;
use PDO;
/**
* Penerbit dan pemeriksa bearer token.
*
* Token adalah string acak 64 karakter heksadesimal (32 byte dari
* random_bytes()). Yang disimpan di database hanyalah hash SHA-256-nya:
* token tidak bisa dipulihkan dari hash-nya, jadi kebocoran tabel
* api_tokens tidak otomatis menjadi jurang pemalsuan identitas.
*
* Siklus hidupnya tiga method: issue() membuat token untuk satu pengguna,
* resolve() menukar token dengan id pengguna selama belum kedaluwarsa,
* dan revoke() menghapus token (logout).
*/
final class TokenAuth
{
/** Masa hidup token default: 3600 detik (1 jam). */
private const MASA_HIDUP = 3600;
public function __construct(private readonly PDO $pdo)
{
}
/**
* Terbitkan token baru untuk satu pengguna.
*
* @param int $userId id pengguna pemilik token.
* @param string|null $kedaluwarsa waktu kedaluwarsa format 'Y-m-d H:i:s';
* null berarti sekarang + 1 jam.
*
* @return string token mentah yang hanya boleh dilihat klien sekali ini.
*/
public function issue(int $userId, ?string $kedaluwarsa = null): string
{
$token = bin2hex(random_bytes(32));
$stmt = $this->pdo->prepare(
'INSERT INTO api_tokens (user_id, token_hash, kedaluwarsa, dibuat_pada) '
. 'VALUES (:user_id, :token_hash, :kedaluwarsa, :dibuat_pada)',
);
$stmt->execute([
'user_id' => $userId,
'token_hash' => hash('sha256', $token),
'kedaluwarsa' => $kedaluwarsa ?? date('Y-m-d H:i:s', time() + self::MASA_HIDUP),
'dibuat_pada' => date('Y-m-d H:i:s'),
]);
return $token;
}
/**
* Uraikan token menjadi id pengguna.
*
* Token dikenali lewat hash-nya, lalu masa berlakunya diperiksa dengan
* perbandingan string: format 'Y-m-d H:i:s' terurut leksikografis sama
* dengan urutan waktunya, sehingga kedaluwarsa < sekarang cukup
* dibandingkan sebagai teks.
*
* @return int|null id pengguna bila token dikenal dan masih hidup,
* null bila token tidak dikenal atau kedaluwarsa.
*/
public function resolve(string $token): ?int
{
$stmt = $this->pdo->prepare(
'SELECT user_id, kedaluwarsa FROM api_tokens WHERE token_hash = :token_hash',
);
$stmt->execute(['token_hash' => hash('sha256', $token)]);
$baris = $stmt->fetch();
if ($baris === false) {
return null;
}
if ($baris['kedaluwarsa'] < date('Y-m-d H:i:s')) {
return null;
}
return (int) $baris['user_id'];
}
/**
* Cabut satu token — dipakai endpoint logout.
*
* @return bool true bila token ditemukan dan dihapus.
*/
public function revoke(string $token): bool
{
$stmt = $this->pdo->prepare(
'DELETE FROM api_tokens WHERE token_hash = :token_hash',
);
$stmt->execute(['token_hash' => hash('sha256', $token)]);
return $stmt->rowCount() > 0;
}
/**
* Pintu pemeriksaan untuk route terlindungi (pola middleware).
*
* Baca header Authorization milik request: bila formatnya sah dan
* token-nya masih hidup, kembalikan null dan biarkan handler bekerja.
* Bila tidak, kembalikan response 401 lengkap dengan header
* WWW-Authenticate: Bearer sesuai anjuran spesifikasi HTTP.
*/
public function periksa(Request $request): ?Response
{
$header = $request->header('Authorization');
if ($header === null || !str_starts_with($header, 'Bearer ')) {
return $this->tolak('Kirim token lewat header Authorization: Bearer <token>.');
}
$token = substr($header, strlen('Bearer '));
if ($this->resolve($token) === null) {
return $this->tolak('Token tidak dikenal atau sudah kedaluwarsa.');
}
return null;
}
/**
* Bangun response penolakan 401.
*/
private function tolak(string $pesan): Response
{
return Response::json(
ErrorHandler::payload([
'code' => 'UNAUTHORIZED',
'message' => $pesan,
]),
401,
)->withHeader('WWW-Authenticate', 'Bearer');
}
}
src/UserRepository.php
Unduh berkas<?php
declare(strict_types=1);
namespace App;
use PDO;
/**
* Repository tabel users di atas PDO.
*
* Menyimpan identitas pengguna API: email unik, nama tampilan, dan hash
* kata sandi — tidak pernah sandi mentah. Kata sandi diolah password_hash()
* dengan algoritma default (bcrypt), sehingga kebocoran berkas database
* tidak langsung membocorkan sandi pengguna.
*/
final class UserRepository
{
public function __construct(private readonly PDO $pdo)
{
}
/**
* Cari satu pengguna berdasarkan email.
*
* Email dinormalkan dulu (trim + lowercase) sehingga '[email protected]'
* dan '[email protected] ' menunjuk baris yang sama.
*
* @return array<string, mixed>|null baris pengguna, atau null bila tidak ada.
*/
public function cariEmail(string $email): ?array
{
$stmt = $this->pdo->prepare(
'SELECT id, email, nama, password_hash, dibuat_pada '
. 'FROM users WHERE email = :email',
);
$stmt->execute(['email' => $this->normalkan($email)]);
$baris = $stmt->fetch();
return $baris === false ? null : $baris;
}
/**
* Buat pengguna baru dan kembalikan baris lengkapnya termasuk id.
*
* Kata sandi di-hash di sini; pengirim tidak pernah bisa menentukan
* bentuk hash-nya. Email yang sudah terdaftar ditolak constraint
* UNIQUE milik database dengan PDOException SQLSTATE 23000.
*
* @return array<string, mixed>
*/
public function buatUser(string $email, string $sandi, string $nama): array
{
$stmt = $this->pdo->prepare(
'INSERT INTO users (email, nama, password_hash, dibuat_pada) '
. 'VALUES (:email, :nama, :password_hash, :dibuat_pada)',
);
$stmt->execute([
'email' => $this->normalkan($email),
'nama' => $nama,
'password_hash' => password_hash($sandi, PASSWORD_DEFAULT),
'dibuat_pada' => date('Y-m-d H:i:s'),
]);
return (array) $this->cariEmail($email);
}
/**
* Normalkan email: buang spasi di pinggir lalu kecilkan hurufnya.
*/
private function normalkan(string $email): string
{
return mb_strtolower(trim($email));
}
}