Chapter Login dan Logout mengakhiri alurnya di satu titik: $_SESSION['user_id'] terisi. Pertanyaan berikutnya justru paling sering dijumpai dalam kode sehari-hari — bagaimana setiap request selanjutnya menjawab “siapa pengguna ini?” dan “bolehkah ia membuka halaman ini?”. Session-based authentication adalah jawaban standar aplikasi PHP yang merender halaman di server: identitas disimpan di session server-side, browser hanya membawa ID session di cookie. Pada chapter ini kita bangun tiga lapisannya secara berurutan — penyimpanan identitas, resolusi current user, dan guard autentikasi — lalu tutup dengan pencegahan session fixation dan batasan desainnya.

Identitas Minimal di Session #

Prinsip pertama session-based authentication: simpan identitas seminimal mungkin. Kunci integer user_id sudah cukup untuk menjawab “siapa penggunanya” karena seluruh data lain bisa diambil dari database kapan pun dibutuhkan. Mari kita lihat wujud session yang mengikuti prinsip tersebut:

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

$log = [];

session_start();
$_SESSION['user_id'] = 42;
session_write_close();

session_start();
$log[] = 'Isi session: ' . json_encode($_SESSION);
$log[] = 'Cara cek login: ' . var_export(isset($_SESSION['user_id']), true);
session_write_close();

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

Output yang Dihasilkan:

text
Isi session: {"user_id":42}
Cara cek login: true

Pemeriksaan login menjadi murah dan tegas: isset($_SESSION['user_id']) — tanpa kueri database, tanpa membaca cookie secara manual. Demo di CLI memakai trik yang sama dengan chapter-chapter session sebelumnya: session_id() tetap, session_write_close() untuk meniru akhir request, dan log yang ditunda sampai semua operasi session selesai.

Mengapa tidak menyimpan username atau email sekaligus agar hemat kueri? Karena data salinan cepat basi: pengguna mengganti nama, dan session masih membawa nama lama sampai ia login ulang. Pengecualian yang wajar hanyalah data tampilan yang jarang berubah — misalnya nama untuk menyapa di header — dengan kesadaran bahwa nilainya bisa tertinggal. Apapun yang berdampak keamanan (peran, status verifikasi) selalu dibaca ulang dari database, bukan dipercayakan kepada salinan di session.

Resolusi Current User #

user_id di session hanyalah alamat; current user adalah data utuh di balik alamat tersebut. Resolusi ini terjadi di awal setiap request: aplikasi membaca user_id dari session, mencari barisnya di database, dan mengembalikan data pengguna — atau null untuk tamu. Fungsi berikut juga menangani satu kasus yang sering dilupakan: session masih hidup, tetapi akunnya sudah dihapus:

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,
    email TEXT NOT NULL
)');
$pdo->prepare('INSERT INTO users (id, username, email) VALUES (?, ?, ?)')
    ->execute([42, 'budi', '[email protected]']);

function currentUser(PDO $pdo, array $session): ?array
{
    if (!isset($session['user_id'])) {
        return null; // tamu: belum pernah login
    }

    $stmt = $pdo->prepare('SELECT id, username, email FROM users WHERE id = ?');
    $stmt->execute([$session['user_id']]);
    $user = $stmt->fetch(PDO::FETCH_ASSOC);

    // Akun bisa saja sudah dihapus setelah session dibuat
    return $user === false ? null : $user;
}

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

$log = [];

// Kasus 1: tamu tanpa login
session_start();
$user = currentUser($pdo, $_SESSION);
$log[] = 'Tamu: current user = ' . ($user === null ? 'null' : json_encode($user));
session_write_close();

// Kasus 2: pengguna yang sudah login
session_start();
$_SESSION['user_id'] = 42;
session_write_close();
session_start();
$user = currentUser($pdo, $_SESSION);
$log[] = 'Sudah login: current user = ' . json_encode($user);
session_write_close();

// Kasus 3: akun dihapus saat session masih tersimpan di server
$pdo->prepare('DELETE FROM users WHERE id = ?')->execute([42]);
session_start();
$user = currentUser($pdo, $_SESSION);
if ($user === null && isset($_SESSION['user_id'])) {
    session_unset();
    session_destroy();
    $log[] = 'Akun terhapus: session dimusnahkan, pengguna kembali jadi tamu';
} else {
    $log[] = 'Akun terhapus: masih dianggap valid';
}

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

