Pengguna lupa password adalah kejadian yang pasti terjadi, dan cara aplikasi menanganinya menentukan nasib akun-akun di dalamnya. Reset password yang salah — token yang bisa dipakai berkali-kali, tanpa batas waktu, atau dengan respons yang membocorkan email mana yang terdaftar — mengubah fitur pemulihan menjadi pintu belakang bagi penyerang. Pada chapter ini kita bangun alurnya secara utuh di atas SQLite: permintaan reset yang tidak membocorkan apa pun, token yang disimpan sebagai hash, jendela waktu kedaluwarsa, pemakaian sekali saja, hingga penggantian password yang sekaligus mematikan session dan remember-me di perangkat lain.

Alur Reset Password #

Alur reset melibatkan dua sisi yang tidak pernah bertemu langsung: form di aplikasi dan email di kotak masuk pengguna. Diagram berikut memetakan jalurnya:

Dua sisi keputusan pada diagram adalah inti keamanannya. Percabangan pertama memastikan respons aplikasi identik untuk email terdaftar maupun tidak — hanya jalur internalnya yang berbeda. Percabangan kedua terjadi ketika tautan diklik: token dicari berdasarkan hash-nya, kedaluwarsanya diperiksa, dan baru kemudian password diganti. Email yang dikirim berbentuk seperti ini:

text
Kepada: [email protected]
Subjek: Reset password akun Anda

Klik tautan berikut untuk mengatur password baru:

https://kursus-php.example/reset-password?token=<token-acak-dari-random-bytes>

Tautan hanya berlaku 1 jam dan dapat dipakai satu kali.
Jika Anda tidak meminta reset, abaikan email ini -
password Anda tidak akan berubah.

Token di URL adalah keacakan murni dari random_bytes() — sama dengan prinsip token remember-me pada chapter Remember-Me . Perbedaannya pada umur dan pemakaian: token reset hidup satu jam dan sekali pakai, karena tujuannya mengganti kunci, bukan menggantikan login.

Token Disimpan sebagai Hash, Bukan Nilai Mentah #

Tabel password_resets tidak menyimpan token apa adanya. Nilai mentah hanya hidup di URL dalam email; database menyimpan hash('sha256', $token) — persis filosofi password hashing dari chapter Password Hashing , meski memakai SHA-256 karena token 64 karakter acak tidak butuh algoritma lambat. Kode berikut menunjukkan penyimpanan dan pencarian ulangnya:

php
<?php
// Token mentah hanya hidup di URL email; database menyimpan hash-nya
$tokenMentah = 'reset-demo-token-tetap-untuk-ujian';
$tokenHash = hash('sha256', $tokenMentah);

echo 'Panjang token mentah: ' . strlen($tokenMentah) . " karakter\n";
echo 'Panjang hash tersimpan: ' . strlen($tokenHash) . " karakter\n";
echo 'Hash hanya berisi heksadesimal: ' . var_export(ctype_xdigit($tokenHash), true) . "\n";

$pdo = new PDO('sqlite::memory:');
$pdo->exec('CREATE TABLE password_resets (
    email TEXT NOT NULL,
    token_hash TEXT NOT NULL UNIQUE,
    expires_at TEXT NOT NULL
)');
$pdo->prepare('INSERT INTO password_resets (email, token_hash, expires_at) VALUES (?, ?, ?)')
    ->execute(['[email protected]', $tokenHash, '2026-01-15 09:00:00']);

// Saat tautan diklik: hash token dari URL, lalu cari barisnya
$stmt = $pdo->prepare('SELECT email, expires_at FROM password_resets WHERE token_hash = ?');

$stmt->execute([hash('sha256', $tokenMentah)]);
echo 'Token dari URL asli ditemukan: ' . var_export($stmt->fetch() !== false, true) . "\n";

$stmt->execute([hash('sha256', 'token-yang-dipalsukan')]);
echo 'Token palsu ditemukan: ' . var_export($stmt->fetch() !== false, true) . "\n";

Output yang Dihasilkan:

text
Panjang token mentah: 34 karakter
Panjang hash tersimpan: 64 karakter
Hash hanya berisi heksadesimal: true
Token dari URL asli ditemukan: true
Token palsu ditemukan: false

