Error HTTP 419 (Page Expired) pada aplikasi monolit berbasis Laravel dan Inertia.js terjadi ketika token CSRF atau sesi pengguna habis masa berlakunya sebelum permintaan mutasi dikirim. Masalah ini paling sering muncul pada formulir panjang: pengguna menghabiskan waktu lama mengisi input, sesi backend kedaluwarsa, dan pengiriman formulir memicu modal dialog error bawaan Inertia.js. Dampaknya fatal bagi user experience—pengguna kehilangan seluruh progres input akibat hard reload.
Akar Masalah: Siklus Hidup Sesi dan Penanganan Error Inertia
Secara default, middleware ValidateCsrfToken atau VerifyCsrfToken di Laravel memverifikasi token yang dikirim melalui header X-XSRF-TOKEN. Ketika sesi backend berakhir (dikendalikan oleh konfigurasi config/session.php), token CSRF tidak lagi valid dan backend mengembalikan respons 419.
Inertia.js menangani respons XHR non-Inertia (respons yang tidak menyertakan header X-Inertia: true) melalui event router internal. Respons 419 standar menampilkan jendela modal HTML mentah yang menghentikan eksekusi aplikasi dan memaksa refresh halaman. Untuk mempertahankan state formulir klien, siklus ini harus diintersepsi sebelum modal default muncul, otentikasi ulang harus dilakukan tanpa reload, dan request mutasi awal harus diulang (replay).
Backend: Endpoint Re-Otentikasi Terisolasi
Buat endpoint khusus yang menangani autentikasi ulang secara headless (JSON) tanpa memicu redirect halaman. Endpoint ini harus memvalidasi kredensial pengguna yang sedang aktif, meregenerasi sesi, dan menerbitkan token CSRF baru.
// routes/web.php
use App\Http\Controllers\Auth\ReAuthenticateController;
use Illuminate\Support\Facades\Route;
Route::post('/re-auth', ReAuthenticateController::class)
->middleware(['web', 'throttle:5,1'])
->name('re-auth');
Implementasi controller memastikan hanya pengguna yang kredensialnya cocok dengan sesi sebelumnya yang dapat melanjutkan, mencegah eskalasi akun:
// app/Http/Controllers/Auth/ReAuthenticateController.php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
class ReAuthenticateController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$request->validate([
'email' => ['required', 'email'],
'password' => ['required', 'string'],
]);
if (! Auth::attempt($request->only('email', 'password'))) {
throw ValidationException::withMessages([
'password' => __('Otentikasi gagal. Kredensial tidak valid.'),
]);
}
$request->session()->regenerate();
return response()->json([
'status' => 'authenticated',
'user' => Auth::user(),
]);
}
}
Client-Side: Intersepsi Router Event Inertia.js
Inertia.js menyediakan event listener global router.on('invalid'). Event ini menangkap semua respons HTTP yang bukan format standar Inertia. Dengan memanggil event.preventDefault(), tampilan modal default 419 dapat ditekan.
Berikut adalah arsitektur penanganannya menggunakan store atau reactive state (contoh berbasis Vue 3 / TypeScript):
// resources/js/services/authReplay.ts
import { reactive } from 'vue';
import { router } from '@inertiajs/vue3';
interface FailedRequest {
url: string;
method: string;
data: any;
options: Record<string, any>;
}
export const authReplayState = reactive({
isOpen: false,
pendingRequest: null as FailedRequest | null,
});
export function registerInertiaInterceptors() {
router.on('invalid', (event) => {
const response = event.detail.response;
if (response.status === 419) {
// 1. Batalkan modal default Inertia
event.preventDefault();
// 2. Tangkap konfigurasi visit yang gagal
const visit = (router as any).activeVisit || {};
authReplayState.pendingRequest = {
url: visit.url?.pathname || window.location.pathname,
method: visit.method || 'post',
data: visit.data || {},
options: {
preserveState: true,
preserveScroll: true,
replace: visit.replace || false,
},
};
// 3. Munculkan dialog login in-place
authReplayState.isOpen = true;
}
});
}
Komponen Modal dan Request Replay
Setelah modal re-autentikasi berhasil memverifikasi kredensial ke endpoint /re-auth via fetch atau axios, sesi browser telah diperbarui beserta cookie XSRF-TOKEN. Langkah selanjutnya adalah mengeksekusi ulang permintaan yang sempat tertunda.
// resources/js/Components/ReAuthModal.vue
<script setup lang="ts">
import { ref } from 'vue';
import { router } from '@inertiajs/vue3';
import axios from 'axios';
import { authReplayState } from '@/services/authReplay';
const email = ref('');
const password = ref('');
const errorMessage = ref('');
const isSubmitting = ref(false);
async function handleReAuth() {
isSubmitting.value = true;
errorMessage.value = '';
try {
// 1. Submit re-auth via standard HTTP request
await axios.post('/re-auth', {
email: email.value,
password: password.value,
});
// 2. Tutup modal
authReplayState.isOpen = false;
password.value = '';
// 3. Replay request yang gagal dengan state form asli
if (authReplayState.pendingRequest) {
const { url, method, data, options } = authReplayState.pendingRequest;
authReplayState.pendingRequest = null;
router.visit(url, {
method: method as any,
data: data,
...options,
});
}
} catch (error: any) {
errorMessage.value = error.response?.data?.message || 'Gagal melakukan otentikasi.';
} finally {
isSubmitting.value = false;
}
}
</script>
<template>
<div v-if="authReplayState.isOpen" class="modal-backdrop">
<div class="modal-content">
<h3>Sesi Anda Telah Berakhir</h3>
<p>Masukkan password Anda untuk melanjutkan tanpa kehilangan progres formulir.</p>
<form @submit.prevent="handleReAuth">
<input type="email" v-model="email" placeholder="Email" required />
<input type="password" v-model="password" placeholder="Password" required />
<span v-if="errorMessage" class="error">{{ errorMessage }}</span>
<button type="submit" :disabled="isSubmitting">
{{ isSubmitting ? 'Memverifikasi...' : 'Lanjutkan' }}
</button>
</form>
</div>
</div>
</template>
Pengujian Otomatis Alur Re-Auth (Pest PHP)
Pengujian otomatis tingkat backend diperlukan untuk menjamin endpoint re-otentikasi meregenerasi session ID dengan benar dan menolak data yang tidak sah tanpa mengacaukan context sesi.
// tests/Feature/Auth/ReAuthenticationTest.php
use App\Models\User;
use Illuminate\Support\Facades\Hash;
test('pengguna dapat melakukan re-autentikasi dan memperbarui sesi', function () {
$user = User::factory()->create([
'email' => '[email protected]',
'password' => Hash::make('secret-password-123'),
]);
$this->actingAs($user);
$initialSessionId = session()->getId();
$response = $this->postJson('/re-auth', [
'email' => '[email protected]',
'password' => 'secret-password-123',
]);
$response->assertOk()
->assertJson([
'status' => 'authenticated',
]);
expect(session()->getId())->not->toBe($initialSessionId);
$this->assertAuthenticatedAs($user);
});
test('re-autentikasi gagal dengan kredensial yang salah', function () {
$user = User::factory()->create([
'password' => Hash::make('secret-password-123'),
]);
$this->actingAs($user);
$response = $this->postJson('/re-auth', [
'email' => $user->email,
'password' => 'wrong-password',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['password']);
});
Pertimbangan Teknis dan Edge Cases
- File/Binary Uploads: Jika request yang gagal berisi input berkas (
FormDatadenganFile/Blob), pastikan data tersebut tidak diserialisasi menjadi JSON biasa saat disimpan ke penampungpendingRequest. Simpan referensiFormDataasli agar multipart boundary tetap utuh saat di-replay. - Rate Limiting: Endpoint
/re-authwajib dibatasi via middlewarethrottleguna mencegah serangan brute-force credential stuffing ketika sesi pengguna habis. - Multi-Tab Race Condition: Jika pengguna membuka banyak tab dan melakukan login ulang pada tab kedua, tab pertama masih menyimpan token CSRF lama. Alternatif penanganan yang lebih pasif adalah endpoint ringan
GET /csrf-refreshuntuk memperbarui token sebelum form di-submit jika interval dwell time terlampaui.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!