Kata sandi yang kuat sekalipun bisa dilupakan, dan aplikasi yang baik harus punya pintu darurat untuknya. Pintu darurat itulah yang menjadi target favorit penyerang: tidak perlu membobol apa pun, cukup menipu pintu ini sampai terbuka untuk akun orang lain. Pada bagian ini kita membangun PasswordResetService dengan token acak sekali pakai yang hanya tersimpan sebagai hash sha256, masa berlaku tepat satu jam, dan respons yang identik untuk email terdaftar maupun tidak. Seluruh alurnya bisa diuji dari CLI karena waktu kita bekukan lewat jam yang disuntikkan — tidak perlu menunggu satu jam sungguhan untuk membuktikan token kedaluwarsa ditolak.
Siklus Hidup Token Reset #
Alur reset yang aman bergerak dari satu pertanyaan: bagaimana membuktikan bahwa orang yang meminta kata sandi baru benar-benar pemilik akun, tanpa meminta kata sandi lamanya? Jawaban standarnya adalah kanal kedua — email. Pemohon menerima tautan berisi token acak; siapa pun yang bisa membuka tautan itu dianggap membuktikan diri sebagai pemilik kotak masuk, dan kotak masuk dianggap milik pemilik akun. Materi konseptual lengkapnya ada pada chapter Password Reset ; fokus kita adalah implementasinya yang aman:
Diagram di atas merangkum lima sifat yang kode kita harus penuhi. Hash-at-rest: database menyimpan hash('sha256', $token), bukan token mentah — kebocoran database tidak serta-merta membocorkan token yang masih hidup. Sekali pakai: baris token dihapus pada pemakaian pertama, sehingga tautan yang sudah dipakai mati untuk selamanya. Berbatas waktu: token kedaluwarsa satu jam setelah diminta, mempersempit jendela serangan. Anti-enumerasi: permintaan untuk email tak dikenal menghasilkan respons yang sama persis dengan yang terdaftar. Pembatalan sesi: kata sandi yang berganti membuat sesi login lama basi, agar penyerang yang sedang menumpang sesi korban ikut terlempar.
Skema: Tabel password_resets dan Penanda pada users #
Dua perubahan skema menyertai layanan ini, keduanya dipasang oleh PasswordResetService::pasangSkema():
CREATE TABLE IF NOT EXISTS password_resets (
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
);
CREATE INDEX IF NOT EXISTS idx_password_resets_user_id
ON password_resets(user_id);
ALTER TABLE users ADD COLUMN kata_sandi_diubah_pada TEXT NULL;Bedah kolomnya satu per satu. token_hash TEXT NOT NULL UNIQUE menyimpan hash sha256 token; constraint UNIQUE sekaligus membuat index sehingga pencarian token — query paling panas di alur ini — cepat tanpa kerja tambahan. kedaluwarsa dan dibuat_pada berformat Y-m-d H:i:s dari jam yang dibekukan, sehingga perbandingan masa berlaku cukup perbandingan string yang urutannya identik dengan urutan waktunya. user_id merujuk users(id) sebagai foreign key. Sementara itu ALTER TABLE menempelkan kolom kata_sandi_diubah_pada pada users — pola migrasi terjaga PRAGMA table_info yang sama dengan kolom role pada Bagian 4. Kolom inilah penanda pembatalan sesi: NULL berarti kata sandi belum pernah diganti lewat alur reset; begitu terisi waktu, semua sesi yang login lebih awal dari waktu itu dianggap basi.
Jam yang Bisa Dibekukan #
Masa berlaku satu jam mustahil diuji bila kode membaca waktu nyata — tidak ada yang mau menunggu 3.600 detik demi melihat token ditolak. Solusinya adalah dependency injection waktu: layanan tidak memanggil new DateTimeImmutable() sendiri, melainkan menerima sebuah Closure yang mengembalikan waktu saat ini. Class JamBeku adalah implementasi closure itu untuk demo dan test:
<?php
require __DIR__ . '/src/PasswordResetService.php';
$jam = new JamBeku('2026-02-01 09:00:00');
echo 'Waktu jam sekarang : ' . $jam->now()->format('Y-m-d H:i:s T') . "\n";
$jam->maju('PT1H25M');
echo 'Setelah maju : ' . $jam->now()->format('Y-m-d H:i:s T') . "\n";Output yang Dihasilkan:
Waktu jam sekarang : 2026-02-01 09:00:00 WIB
Setelah maju : 2026-02-01 10:25:00 WIBJamBeku berhenti pada waktu yang diberikan constructor dan hanya bergeser ketika maju() dipanggil dengan spesifikasi DateInterval. Saat membangun layanan, kita menyuntikkan $jam->now(...) — first-class callable syntax PHP 8.1 yang menghasilkan Closure terikat pada objek $jam, sehingga setiap kali layanan bertanya waktu, jawabannya selalu keadaan jam saat itu. Di produksi, closure yang sama cukup diganti fn (): DateTimeImmutable => new DateTimeImmutable() — kode layanan tidak berubah sedikit pun.
Class PasswordResetService #
Berikut isi lengkap src/PasswordResetService.php sebagaimana tersedia pada berkas sumber di bagian bawah halaman:
<?php
declare(strict_types=1);
/**
* Jam yang berhenti mengikuti waktu nyata.
*
* Waktu sungguhan (time(), new DateTimeImmutable("now")) bergerak
* sendiri dan tidak bisa diatur, sehingga masa berlaku token mustahil
* diuji tanpa menunggu satu jam yang sesungguhnya. JamBeku berhenti
* pada waktu yang ditentukan lewat constructor dan hanya maju ketika
* maju() dipanggil - penuh kendali di tangan pemanggil.
*/
final class JamBeku
{
private DateTimeImmutable $waktu;
public function __construct(string $mulai)
{
$this->waktu = new DateTimeImmutable($mulai, new DateTimeZone('Asia/Jakarta'));
}
public function now(): DateTimeImmutable
{
return $this->waktu;
}
public function maju(string $spesifikasi): void
{
$this->waktu = $this->waktu->add(new DateInterval($spesifikasi));
}
}
/**
* Exception internal: token tidak dikenal, atau sudah pernah dipakai.
*/
final class TokenResetTidakSahException extends RuntimeException
{
}
/**
* Exception internal: token ditemukan tetapi masa berlakunya lewat.
*/
final class TokenResetKedaluwarsaException extends RuntimeException
{
}
/**
* Exception ramah pengguna: kata sandi baru tidak lolos validasi.
*/
final class PasswordBaruTidakValidException extends RuntimeException
{
}
/**
* Layanan reset kata sandi dengan token sekali pakai.
*
* Siklus hidupnya: mintaReset(email) membuat token acak dan
* menyimpan HASH sha256-nya berikut masa berlaku satu jam;
* jalankanReset(token, sandiBaru) mencari lewat hash, menolak
* token kedaluwarsa, menghapus baris token (sekali pakai),
* mengganti hash kata sandi, dan mencatat penanda pembatalan
* sesi. Respons untuk email yang tidak terdaftar dibuat sama
* persis dengan yang terdaftar agar alur ini tidak bisa dipakai
* memetakan email pengguna.
*/
final class PasswordResetService
{
/** Masa berlaku token sejak diminta. */
private const MASA_BERLAKU = 'PT1H';
private const SKEMA_RESET = '
CREATE TABLE IF NOT EXISTS password_resets (
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
);
CREATE INDEX IF NOT EXISTS idx_password_resets_user_id
ON password_resets(user_id);
';
public function __construct(
private PDO $pdo,
private UserRepository $users,
private Closure $jam,
) {
}
/**
* Pasang tabel password_resets dan kolom penanda pada users.
*
* CREATE TABLE IF NOT EXISTS sudah idempoten; kolom penanda
* kata_sandi_diubah_pada dijaga pemeriksaan PRAGMA table_info
* karena ALTER TABLE ... ADD COLUMN tidak bisa dijalankan dua
* kali - pola yang sama dengan kolom role pada Bagian 4.
*/
public static function pasangSkema(PDO $pdo): void
{
$pdo->exec(self::SKEMA_RESET);
if (!self::kolomAda($pdo, 'users', 'kata_sandi_diubah_pada')) {
$pdo->exec('ALTER TABLE users ADD COLUMN kata_sandi_diubah_pada TEXT NULL');
}
}
/**
* Permintaan reset: kembalikan token untuk "dikirimkan" ke email.
*
* Email yang tidak terdaftar tetap menerima token berpola sama -
* hanya saja tidak ada baris yang tersimpan, sehingga token itu
* tidak pernah bisa dipakai. Dari luar, kedua kasus tidak bisa
* dibedakan: panjang, format, dan waktu responsnya seragam.
*/
public function mintaReset(string $email): string
{
// Token dibuat lebih dulu agar kasus email dikenal maupun
// tidak dikenal menempuh urutan kerja yang sama.
$token = bin2hex(random_bytes(32));
$user = $this->users->cariEmail($email);
if ($user !== null) {
$sekarang = $this->sekarang();
$stmt = $this->pdo->prepare(
'INSERT INTO password_resets (user_id, token_hash, kedaluwarsa, dibuat_pada)
VALUES (?, ?, ?, ?)'
);
$stmt->execute([
$user['id'],
hash('sha256', $token),
$sekarang->add(new DateInterval(self::MASA_BERLAKU))->format('Y-m-d H:i:s'),
$sekarang->format('Y-m-d H:i:s'),
]);
}
return $token;
}
/**
* Jalankan reset dengan token: ganti kata sandi dan buang token.
*
* Seluruh penulisan dibungkus transaction: bila salah satu langkah
* gagal, keempatnya batal - tidak ada kata sandi yang terganti
* tanpa token terhapus, dan tidak ada token yang terhapus tanpa
* kata sandi terganti.
*
* @throws PasswordBaruTidakValidException bila sandi baru < 8 karakter
* @throws TokenResetTidakSahException bila token tidak dikenal / terpakai
* @throws TokenResetKedaluwarsaException bila token melewati masa berlaku
*/
public function jalankanReset(string $token, string $passwordBaru): void
{
if (strlen($passwordBaru) < 8) {
throw new PasswordBaruTidakValidException('Kata sandi baru minimal 8 karakter.');
}
$this->pdo->beginTransaction();
try {
$stmt = $this->pdo->prepare(
'SELECT id, user_id, kedaluwarsa FROM password_resets WHERE token_hash = ?'
);
$stmt->execute([hash('sha256', $token)]);
$baris = $stmt->fetch(PDO::FETCH_ASSOC);
if ($baris === false) {
throw new TokenResetTidakSahException('Token reset tidak dikenal atau sudah dipakai.');
}
if ($this->sekarang()->format('Y-m-d H:i:s') > $baris['kedaluwarsa']) {
throw new TokenResetKedaluwarsaException('Token reset melewati masa berlaku satu jam.');
}
// Hapus token lebih dulu: sekali pakai berarti permintaan
// ulang dengan token yang sama menemukan baris yang tiada.
$hapus = $this->pdo->prepare('DELETE FROM password_resets WHERE id = ?');
$hapus->execute([$baris['id']]);
if (!$this->users->updateHash(
(int) $baris['user_id'],
password_hash($passwordBaru, PASSWORD_BCRYPT)
)) {
throw new RuntimeException('Pengguna pemilik token tidak ditemukan.');
}
// Penanda pembatalan sesi: sesi yang login lebih awal dari
// waktu ini dianggap basi oleh sesiMasihSah().
$tanda = $this->pdo->prepare(
'UPDATE users SET kata_sandi_diubah_pada = ? WHERE id = ?'
);
$tanda->execute([
$this->sekarang()->format('Y-m-d H:i:s'),
$baris['user_id'],
]);
$this->pdo->commit();
} catch (Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
}
/**
* Apakah sesi yang login pada $waktuLogin masih sah?
*
* Sesi dianggap sah bila penggunanya dikenal dan login terjadi
* SETELAH kata sandi terakhir diganti. Login pada detik yang
* sama dengan penggantian dianggap belum sah - konservatif,
* tetapi lebih aman daripada sebaliknya.
*/
public function sesiMasihSah(int $userId, DateTimeImmutable $waktuLogin): bool
{
$stmt = $this->pdo->prepare(
'SELECT kata_sandi_diubah_pada FROM users WHERE id = ?'
);
$stmt->execute([$userId]);
$penanda = $stmt->fetchColumn();
if ($penanda === false) {
return false;
}
if ($penanda === null) {
return true;
}
return $waktuLogin->format('Y-m-d H:i:s') > (string) $penanda;
}
/**
* Jumlah baris token yang tersimpan - untuk memeriksa bahwa
* pemakaian token benar-benar menghapusnya dan bahwa email
* asing tidak pernah meninggalkan baris.
*/
public function jumlahTokenTersimpan(): int
{
return (int) $this->pdo->query(
'SELECT COUNT(*) FROM password_resets'
)->fetchColumn();
}
private function sekarang(): DateTimeImmutable
{
return ($this->jam)();
}
/**
* Apakah sebuah kolom sudah ada pada tabel? PRAGMA table_info
* tidak menerima parameter bound, jadi nama tabel diinterpolasi
* langsung - aman karena berasal dari kode, bukan masukan.
*/
private static function kolomAda(PDO $pdo, string $tabel, string $kolom): bool
{
$baris = $pdo->query('PRAGMA table_info(' . $tabel . ')')
->fetchAll(PDO::FETCH_ASSOC);
return in_array($kolom, array_column($baris, 'name'), true);
}
}Keputusan-keputusan penting pada class tersebut perlu dibedah satu per satu:
mintaReset()membuat token sebelum mencari email. Urutan ini membuat kedua kasus menempuh kerja yang sama persis: saturandom_bytes(), satu pencarian email, satu nilai kembalian berpola sama. Email tak dikenal tidak menyisakan baris apa pun, namun pemanggil tidak bisa membedakannya dari kasus yang sah.jalankanReset()memvalidasi sandi baru sebelum menyentuh token. Penolakan sandi pendek terjadi sebelumbeginTransaction(), sehingga token tidak terbuang oleh kesalahan ketik — pemohon yang salah mengetik boleh mencoba lagi dengan token yang sama.- Empat langkah penulisan dibungkus satu transaction. Cari baris token, tolak bila kedaluwarsa, hapus token, ganti hash sandi, catat penanda — bila salah satu melempar,
rollBack()membatalkan semuanya. Sifat ini yang membuat “token terhapus” dan “sandi terganti” selalu berpasangan, tidak pernah yang satu tanpa yang lain. - Token dicari lewat hash, bukan nilainya. Parameter query adalah
hash('sha256', $token); nilai mentah tidak pernah menyentuh SQL maupun log query. Pola ini adalah cara yang tepat membandingkan nilai rahasia dengan kolom: hitung hash-nya di sisi PHP lalu cari hasilnya, jangan kirim nilai mentah ke database. sesiMasihSah()membaca penanda, bukan menghancurkan sesi. Sesi PHP tersimpan dalam berkas per-session id yang tidak bisa dicari berdasar pengguna; cara portabelnya adalah menandai batas waktu pada data pengguna lalu memeriksa usia login di setiap request. ImplementasiAuthServiceBagian 3 tidak perlu berubah — pada aplikasi produksi,login()cukup turut mencatat waktu login ke session, dan middleware memanggilsesiMasihSah()sebelum route berjalan.
Walkthrough: Reset Sukses dari Awal sampai Akhir #
Skrip berikut menjalankan seluruh siklus hidup token untuk akun Siti: permintaan pukul 09:15, penolakan sandi pendek, reset sukses pukul 09:16, verifikasi dari luar lewat attemptLogin(), percobaan memakai token yang sama lagi, dan status sesi lama versus baru:
<?php
require __DIR__ . '/../bagian-2-registrasi-dan-password-hashing/src/UserRepository.php';
require __DIR__ . '/../bagian-3-login-dan-session/src/AuthService.php';
require __DIR__ . '/src/PasswordResetService.php';
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-user-schema/schema.sql'));
PasswordResetService::pasangSkema($pdo);
$users = new UserRepository($pdo);
$auth = new AuthService($users);
$jam = new JamBeku('2026-02-01 09:00:00');
$reset = new PasswordResetService($pdo, $users, $jam->now(...));
// Siti login pukul 09:00 - sesi ini akan kami tuding nanti.
$waktuLoginLama = $jam->now();
// Pukul 09:15 Siti lupa kata sandi dan meminta reset.
$jam->maju('PT15M');
$token = $reset->mintaReset('[email protected]');
$hashTersimpan = $pdo->query('SELECT token_hash FROM password_resets')->fetchColumn();
echo "== Permintaan reset (pukul 09:15) ==\n";
echo 'Token kembali ke pemanggil : ' . strlen($token) . " karakter heksadesimal\n";
echo 'Yang tersimpan di database : hash sha256 sepanjang ' . strlen((string) $hashTersimpan) . " karakter\n";
echo 'Isinya sama dengan token? : ' . var_export(hash_equals($token, (string) $hashTersimpan), true) . "\n";
// Pukul 09:16 token dipakai. Sandi terlalu pendek ditolak lebih dulu.
$jam->maju('PT1M');
try {
$reset->jalankanReset($token, 'pendek');
echo "Sandi pendek lolos (seharusnya tidak)\n";
} catch (PasswordBaruTidakValidException $e) {
echo "\n== Sandi baru ditolak validasi ==\n";
echo " {$e->getMessage()}\n";
echo ' Token ikut terhapus? : ' . var_export($reset->jumlahTokenTersimpan() === 0, true) . "\n";
}
$reset->jalankanReset($token, 'rahasia-baru-2026');
$loginLama = $auth->attemptLogin('[email protected]', 'sandi-siti-2026');
$loginBaru = $auth->attemptLogin('[email protected]', 'rahasia-baru-2026');
$penanda = $pdo->query(
"SELECT kata_sandi_diubah_pada FROM users WHERE email = '[email protected]'"
)->fetchColumn();
echo "\n== Reset sukses (pukul 09:16) ==\n";
echo 'Login sandi lama : ' . var_export($loginLama !== null, true) . "\n";
echo 'Login sandi baru : ' . var_export($loginBaru !== null, true) . "\n";
echo 'Token terhapus (sekali pakai) : ' . var_export($reset->jumlahTokenTersimpan() === 0, true) . "\n";
echo "Penanda pembatalan sesi : $penanda\n";
try {
$reset->jalankanReset($token, 'sudi-dipakai-lagi-2026');
echo "Token dipakai ulang (seharusnya tidak)\n";
} catch (TokenResetTidakSahException $e) {
echo "\n== Token dipakai ulang ==\n";
echo " {$e->getMessage()}\n";
}
// Sesi lama login 09:00 vs sesi baru login 09:20.
$jam->maju('PT4M');
echo "\n== Status sesi setelah reset ==\n";
echo 'Sesi login 09:00 (sebelum reset) : ' . var_export($reset->sesiMasihSah(2, $waktuLoginLama), true) . "\n";
echo 'Sesi login 09:20 (sesudah reset) : ' . var_export($reset->sesiMasihSah(2, $jam->now()), true) . "\n";Output yang Dihasilkan:
== Permintaan reset (pukul 09:15) ==
Token kembali ke pemanggil : 64 karakter heksadesimal
Yang tersimpan di database : hash sha256 sepanjang 64 karakter
Isinya sama dengan token? : false
== Sandi baru ditolak validasi ==
Kata sandi baru minimal 8 karakter.
Token ikut terhapus? : false
== Reset sukses (pukul 09:16) ==
Login sandi lama : false
Login sandi baru : true
Token terhapus (sekali pakai) : true
Penanda pembatalan sesi : 2026-02-01 09:16:00
== Token dipakai ulang ==
Token reset tidak dikenal atau sudah dipakai.
== Status sesi setelah reset ==
Sesi login 09:00 (sebelum reset) : false
Sesi login 09:20 (sesudah reset) : trueBaca output bagian demi bagian. Paruh pertama membuktikan hash-at-rest: token yang kembali ke pemanggil dan string yang tersimpan di database sama-sama panjang 64 karakter, tetapi hash_equals() menyatakan keduanya berbeda — yang tersimpan memang hash sha256, bukan token. Penolakan sandi pendek tidak menghapus token (Token ikut terhapus? : false), sehingga percobaan kedua dengan sandi yang benar masih bisa memakai token yang sama. Setelah reset sukses, sandi lama mati dan sandi baru hidup — dibuktikan dari luar lewat attemptLogin(), bukan lewat janji class-nya sendiri. Penanda sesi tercatat pukul 09:16 persis, dan dua baris terakhir menunjukkan pembagian nasib: sesi yang login sebelum penanda tidak lagi sah, sesi yang login sesudahnya sah. Percobaan memakai token untuk kedua kalinya menemukan baris yang sudah dihapus, dan TokenResetTidakSahException adalah akhir ceritanya.
Token Kedaluwarsa dan Email Tak Dikenal #
Dua sifat terakhir butuh skenario tersendiri karena keduanya tentang penolakan. Skrip berikut membiarkan token Dewi lewat masa berlakunya satu menit sebelum dipakai, lalu membandingkan respons mintaReset() untuk email terdaftar dan email hantu:
<?php
require __DIR__ . '/../bagian-2-registrasi-dan-password-hashing/src/UserRepository.php';
require __DIR__ . '/../bagian-3-login-dan-session/src/AuthService.php';
require __DIR__ . '/src/PasswordResetService.php';
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-user-schema/schema.sql'));
PasswordResetService::pasangSkema($pdo);
$users = new UserRepository($pdo);
$auth = new AuthService($users);
$jam = new JamBeku('2026-02-01 12:00:00');
$reset = new PasswordResetService($pdo, $users, $jam->now(...));
// Token diminta pukul 12:00 -> berlaku sampai 13:00.
$tokenDewi = $reset->mintaReset('[email protected]');
// Penyerang baru membuka tautan pukul 13:01.
$jam->maju('PT61M');
try {
$reset->jalankanReset($tokenDewi, 'sandi-pengganti-2026');
echo "Token kedaluwarsa lolos (seharusnya tidak)\n";
} catch (TokenResetKedaluwarsaException $e) {
echo "== Token kedaluwarsa (diminta 12:00, dipakai 13:01) ==\n";
echo " sebab internal : {$e->getMessage()}\n";
echo " pesan ke user : Token tidak valid atau kedaluwarsa. Silakan minta ulang.\n";
}
$sandiLamaUtuh = $auth->attemptLogin('[email protected]', 'sandi-dewi-2026') !== null;
echo ' sandi lama masih berlaku : ' . var_export($sandiLamaUtuh, true) . "\n";
echo ' baris token di database : ' . $reset->jumlahTokenTersimpan()
. " (menunggu pembersihan berkala)\n";
// Enumerasi: bandingkan respons untuk email terdaftar vs tidak.
$tokenNyata = $reset->mintaReset('[email protected]');
$sebelum = $reset->jumlahTokenTersimpan();
$tokenHantu = $reset->mintaReset('[email protected]');
$hantuMeninggalkanBaris = $reset->jumlahTokenTersimpan() > $sebelum;
echo "\n== Enumerasi email: terdaftar vs tidak ==\n";
echo 'Respons untuk [email protected] : token ' . strlen($tokenNyata) . " karakter\n";
echo 'Respons untuk [email protected] : token ' . strlen($tokenHantu) . " karakter\n";
echo 'Format keduanya heksadesimal : ' . var_export(ctype_xdigit($tokenNyata) && ctype_xdigit($tokenHantu), true) . "\n";
echo 'Email hantu meninggalkan baris : ' . var_export($hantuMeninggalkanBaris, true) . "\n";
try {
$reset->jalankanReset($tokenHantu, 'sandi-serangan-2026');
echo "Token hantu lolos (seharusnya tidak)\n";
} catch (TokenResetTidakSahException $e) {
echo "\n== Token hantu dipakai reset ==\n";
echo " sebab internal : {$e->getMessage()}\n";
echo " pesan ke user : Token tidak valid atau kedaluwarsa. Silakan minta ulang.\n";
}Output yang Dihasilkan:
== Token kedaluwarsa (diminta 12:00, dipakai 13:01) ==
sebab internal : Token reset melewati masa berlaku satu jam.
pesan ke user : Token tidak valid atau kedaluwarsa. Silakan minta ulang.
sandi lama masih berlaku : true
baris token di database : 1 (menunggu pembersihan berkala)
== Enumerasi email: terdaftar vs tidak ==
Respons untuk [email protected] : token 64 karakter
Respons untuk [email protected] : token 64 karakter
Format keduanya heksadesimal : true
Email hantu meninggalkan baris : false
== Token hantu dipakai reset ==
sebab internal : Token reset tidak dikenal atau sudah dipakai.
pesan ke user : Token tidak valid atau kedaluwarsa. Silakan minta ulang.Token kedaluwarsa ditolak dan sandi lama Dewi tetap berlaku — transaction memastikan tidak ada setengah pekerjaan yang lolos. Perhatikan baris pesan ke user pada kedua penolakan di output: sebab internalnya berbeda (kedaluwarsa versus tidak dikenal), tetapi kalimat untuk pengguna persis sama. Pembedaan sebab memang berguna — untuk log dan operator — tetapi respons HTTP tidak boleh membocorkannya, persis pola pesan generik login pada Bagian 3. Sementara itu uji enumerasi menunjukkan kedua respons mintaReset() berpola identik: sama-sama token 64 karakter heksadesimal. Bedanya baru terlihat dari dalam database — jumlah baris tidak bertambah saat email hantu diminta, sehingga token hantu tidak pernah bisa dipakai, dan penyerang yang mencobanya mendapat penolakan dengan pesan yang sama seperti token bekas.
Cara Menjalankan Contoh #
Contoh pada bagian ini membutuhkan schema.sql dari Bagian 1, class dari Bagian 2 dan 3, serta PasswordResetService.php dari bagian ini. Susun foldernya sebagai berikut:
sistem-autentikasi/
├── bagian-1-user-schema/
│ └── schema.sql
├── bagian-2-registrasi-dan-password-hashing/
│ └── src/
│ ├── UserRepository.php
│ └── RegistrationService.php
├── bagian-3-login-dan-session/
│ └── src/
│ └── AuthService.php
├── bagian-4-role-dan-permissions/
│ └── src/
│ └── AccessControl.php
└── bagian-5-password-reset/
├── src/
│ └── PasswordResetService.php
└── uji-reset.phpSimpan salah satu walkthrough di atas sebagai uji-reset.php di dalam folder bagian-5-password-reset, lalu jalankan:
php uji-reset.phpSeluruh contoh memakai :memory: dan jam beku, sehingga hasilnya identik berapa pun kali dan kapan pun dijalankan — pukul 09:16 pada output akan selalu pukul 09:16. Pada aplikasi web sungguhan, mintaReset() dipanggil route POST /reset yang menerima email dari form, dan jalankanReset() dipanggil route POST /reset/konfirmasi yang menerima token dari query string tautan di email; keduanya membalas pesan yang sama tanpa membedakan nasib email.
Apa yang Berubah #
Proyek kini punya pintu darurat yang tidak mudah dibobol:
- Berkas
src/PasswordResetService.phpbaru berisiJamBeku, tiga exception, danPasswordResetService:mintaReset()yang enumeration-safe,jalankanReset()transaksional dengan token sekali pakai dan masa berlaku satu jam,sesiMasihSah()untuk pembatalan sesi, sertapasangSkema()yang memasang tabelpassword_resetsdan kolom penandakata_sandi_diubah_pada. - Kata sandi kini bisa diganti lewat
updateHash()yang sejak Bagian 2 memang menunggu pemanggil ini; hash baru tetap dihasilkanpassword_hash()cost default. - Sesi login lama menjadi basi setelah reset lewat penanda waktu pada data pengguna — diperiksa middleware, tanpa mengubah
AuthServiceBagian 3. - Waktu menjadi dependency yang disuntikkan lewat
Closure, sehingga masa berlaku token teruji penuh tanpa jam berdetik sungguhan.
Ringkasan #
Alur reset yang aman ternyata lebih banyak tentang menolak dengan benar: token yang tidak dikenal, yang kedaluwarsa, dan email yang tidak terdaftar semuanya harus ditolak tanpa memberi tahu penyerang kenapa. Hash-at-rest menutup kebocoran database, sekali pakai menutup pemakaian ulang tautan, masa berlaku satu jam mempersempit jendela, dan respons seragam menutup pemetaan email. Dengan lima bagian, sistem autentikasi ini lengkap sebagai perangkat — yang belum lengkap adalah kepercayaan padanya. Bagian 6 menutup proyek dengan mengubah semua janji keamanan di atas menjadi test PHPUnit yang berjaga setiap kali kode berubah.
Referensi #
- Password Reset — Course PHP
- random_bytes() — php.net
- hash() — php.net
- OWASP Forgot Password Cheat Sheet
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
src/PasswordResetService.php
Unduh berkas<?php
declare(strict_types=1);
/**
* Jam yang berhenti mengikuti waktu nyata.
*
* Waktu sungguhan (time(), new DateTimeImmutable("now")) bergerak
* sendiri dan tidak bisa diatur, sehingga masa berlaku token mustahil
* diuji tanpa menunggu satu jam yang sesungguhnya. JamBeku berhenti
* pada waktu yang ditentukan lewat constructor dan hanya maju ketika
* maju() dipanggil - penuh kendali di tangan pemanggil.
*/
final class JamBeku
{
private DateTimeImmutable $waktu;
public function __construct(string $mulai)
{
$this->waktu = new DateTimeImmutable($mulai, new DateTimeZone('Asia/Jakarta'));
}
public function now(): DateTimeImmutable
{
return $this->waktu;
}
public function maju(string $spesifikasi): void
{
$this->waktu = $this->waktu->add(new DateInterval($spesifikasi));
}
}
/**
* Exception internal: token tidak dikenal, atau sudah pernah dipakai.
*/
final class TokenResetTidakSahException extends RuntimeException
{
}
/**
* Exception internal: token ditemukan tetapi masa berlakunya lewat.
*/
final class TokenResetKedaluwarsaException extends RuntimeException
{
}
/**
* Exception ramah pengguna: kata sandi baru tidak lolos validasi.
*/
final class PasswordBaruTidakValidException extends RuntimeException
{
}
/**
* Layanan reset kata sandi dengan token sekali pakai.
*
* Siklus hidupnya: mintaReset(email) membuat token acak dan
* menyimpan HASH sha256-nya berikut masa berlaku satu jam;
* jalankanReset(token, sandiBaru) mencari lewat hash, menolak
* token kedaluwarsa, menghapus baris token (sekali pakai),
* mengganti hash kata sandi, dan mencatat penanda pembatalan
* sesi. Respons untuk email yang tidak terdaftar dibuat sama
* persis dengan yang terdaftar agar alur ini tidak bisa dipakai
* memetakan email pengguna.
*/
final class PasswordResetService
{
/** Masa berlaku token sejak diminta. */
private const MASA_BERLAKU = 'PT1H';
private const SKEMA_RESET = '
CREATE TABLE IF NOT EXISTS password_resets (
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
);
CREATE INDEX IF NOT EXISTS idx_password_resets_user_id
ON password_resets(user_id);
';
public function __construct(
private PDO $pdo,
private UserRepository $users,
private Closure $jam,
) {
}
/**
* Pasang tabel password_resets dan kolom penanda pada users.
*
* CREATE TABLE IF NOT EXISTS sudah idempoten; kolom penanda
* kata_sandi_diubah_pada dijaga pemeriksaan PRAGMA table_info
* karena ALTER TABLE ... ADD COLUMN tidak bisa dijalankan dua
* kali - pola yang sama dengan kolom role pada Bagian 4.
*/
public static function pasangSkema(PDO $pdo): void
{
$pdo->exec(self::SKEMA_RESET);
if (!self::kolomAda($pdo, 'users', 'kata_sandi_diubah_pada')) {
$pdo->exec('ALTER TABLE users ADD COLUMN kata_sandi_diubah_pada TEXT NULL');
}
}
/**
* Permintaan reset: kembalikan token untuk "dikirimkan" ke email.
*
* Email yang tidak terdaftar tetap menerima token berpola sama -
* hanya saja tidak ada baris yang tersimpan, sehingga token itu
* tidak pernah bisa dipakai. Dari luar, kedua kasus tidak bisa
* dibedakan: panjang, format, dan waktu responsnya seragam.
*/
public function mintaReset(string $email): string
{
// Token dibuat lebih dulu agar kasus email dikenal maupun
// tidak dikenal menempuh urutan kerja yang sama.
$token = bin2hex(random_bytes(32));
$user = $this->users->cariEmail($email);
if ($user !== null) {
$sekarang = $this->sekarang();
$stmt = $this->pdo->prepare(
'INSERT INTO password_resets (user_id, token_hash, kedaluwarsa, dibuat_pada)
VALUES (?, ?, ?, ?)'
);
$stmt->execute([
$user['id'],
hash('sha256', $token),
$sekarang->add(new DateInterval(self::MASA_BERLAKU))->format('Y-m-d H:i:s'),
$sekarang->format('Y-m-d H:i:s'),
]);
}
return $token;
}
/**
* Jalankan reset dengan token: ganti kata sandi dan buang token.
*
* Seluruh penulisan dibungkus transaction: bila salah satu langkah
* gagal, keempatnya batal - tidak ada kata sandi yang terganti
* tanpa token terhapus, dan tidak ada token yang terhapus tanpa
* kata sandi terganti.
*
* @throws PasswordBaruTidakValidException bila sandi baru < 8 karakter
* @throws TokenResetTidakSahException bila token tidak dikenal / terpakai
* @throws TokenResetKedaluwarsaException bila token melewati masa berlaku
*/
public function jalankanReset(string $token, string $passwordBaru): void
{
if (strlen($passwordBaru) < 8) {
throw new PasswordBaruTidakValidException('Kata sandi baru minimal 8 karakter.');
}
$this->pdo->beginTransaction();
try {
$stmt = $this->pdo->prepare(
'SELECT id, user_id, kedaluwarsa FROM password_resets WHERE token_hash = ?'
);
$stmt->execute([hash('sha256', $token)]);
$baris = $stmt->fetch(PDO::FETCH_ASSOC);
if ($baris === false) {
throw new TokenResetTidakSahException('Token reset tidak dikenal atau sudah dipakai.');
}
if ($this->sekarang()->format('Y-m-d H:i:s') > $baris['kedaluwarsa']) {
throw new TokenResetKedaluwarsaException('Token reset melewati masa berlaku satu jam.');
}
// Hapus token lebih dulu: sekali pakai berarti permintaan
// ulang dengan token yang sama menemukan baris yang tiada.
$hapus = $this->pdo->prepare('DELETE FROM password_resets WHERE id = ?');
$hapus->execute([$baris['id']]);
if (!$this->users->updateHash(
(int) $baris['user_id'],
password_hash($passwordBaru, PASSWORD_BCRYPT)
)) {
throw new RuntimeException('Pengguna pemilik token tidak ditemukan.');
}
// Penanda pembatalan sesi: sesi yang login lebih awal dari
// waktu ini dianggap basi oleh sesiMasihSah().
$tanda = $this->pdo->prepare(
'UPDATE users SET kata_sandi_diubah_pada = ? WHERE id = ?'
);
$tanda->execute([
$this->sekarang()->format('Y-m-d H:i:s'),
$baris['user_id'],
]);
$this->pdo->commit();
} catch (Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
}
/**
* Apakah sesi yang login pada $waktuLogin masih sah?
*
* Sesi dianggap sah bila penggunanya dikenal dan login terjadi
* SETELAH kata sandi terakhir diganti. Login pada detik yang
* sama dengan penggantian dianggap belum sah - konservatif,
* tetapi lebih aman daripada sebaliknya.
*/
public function sesiMasihSah(int $userId, DateTimeImmutable $waktuLogin): bool
{
$stmt = $this->pdo->prepare(
'SELECT kata_sandi_diubah_pada FROM users WHERE id = ?'
);
$stmt->execute([$userId]);
$penanda = $stmt->fetchColumn();
if ($penanda === false) {
return false;
}
if ($penanda === null) {
return true;
}
return $waktuLogin->format('Y-m-d H:i:s') > (string) $penanda;
}
/**
* Jumlah baris token yang tersimpan - untuk memeriksa bahwa
* pemakaian token benar-benar menghapusnya dan bahwa email
* asing tidak pernah meninggalkan baris.
*/
public function jumlahTokenTersimpan(): int
{
return (int) $this->pdo->query(
'SELECT COUNT(*) FROM password_resets'
)->fetchColumn();
}
private function sekarang(): DateTimeImmutable
{
return ($this->jam)();
}
/**
* Apakah sebuah kolom sudah ada pada tabel? PRAGMA table_info
* tidak menerima parameter bound, jadi nama tabel diinterpolasi
* langsung - aman karena berasal dari kode, bukan masukan.
*/
private static function kolomAda(PDO $pdo, string $tabel, string $kolom): bool
{
$baris = $pdo->query('PRAGMA table_info(' . $tabel . ')')
->fetchAll(PDO::FETCH_ASSOC);
return in_array($kolom, array_column($baris, 'name'), true);
}
}