Pencarian berdasarkan hash membuat kebocoran database tidak langsung membuahkan tautan aktif: penyerang memegang hash, bukan token mentah, dan prapeta SHA-256 atas 64 karakter acak tidak praktis dihitung. Kolom token_hash diberi UNIQUE sehingga lookup-nya berindeks dan duplikasi token mustahil. Verifikasi di sini tidak memerlukan hash_equals() karena pencarian dilakukan lewat indeks database, bukan perbandingan string yang bisa diukur waktunya per karakter.

Permintaan Reset dan Respons Seragam #

Fungsi requestReset() di bawah ini menerima email dari form, dan — hanya bila email terdaftar — menyisipkan baris token. Yang dikembalikan ke pemanggil adalah pesan yang sama untuk semua kemungkinan:

php
<?php
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('CREATE TABLE users (
    id INTEGER PRIMARY KEY,
    email TEXT NOT NULL UNIQUE
)');
$pdo->exec('CREATE TABLE password_resets (
    token_hash TEXT PRIMARY KEY,
    user_id INTEGER NOT NULL,
    expires_at TEXT NOT NULL
)');
$pdo->prepare('INSERT INTO users (id, email) VALUES (?, ?)')->execute([42, '[email protected]']);

// $token disuntikkan pemanggil agar mudah diuji;
// aplikasi nyata membangkitkannya: bin2hex(random_bytes(32))
function requestReset(PDO $pdo, string $email, string $token, string $now, string $berlakuSelama): array
{
    $respons = ['pesan' => 'Jika email terdaftar, tautan reset telah dikirim. Silakan periksa kotak masuk Anda.'];

    $stmt = $pdo->prepare('SELECT id FROM users WHERE email = ?');
    $stmt->execute([$email]);
    $userId = $stmt->fetchColumn();

    if ($userId !== false) {
        $berlakuSampai = (new DateTimeImmutable($now))->modify($berlakuSelama)->format('Y-m-d H:i:s');
        $pdo->prepare('INSERT INTO password_resets (token_hash, user_id, expires_at) VALUES (?, ?, ?)')
            ->execute([hash('sha256', $token), (int) $userId, $berlakuSampai]);
    }

    return $respons;
}

$terdaftar = requestReset($pdo, '[email protected]', 'token-demo-tetap', '2026-01-15 08:00:00', '+1 hour');
$asing = requestReset($pdo, '[email protected]', 'token-lain-tak-dipakai', '2026-01-15 08:00:00', '+1 hour');

echo 'Respons untuk email terdaftar: ' . $terdaftar['pesan'] . "\n";
echo 'Respons untuk email asing: ' . $asing['pesan'] . "\n";
echo 'Pesan identik: ' . var_export($terdaftar['pesan'] === $asing['pesan'], true) . "\n";
echo 'Baris token yang tersimpan: ' . $pdo->query('SELECT COUNT(*) FROM password_resets')->fetchColumn() . "\n";

$stmt = $pdo->prepare('SELECT expires_at FROM password_resets WHERE token_hash = ?');
$stmt->execute([hash('sha256', 'token-demo-tetap')]);
echo 'Berlaku sampai: ' . $stmt->fetchColumn() . "\n";

Output yang Dihasilkan:

text
Respons untuk email terdaftar: Jika email terdaftar, tautan reset telah dikirim. Silakan periksa kotak masuk Anda.
Respons untuk email asing: Jika email terdaftar, tautan reset telah dikirim. Silakan periksa kotak masuk Anda.
Pesan identik: true
Baris token yang tersimpan: 1
Berlaku sampai: 2026-01-15 09:00:00

Output membuktikan dua sisi janji yang sama: kedua email menerima pesan yang identik, tetapi hanya email terdaftar yang menghasilkan baris token — hitungannya satu. Tanggal kedaluwarsa dihitung dari stempel waktu yang disuntikkan, sehingga 2026-01-15 08:00:00 plus +1 hour menghasilkan 2026-01-15 09:00:00 tanpa menyentuh jam sistem. Waktu satu jam adalah kompromi yang lazim: cukup lama untuk membuka email, cukup pendek untuk memperkecil jendela bila tautan bocor.

