Session-based authentication dari chapter sebelumnya punya kelemahan pengalaman: cookie PHPSESSID mati saat browser ditutup, dan data session di server dibersihkan setelah idle 24 menit. Pengguna yang tiap pagi harus login ulang akan segera kabur. Remember-me menambalnya dengan cookie berumur panjang — tetapi menghubungkan cookie tersebut ke alur autentikasi menuntut kehati-hatian tersendiri: token harus diverifikasi tanpa memercayakan isinya, session baru harus dibuat dengan benar, dan seluruh token harus bisa dicabut saat password berubah. Desain token selector:validator beserta alasan kriptografisnya sudah kita bangun tuntas pada chapter Remember-Me Patterns ; chapter ini fokus pada sisi integrasinya ke dalam authentication.

Token Remember-Me dalam Alur Autentikasi #

Mari kita posisikan dulu di mana remember-me bekerja. Saat login dengan kotak “ingat saya” dicentang, aplikasi menerima kredensial, membuat session seperti biasa, lalu menambahkan satu langkah: menerbitkan token dan mengirimnya sebagai cookie berumur 30 hari. Pada kunjungan-kunjungan berikutnya, urutannya berbeda — browser datang tanpa session aktif, dan middleware autentikasi mencoba jalur kedua:

  1. Baca cookie remember_me dari request.
  2. Cari baris token berdasarkan selector di database.
  3. Tolak bila token tidak ada, sudah kedaluwarsa, atau validator tidak cocok dengan hash tersimpan.
  4. Bila lolos: buat session baru (lengkap dengan regenerasi ID), lalu teruskan request seperti pengguna yang baru saja login.

Perbedaan kuncinya dengan password: token diverifikasi setiap request tanpa melibatkan password_verify() — perbandingannya memakai hash_equals() terhadap hash SHA-256, karena token bukan buatan manusia melainkan keacakan murni. Karena token hidup di tabel database, aplikasi bisa mencabutnya kapan pun — kekuatan yang tidak dimiliki session cookie biasa.

Implementasi berikut menyatukan seluruh jalur di atas. Tabel remember_tokens menyimpan hash validator, token demo memakai nilai tetap agar contoh deterministik, dan proses pembuatan session memakai pola yang sama dengan login biasa:

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

$pdo->exec('CREATE TABLE users (
    id INTEGER PRIMARY KEY,
    username TEXT NOT NULL
)');
$pdo->exec('CREATE TABLE remember_tokens (
    selector TEXT PRIMARY KEY,
    user_id INTEGER NOT NULL,
    validator_hash TEXT NOT NULL,
    expires_at TEXT NOT NULL
)');

$pdo->prepare('INSERT INTO users (id, username) VALUES (?, ?)')->execute([42, 'budi']);