Output yang Dihasilkan:

text
Tamu: current user = null
Sudah login: current user = {"id":42,"username":"budi","email":"[email protected]"}
Akun terhapus: session dimusnahkan, pengguna kembali jadi tamu

Mari kita bedah tiga jalurnya:

  1. Jalur tamu: kunci user_id tidak ada di session, fungsi langsung mengembalikan null tanpa membuka koneksi database.
  2. Jalur normal: prepared statement mencari baris berdasarkan user_id; hasilnya array asosiatif berisi id, username, dan email siap dipakai handler — data ini selalu segar karena dibaca ulang dari database.
  3. Jalur akun terhapus: SELECT mengembalikan false meskipun user_id masih tersimpan di session. Handler menangkap kondisi “session mengaku login tetapi akunnya tiada” dengan memusnahkan session, sehingga pengguna berikutnya diperlakukan sebagai tamu.

Polanya ?array — null berarti tamu — membuat pemanggil bisa memakai if ($user === null) di satu tempat, alih-alih memeriksa isset($_SESSION[...]) dan kegagalan kueri di banyak tempat berbeda.

Guard Autentikasi di Handler #

Halaman privat — dashboard, pengaturan akun, keranjang — perlu satu gerbang yang sama: bila pengguna belum login, tolak sebelum handler inti menyentuh data apa pun. Kita tulis gerbang itu sebagai fungsi requireLogin() yang mengembalikan bentuk respons, sehingga bisa diuji tanpa server web:

php
<?php
function requireLogin(array $session, callable $halamanPrivat): array
{
    if (!isset($session['user_id'])) {
        return ['status' => 401, 'location' => '/login', 'body' => null];
    }

    return ['status' => 200, 'location' => null, 'body' => $halamanPrivat($session['user_id'])];
}

$dashboard = function (int $userId): string {
    return "Selamat datang kembali, user #$userId";
};

$respons = requireLogin([], $dashboard);
echo 'Tamu: status ' . $respons['status'] . ', redirect ke ' . $respons['location'] . "\n";

$respons = requireLogin(['user_id' => 42], $dashboard);
echo 'Sudah login: status ' . $respons['status'] . ', body: ' . ($respons['body'] ?? '-') . "\n";

Output yang Dihasilkan:

text
Tamu: status 401, redirect ke /login
Sudah login: status 200, body: Selamat datang kembali, user #42

Fungsi tersebut menerima dua hal: potongan $_SESSION (tamu berupa array kosong) dan handler halaman sebagai callable. Tamu mendapatkan respons 401 plus lokasi /login untuk dijadikan redirect; pengguna terautentikasi mendapatkan hasil handler — dalam contoh ini teks sapaan dashboard. Handler inti tidak perlu tahu detail pemeriksaan login karena gerbang menolak lebih dulu.

Pada aplikasi web nyata, gerbang yang sama ditulis dengan header HTTP dan exit:

php
<?php
// Sketsa guard autentikasi pada aplikasi web nyata
function guardAuth(): void
{
    if (!isset($_SESSION['user_id'])) {
        http_response_code(401);
        header('Location: /login?redirect=' . urlencode($_SERVER['REQUEST_URI'] ?? '/'));
        exit;
    }
}

guardAuth();
// Kode handler di bawah baris ini hanya berjalan untuk pengguna yang sudah login

Guard di atas menjawab autentikasi (“sudah login?”). Pertanyaan “login sebagai siapa yang boleh membuka halaman ini?” adalah urusan otorisasi, yang kita bangun pada chapter Role-Based Access Control dengan pola gerbang yang sama.

Mencegah Session Fixation dengan Regenerasi ID #

Session fixation adalah serangan yang memanfaatkan ID session yang tidak berubah: penyerang menanam ID session yang ia kenal (misalnya lewat tautan berisi PHPSESSID), korban login dengan ID tersebut, dan penyerang ikut masuk memakai ID yang sama. Penawarnya satu baris yang sudah kita pakai di chapter login — session_regenerate_id(true) tepat setelah verifikasi kredensial sukses:

