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 FormData berisi instance file biner (UploadFile), replay manual via objek JSON murni akan gagal. Untuk mutasi bertipe multipart/form-data, pastikan visit.data tetap dipertahankan sebagai FormData.
  • Dual Factor / WebAuthn (Passkeys): Untuk level keamanan lebih tinggi, ganti atau perkuat verifikasi password pada /sudo/confirm dengan tantangan WebAuthn/FIDO2. Logika interseptor frontend tetap identik karena bergantung pada status code 423.