Arsitektur Sudo Mode pada Aplikasi Berbasis SPA/Inertia
Middleware bawaan Laravel password.confirm mengasumsikan pola MPA (Multi-Page Application) tradisional. Ketika masa konfirmasi password berakhir, aplikasi memicu HTTP 302 redirect ke rute password.confirm. Pada aplikasi Inertia.js, pendekatan ini destruktif: state form lokal yang sedang diisi oleh pengguna akan hilang akibat perpindahan rute penuh.
Pendekatan yang benar adalah menerapkan Step-Up Authentication (Sudo Mode) secara headless. Backend mengembalikan status HTTP spesifik (misal: 423 Locked) jika mutasi memerlukan verifikasi ulang kredensial. Frontend Inertia mengintersepsi respons tersebut, menahan data mutasi terakhir, menampilkan modal konfirmasi password, lalu melakukan replay request secara otomatis tanpa reset state UI.
Backend: Sudo Middleware & Endpoint Verifikasi
Middleware kustom diperlukan untuk memeriksa timestamp validasi dalam session (auth.password_confirmed_at). Durasi default umumnya 900 detik (15 menit).
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureSudoMode
{
public function handle(Request $request, Closure $next): Response
{
$confirmedAt = (int) $request->session()->get('auth.password_confirmed_at', 0);
$timeout = config('auth.password_timeout', 900);
if ((time() - $confirmedAt) > $timeout) {
if ($request->header('X-Inertia')) {
return response()->json([
'message' => 'Sudo mode required.',
'sudo_required' => true,
], 423);
}
return redirect()->guest(route('password.confirm'));
}
return $next($request);
}
}
Tambahkan endpoint verifikasi dengan proteksi rate limiting ketat untuk mencegah brute-force attack terhadap password pengguna.
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class SudoController extends Controller
{
public function confirm(Request $request)
{
$request->validate([
'password' => ['required', 'string'],
]);
if (! Hash::check($request->password, $request->user()->password)) {
throw ValidationException::withMessages([
'password' => [__('auth.password')],
]);
}
$request->session()->put('auth.password_confirmed_at', time());
return response()->noContent();
}
}
Daftarkan rute dan pasang throttle rate limit maksimum 5 percobaan per menit:
use App\Http\Controllers\SudoController;
use App\Http\Middleware\EnsureSudoMode;
Route::post('/sudo/confirm', [SudoController::class, 'confirm'])
->middleware(['auth', 'throttle:5,1'])
->name('sudo.confirm');
Route::delete('/account/api-keys/{key}', [ApiKeyController::class, 'destroy'])
->middleware(['auth', EnsureSudoMode::class]);
Frontend: Event Interception & Request Replay
Inertia menyediakan event lifecycle global. Event invalid dipicu ketika backend mengembalikan respons non-Inertia (seperti JSON 423). Frontend mengintersepsi event ini, menyimpan konteks request yang gagal, memunculkan modal dialog, dan melakukan request ulang ketika verifikasi berhasil.
import { router } from '@inertiajs/vue3';
import { reactive, ref } from 'vue';
export const sudoState = reactive({
isOpen: false,
pendingVisit: null,
});
export function useSudo() {
function initSudoInterceptor() {
router.on('invalid', (event) => {
const response = event.detail.response;
if (response.status === 423 && response.data?.sudo_required) {
// Cegah modal error default Inertia (modal HTML error overlay)
event.preventDefault();
// Simpan payload kunjungan terakhir untuk di-replay
sudoState.pendingVisit = event.detail.visit;
sudoState.isOpen = true;
}
});
}
function replayPendingRequest() {
if (!sudoState.pendingVisit) return;
const visit = sudoState.pendingVisit;
sudoState.pendingVisit = null;
sudoState.isOpen = false;
router.visit(visit.url, {
method: visit.method,
data: visit.data,
preserveScroll: true,
preserveState: true,
headers: visit.headers,
});
}
return { sudoState, initSudoInterceptor, replayPendingRequest };
}
Komponen Modal Re-Autentikasi (Vue 3 Script Setup)
Komponen modal mengeksekusi POST ke /sudo/confirm via axios murni (bukan router.post) agar tidak mengganggu stack riwayat Inertia atau mereset props halaman induk.
<script setup>
import { ref } from 'vue';
import axios from 'axios';
import { sudoState, useSudo } from '@/composables/useSudo';
const { replayPendingRequest } = useSudo();
const password = ref('');
const error = ref('');
const loading = ref(false);
async function submitConfirm() {
loading.value = true;
error.value = '';
try {
await axios.post(route('sudo.confirm'), { password: password.value });
password.value = '';
replayPendingRequest();
} catch (err) {
if (err.response?.status === 422) {
error.value = err.response.data.errors?.password?.[0] ?? 'Password salah.';
} else if (err.response?.status === 429) {
error.value = 'Terlalu banyak percobaan. Silakan tunggu beberapa saat.';
} else {
error.value = 'Terjadi kesalahan sistem.';
}
} finally {
loading.value = false;
}
}
</script>
<template>
<div v-if="sudoState.isOpen" class="modal-backdrop">
<div class="modal-content">
<h3>Konfirmasi Akses Sensitif</h3>
<p>Masukkan password Anda untuk melanjutkan aksi ini.</p>
<form @submit.prevent="submitConfirm">
<input
type="password"
v-model="password"
placeholder="Kata Sandi"
required
autocomplete="current-password"
/>
<span v-if="error" class="text-danger">{{ error }}</span>
<div class="actions">
<button type="button" @click="sudoState.isOpen = false">Batal</button>
<button type="submit" :disabled="loading">Verifikasi</button>
</div>
</form>
</div>
</div>
</template>
Pengujian Otomatis (Feature Test)
Verifikasi bahwa mutasi terproteksi dengan benar saat timestamp session tidak ada, kedaluwarsa, dan valid.
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class SudoModeTest extends TestCase
{
use RefreshDatabase;
public function test_mutation_blocked_if_sudo_not_confirmed(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->withHeaders(['X-Inertia' => 'true'])
->deleteJson('/account/api-keys/1');
$response->assertStatus(423)
->assertJson(['sudo_required' => true]);
}
public function test_mutation_blocked_if_sudo_expired(): void
{
$user = User::factory()->create();
$expiredTimestamp = time() - 1000; // Default timeout: 900 detik
$response = $this->actingAs($user)
->withSession(['auth.password_confirmed_at' => $expiredTimestamp])
->withHeaders(['X-Inertia' => 'true'])
->deleteJson('/account/api-keys/1');
$response->assertStatus(423);
}
public function test_sudo_confirmation_allows_mutation(): void
{
$user = User::factory()->create([
'password' => bcrypt('secret-password'),
]);
$confirmResponse = $this->actingAs($user)
->postJson('/sudo/confirm', [
'password' => 'secret-password',
]);
$confirmResponse->assertNoContent();
$this->assertNotNull(session('auth.password_confirmed_at'));
$mutationResponse = $this->actingAs($user)
->withHeaders(['X-Inertia' => 'true'])
->deleteJson('/account/api-keys/1');
$mutationResponse->assertSuccessful();
}
}
Trade-offs dan Catatan Produksi
- File Upload Handling: Jika mutasi awal membawa
FormDataberisi instance file biner (UploadFile), replay manual via objek JSON murni akan gagal. Untuk mutasi bertipemultipart/form-data, pastikanvisit.datatetap dipertahankan sebagaiFormData. - Dual Factor / WebAuthn (Passkeys): Untuk level keamanan lebih tinggi, ganti atau perkuat verifikasi password pada
/sudo/confirmdengan tantangan WebAuthn/FIDO2. Logika interseptor frontend tetap identik karena bergantung pada status code423.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!