// Token yang "diterbitkan saat login 30 hari lalu" - nilai tetap untuk pengujian
$selector = 'selector-tetap-demo-01';
$validator = 'validator-tetap-demo-untuk-pengujian';
$pdo->prepare('INSERT INTO remember_tokens (selector, user_id, validator_hash, expires_at)
               VALUES (?, ?, ?, ?)')
    ->execute([$selector, 42, hash('sha256', $validator), '2026-02-14 08:00:00']);

function attemptAutoLogin(PDO $pdo, string $cookieValue, string $now): ?int
{
    [$tokenSelector, $tokenValidator] = array_pad(explode(':', $cookieValue, 2), 2, '');

    $stmt = $pdo->prepare('SELECT user_id, validator_hash, expires_at FROM remember_tokens WHERE selector = ?');
    $stmt->execute([$tokenSelector]);
    $row = $stmt->fetch(PDO::FETCH_ASSOC);

    if ($row === false || $now > $row['expires_at']) {
        return null; // selector tidak dikenal, atau token kedaluwarsa
    }
    if (!hash_equals($row['validator_hash'], hash('sha256', $tokenValidator))) {
        return null; // validator tidak cocok
    }

    return (int) $row['user_id'];
}

$dir = sys_get_temp_dir() . '/php-kursus-sesi';
if (!is_dir($dir)) {
    mkdir($dir);
}
session_save_path($dir);
session_id('auth-remember-demo');
@unlink($dir . '/sess_auth-remember-demo');

$log = [];

// "Request": pengguna datang tanpa session aktif (cookie PHPSESSID sudah mati)
session_start();
$log[] = 'Sebelum auto-login: user_id di session = ' . ($_SESSION['user_id'] ?? 'tidak ada');

$cookieDariBrowser = $selector . ':' . $validator;
$userId = attemptAutoLogin($pdo, $cookieDariBrowser, '2026-01-20 08:00:00');

if ($userId !== null) {
    session_regenerate_id(true); // auto-login adalah login: ID session baru
    $_SESSION['user_id'] = $userId;
    $log[] = 'Auto-login sukses: user_id ' . $userId . ' kini ada di session';
} else {
    $log[] = 'Auto-login ditolak: pengguna tetap tamu';
}
session_write_close();

// Cookie yang dipalsukan (validator diganti) harus ditolak
$cookiePalsu = $selector . ':validator-yang-dipalsukan';
$log[] = 'Validator palsu diterima: ' . var_export(attemptAutoLogin($pdo, $cookiePalsu, '2026-01-20 08:00:00') !== null, true);

// Kunjungan setelah tanggal kedaluwarsa harus ditolak
$log[] = 'Kunjungan setelah kedaluwarsa diterima: ' . var_export(attemptAutoLogin($pdo, $cookieDariBrowser, '2026-03-01 08:00:00') !== null, true);

echo implode("\n", $log), "\n";

Output yang Dihasilkan:

text
Sebelum auto-login: user_id di session = tidak ada
Auto-login sukses: user_id 42 kini ada di session
Validator palsu diterima: false
Kunjungan setelah kedaluwarsa diterima: false

Mari kita bedah bagian-bagian pentingnya:

  1. Pemecahan cookie (baris pertama dalam fungsi): explode(':', $cookieValue, 2) memisahkan selector dan validator; array_pad(..., 2, '') menjamin kedua variabel selalu terisi, sehingga cookie tanpa titik dua pun tidak memicu error.
  2. Penolakan bertahap: selector tak dikenal dan token kedaluwarsa ditangani satu kondisi; validator palsu ditolak hash_equals(). Output membuktikan ketiganya: hanya cookie utuh dan masih segar yang diterima.
  3. Pembuatan session: session_regenerate_id(true) dipanggil sebelum menulis $_SESSION['user_id'] — auto-login setara login baru, sehingga perlindungan session fixation dari chapter Session-Based Authentication berlaku juga di sini.
  4. Urutan pemeriksaan: verifikasi token dilakukan setelah pemeriksaan “sudah login?” gagal — pengguna dengan session aktif tidak perlu menyentuh tabel token sama sekali.

Masa Berlaku Token vs Masa Berlaku Session #

Remember-me memelihara dua jam yang berbeda, dan keduanya tidak boleh tertukar. Cookie session hidup sesuai siklus browser dan gc_maxlifetime; token remember-me hidup menurut kolom expires_at di database plus atribut expires pada cookienya. Perbandingan lengkapnya:

AspekCookie session (PHPSESSID)Cookie remember-me
IsiID session acakpasangan selector:validator
Umur cookiesession cookie — hilang saat browser ditutupbiasanya 30 hari lewat atribut expires
Umur datasession.gc_maxlifetime (bawaan 1440 detik idle)kolom expires_at di database
Cara dicabuttidak praktis — tidak ada daftar session per userhapus baris token di database
Verifikasi tiap requestkeberadaan data session di penyimpananlookup selector + hash_equals()

Konsekuensi praktisnya: angka 30 hari pada cookie harus sama dengan angka di expires_at. Bila cookie berumur lebih pendek dari baris database, pengguna “kehilangan” ingatan lebih cepat dari seharusnya; bila lebih panjang, browser terus mengirim token yang server tolak — percuma tapi berisiko bising. Perbandingan expires_at pada contoh memakai string waktu berformat ISO yang urutan leksikografisnya sama dengan urutan kronologis.

Invalidasi Semua Token saat Password Berubah #

Password yang diganti harus mematikan semua pintu masuk lama — termasuk token remember-me di semua perangkat. Kalau tidak, siapa pun yang sempat menyalin cookie tetap bisa masuk selama 30 hari ke depan meskipun password sudah bukan rahasianya lagi. Fungsi changePassword() berikut menunjukkan urutan kerjanya:

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

$pdo->exec('CREATE TABLE users (
    id INTEGER PRIMARY KEY,
    username TEXT NOT NULL,
    password_hash TEXT NOT NULL
)');
$pdo->exec('CREATE TABLE remember_tokens (
    selector TEXT PRIMARY KEY,
    user_id INTEGER NOT NULL
)');

$pdo->prepare('INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?)')
    ->execute([42, 'budi', password_hash('password-lama-123', PASSWORD_DEFAULT)]);

// Tiga token di tiga "perangkat" milik Budi, satu token milik user lain
$stmt = $pdo->prepare('INSERT INTO remember_tokens (selector, user_id) VALUES (?, 42)');
foreach (['token-laptop', 'token-ponsel', 'token-tablet'] as $selector) {
    $stmt->execute([$selector]);
}
$pdo->prepare('INSERT INTO remember_tokens (selector, user_id) VALUES (?, ?)')
    ->execute(['token-siti', 7]);

function changePassword(PDO $pdo, int $userId, string $passwordLama, string $passwordBaru): array
{
    $stmt = $pdo->prepare('SELECT password_hash FROM users WHERE id = ?');
    $stmt->execute([$userId]);
    $hashLama = $stmt->fetchColumn();

    if (!password_verify($passwordLama, $hashLama)) {
        return ['sukses' => false, 'pesan' => 'Password lama salah.'];
    }

    // Hash password baru, lalu cabut SELURUH token remember-me user ini
    $hashBaru = password_hash($passwordBaru, PASSWORD_DEFAULT);
    $pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?')->execute([$hashBaru, $userId]);
    $pdo->prepare('DELETE FROM remember_tokens WHERE user_id = ?')->execute([$userId]);

    return ['sukses' => true];
}

echo 'Token Budi sebelum ganti password: '
    . $pdo->query('SELECT COUNT(*) FROM remember_tokens WHERE user_id = 42')->fetchColumn() . "\n";

$hasil = changePassword($pdo, 42, 'password-lama-123', 'password-baru-456');
echo 'Ganti password: ' . var_export($hasil['sukses'], true) . "\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";
echo 'Token Budi tersisa: '
    . $pdo->query('SELECT COUNT(*) FROM remember_tokens WHERE user_id = 42')->fetchColumn() . "\n";
echo 'Token user lain tetap utuh: '
    . $pdo->query('SELECT COUNT(*) FROM remember_tokens WHERE user_id = 7')->fetchColumn() . "\n";

Output yang Dihasilkan:

text
Token Budi sebelum ganti password: 3
Ganti password: true
Password baru terverifikasi: true
Token Budi tersisa: 0
Token user lain tetap utuh: 1

Verifikasi password lama di awal mencegah orang yang membuka laptop yang belum terkunci mengganti password tanpa tahu kredensialnya. DELETE ... WHERE user_id = ? menunjukkan pencabutan yang presisi: tiga token milik Budi lenyap, token milik pengguna lain tidak tersentuh. Password baru diverifikasi dengan password_verify() — hash mentahnya tidak pernah dicetak karena berbeda tiap eksekusi.

Session yang sedang aktif di perangkat lain juga perlu dimatikan. Kombinasi lengkapnya adalah dua langkah dalam satu transaksi: DELETE seluruh token remember-me (seperti di atas) plus UPDATE sessions_invalid_before untuk mematikan session di mana pun, persis pola logout-semua-perangkat dari chapter Login dan Logout . Perangkat tempat pengguna mengganti password sendiri biasanya dikecualikan — aplikasi menimpa session lama dengan login yang baru.

Batasan Keamanan Remember-Me #

Setiap kenyamanan dibayar dengan permukaan serangan, dan remember-me memperpanjang umur pintu masuk hingga berhari-hari. Batasan berikut wajib ditegakkan bersamaan:

  • Cookie hanya dikirim lewat HTTPS (secure), tidak terbaca JavaScript (httponly), dan samesite dipasang untuk meredam CSRF — kombinasi flag yang dibahas pada chapter Cookie Options .
  • Database menyimpan hash validator, bukan validator mentah; kebocoran database tidak langsung menjadi tiket masuk.
  • Token dirotasi setiap kali dipakai auto-login, sehingga pemakaian token lama terdeteksi sebagai indikasi pencurian — pola token series dari chapter Remember-Me Patterns .
  • Umur token dibatasi wajar (7-30 hari) dan fitur ini bersifat opt-in; perangkat umum atau komputer kantor sebaiknya tidak mencentangnya.
  • Logout mencabut baris token di samping membersihkan session — meninggalkan token hidup berarti pengguna login lagi tepat setelah logout.

Ringkasan #

Remember-me mengintegrasikan token jangka panjang ke alur autentikasi: cookie selector:validator diverifikasi lewat lookup database dan hash_equals(), lalu auto-login membuat session segar dengan session_regenerate_id(true). Masa berlaku token dijaga di dua tempat — atribut expires cookie dan kolom expires_at — dan pergantian password mencabut seluruh token milik user sambil mematikan session lainnya. Satu skenario masih menyisakan masalah: pengguna yang lupa password sama sekali tidak bisa masuk untuk menggantinya. Chapter Password Reset menutup celah itu dengan token sekali pakai yang dikirim lewat email.

Referensi #