php
<?php
$dir = sys_get_temp_dir() . '/php-kursus-sesi';
if (!is_dir($dir)) {
    mkdir($dir);
}
session_save_path($dir);
session_id('demo-fixation');
@unlink($dir . '/sess_demo-fixation');

$log = [];

session_start();
$idSebelumLogin = session_id();
$log[] = 'Status awal: tamu memakai ID session dari cookie';

session_regenerate_id(true); // dipanggil tepat setelah verifikasi login sukses
$idSesudahLogin = session_id();
$_SESSION['user_id'] = 42;
$masihAda = isset($_SESSION['user_id']);
session_write_close();

$log[] = 'ID session berganti saat login: ' . var_export($idSebelumLogin !== $idSesudahLogin, true);
$log[] = 'Data user_id tetap tersimpan: ' . var_export($masihAda, true);

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

Output yang Dihasilkan:

text
Status awal: tamu memakai ID session dari cookie
ID session berganti saat login: true
Data user_id tetap tersimpan: true

Output sengaja mencetak fakta “ID berganti” tanpa menampilkan nilai ID-nya, karena ID baru dibangkitkan acak pada setiap eksekusi. Parameter true membuat PHP sekaligus menghapus file session lama, sehingga ID yang tertanam di browser penyerang mengarah ke session kosong. Mekanisme lengkapnya — mengapa regenerasi aman, kapan waktu terbaik memanggilnya, dan peran session.use_strict_mode — sudah dibedah tuntas pada chapter Session Regeneration .

Pembersihan saat Logout #

Logout pada session-based authentication membersihkan jejak di tiga tempat sekaligus. Pertama, session_unset() mengosongkan $_SESSION di memori; kedua, session_destroy() menghapus data session dari penyimpanan server; ketiga, cookie PHPSESSID di browser ditimpa dengan kedaluwarsa di masa lalu. Ketiganya sudah kita praktikkan dengan output nyata pada chapter Login dan Logout , jadi di sini cukup satu checklist:

  • session_unset() — data identitas hilang dari request berjalan.
  • session_destroy() — file session di server ikut lenyap.
  • setcookie() berumur negatif — browser berhenti mengirim ID lama.
  • Hapus baris token remember-me di database, bila fitur itu aktif.

Poin terakhir sering terlupakan: tanpa pencabutan token, cookie remember-me justru mem-login-kan ulang pengguna tepat setelah ia logout — perangkap yang kita bongkar pada chapter Remember-Me .

Batasan: Satu Browser Satu Session #

Desain session PHP memiliki satu batasan bawaan yang perlu dipahami: setiap browser yang login mendapat session-nya sendiri. Budi yang login dari laptop dan dari ponsel menghasilkan dua file session terpisah — keduanya sah, keduanya berumur sesuai gc_maxlifetime masing-masing, dan session_destroy() dari laptop tidak menyentuh session di ponsel. PHP tidak menyimpan pemetaan “user ini punya session mana saja” pada penyimpanan bawaan.

Bila kebijakan produk menuntut satu session aktif saja, ada dua pendekatan yang dikenal luas. Pendekatan pertama adalah stempel waktu penolakan — kolom sessions_invalid_before yang sudah kita praktikkan pada logout semua perangkat; setiap request membandingkan waktu login session dengan stempel tersebut. Pendekatan kedua memindahkan penyimpanan session ke database atau Redis dengan tabel yang memuat user_id, sehingga aplikasi bisa meminta “hapus semua session milik user 42” secara eksplisit. Kedua pendekatan sama-sama menambah satu kueri per request — harga yang pantas untuk kemampuan pencabutan.

Ringkasan #

Session-based authentication berdiri di atas tiga komponen: identitas minimal user_id di $_SESSION, resolusi currentUser() yang membaca ulang data dari database dan menangani akun terhapus, serta guard requireLogin() yang menolak tamu sebelum handler berjalan. Regenerasi ID saat login menutup celah fixation, logout membersihkan session beserta cookienya, dan batasan satu-browser-satu-session diatasi lewat stempel waktu penolakan atau penyimpanan session terpusat. Keterbatasan lain muncul dari sisi pengalaman: session cepat mati saat browser ditutup. Chapter Remember-Me menambahkan ingatan jangka panjang di atas fondasi ini.

Referensi #