Reset dengan Token Sekali Pakai #

Sisi kedua alur berjalan ketika tautan diklik. Fungsi performReset() memverifikasi token, mengganti password, dan membersihkan seluruh jejak akses lama dalam satu rangkaian:

php
<?php
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

$pdo->exec('CREATE TABLE users (
    id INTEGER PRIMARY KEY,
    email TEXT NOT NULL UNIQUE,
    password_hash TEXT NOT NULL,
    sessions_invalid_before TEXT NOT NULL DEFAULT \'1970-01-01 00:00:00\'
)');
$pdo->exec('CREATE TABLE password_resets (
    token_hash TEXT PRIMARY KEY,
    user_id INTEGER NOT NULL,
    expires_at TEXT NOT NULL
)');
$pdo->exec('CREATE TABLE remember_tokens (
    selector TEXT PRIMARY KEY,
    user_id INTEGER NOT NULL
)');

// Keadaan awal: token utama terbit 08:00 berlaku 1 jam,
// token kedua dari permintaan lama tinggal 15 menit tersisa
$pdo->prepare('INSERT INTO users (id, email, password_hash) VALUES (?, ?, ?)')
    ->execute([42, '[email protected]', password_hash('password-lama-123', PASSWORD_DEFAULT)]);
$pdo->prepare('INSERT INTO password_resets (token_hash, user_id, expires_at) VALUES (?, ?, ?)')
    ->execute([hash('sha256', 'token-demo-tetap'), 42, '2026-01-15 09:00:00']);
$pdo->prepare('INSERT INTO password_resets (token_hash, user_id, expires_at) VALUES (?, ?, ?)')
    ->execute([hash('sha256', 'token-kedaluwarsa'), 42, '2026-01-15 08:15:00']);
$stmt = $pdo->prepare('INSERT INTO remember_tokens (selector, user_id) VALUES (?, 42)');
foreach (['token-laptop', 'token-ponsel'] as $selector) {
    $stmt->execute([$selector]);
}

function performReset(PDO $pdo, string $token, string $passwordBaru, string $konfirmasi, string $now): array
{
    if ($passwordBaru !== $konfirmasi) {
        return ['sukses' => false, 'pesan' => 'Konfirmasi password tidak cocok.'];
    }
    if (strlen($passwordBaru) < 8) {
        return ['sukses' => false, 'pesan' => 'Password minimal 8 karakter.'];
    }

    $stmt = $pdo->prepare('SELECT user_id, expires_at FROM password_resets WHERE token_hash = ?');
    $stmt->execute([hash('sha256', $token)]);
    $row = $stmt->fetch(PDO::FETCH_ASSOC);

    if ($row === false) {
        return ['sukses' => false, 'pesan' => 'Tautan reset tidak valid atau sudah dipakai.'];
    }
    if ($now > $row['expires_at']) {
        return ['sukses' => false, 'pesan' => 'Tautan reset sudah kedaluwarsa. Silakan minta yang baru.'];
    }

    // Tuntas sekaligus: hash baru, token dihapus, session lain dimatikan
    $hashBaru = password_hash($passwordBaru, PASSWORD_DEFAULT);
    $pdo->prepare('UPDATE users SET password_hash = ?, sessions_invalid_before = ? WHERE id = ?')
        ->execute([$hashBaru, $now, (int) $row['user_id']]);
    $pdo->prepare('DELETE FROM password_resets WHERE token_hash = ?')
        ->execute([hash('sha256', $token)]);
    $pdo->prepare('DELETE FROM remember_tokens WHERE user_id = ?')
        ->execute([(int) $row['user_id']]);

    return ['sukses' => true, 'pesan' => 'Password berhasil diganti. Silakan login kembali.'];
}

// Konfirmasi tidak cocok: ditolak, token tetap hidup
$hasil = performReset($pdo, 'token-demo-tetap', 'password-baru-456', 'password-bedaaa', '2026-01-15 08:30:00');
echo 'Konfirmasi beda: ' . $hasil['pesan'] . "\n";

// Reset sukses dalam jendela waktu 1 jam
$hasil = performReset($pdo, 'token-demo-tetap', 'password-baru-456', 'password-baru-456', '2026-01-15 08:30:00');
echo 'Reset sukses: ' . var_export($hasil['sukses'], true) . ' - ' . $hasil['pesan'] . "\n";

$stmt = $pdo->prepare('SELECT password_hash FROM users WHERE id = ?');
$stmt->execute([42]);
echo 'Password baru terverifikasi: ' . var_export(password_verify('password-baru-456', $stmt->fetchColumn()), true) . "\n";

// Token sekali pakai: percobaan kedua dengan token yang sama ditolak
$hasil = performReset($pdo, 'token-demo-tetap', 'password-lain-789', 'password-lain-789', '2026-01-15 08:45:00');
echo 'Token dipakai kedua kali: ' . $hasil['pesan'] . "\n";

// Token dari permintaan lama: jendela waktunya sudah lewat
$hasil = performReset($pdo, 'token-kedaluwarsa', 'password-baru-456', 'password-baru-456', '2026-01-15 08:30:00');
echo 'Token kedaluwarsa: ' . $hasil['pesan'] . "\n";

echo 'Sisa remember token Budi: '
    . $pdo->query('SELECT COUNT(*) FROM remember_tokens WHERE user_id = 42')->fetchColumn() . "\n";
$stmt = $pdo->prepare('SELECT sessions_invalid_before FROM users WHERE id = ?');
$stmt->execute([42]);
echo 'Session sebelum pukul ' . $stmt->fetchColumn() . " dinyatakan tidak sah\n";

Output yang Dihasilkan:

text
Konfirmasi beda: Konfirmasi password tidak cocok.
Reset sukses: true - Password berhasil diganti. Silakan login kembali.
Password baru terverifikasi: true
Token dipakai kedua kali: Tautan reset tidak valid atau sudah dipakai.
Token kedaluwarsa: Tautan reset sudah kedaluwarsa. Silakan minta yang baru.
Sisa remember token Budi: 0
Session sebelum pukul 2026-01-15 08:30:00 dinyatakan tidak sah

Mari kita bedah keputusan-keputusan di dalamnya:

  1. Validasi input dulu: konfirmasi yang tidak cocok dan password kurang dari 8 karakter ditolak sebelum token disentuh — kedua error ini tidak mengonsumsi token, pengguna boleh mencoba lagi di form yang sama.
  2. Token sekali pakai: baris token dihapus dengan DELETE pada blok sukses. Percobaan kedua dengan token yang sama menemui SELECT yang mengembalikan false, lalu ditolak dengan pesan “tidak valid atau sudah dipakai” — persis seperti output.
  3. Jendela waktu: token kedua terbit untuk kedaluwarsa pukul 08:15; percobaan pukul 08:30 ditolak. Perbandingan string ISO aman karena urutan leksikografisnya sama dengan urutan waktu.
  4. Rehash, bukan sandi lama: password_hash() dipanggil segar untuk password baru — hasilnya berbeda setiap eksekusi, jadi output hanya membuktikan lewat password_verify().
  5. Logout perangkat lain: sessions_invalid_before di-set ke waktu reset (pola dari chapter Login dan Logout ) dan seluruh remember_tokens milik user dihapus — dua remember token Budi lenyap, dan setiap session yang login sebelum pukul 08:30 ditolak di request berikutnya.

Ringkasan #

Reset password yang aman berdiri di atas lima keputusan: respons permintaan yang seragam untuk semua email, token acak yang disimpan sebagai hash SHA-256, jendela kedaluwarsa yang pendek, pemakaian sekali saja lewat penghapusan baris, dan pembersihan menyeluruh — password di-hash ulang, session lain tidak sah, remember-me dicabut. Penggantian kredensial selesai tanpa pernah mempercayakan satu pun rahasia kepada URL selain token sekali pakai itu sendiri. Pola tautan-ke-email yang sama akan muncul sekali lagi dengan tujuan berbeda: memastikan alamat email benar-benar milik pendaftar, pada chapter Email Verification .

Referensi #