Implementasi autentikasi dua faktor (2FA) berbasis Time-based One-Time Password (TOTP) pada monolit modern seperti Laravel dan Inertia.js sering kali memicu celah keamanan terselubung: kebocoran data terautentikasi sebelum proses verifikasi kedua selesai.
Masalah ini terjadi ketika aplikasi mencatat user ke dalam guard autentikasi utama segera setelah password valid, lalu mengandalkan redirection atau middleware rute parsial untuk menghadang user di halaman tantangan 2FA. Pada arsitektur Inertia, pendekatan tersebut mengekspos state internal aplikasi ke browser melalui shared props.
Anatomi Masalah: Kebocoran Data di HandleInertiaRequests
Inertia.js bekerja dengan memuat payload JSON pada setiap request, baik via initial page visit (root HTML via data-page) maupun navigasi XHR. Middleware HandleInertiaRequests bertugas membagikan state global ke komponen frontend melalui method share().
Jika alur login Anda memanggil Auth::login($user) tepat setelah validasi password, maka pada siklus request berikutnya ke route two-factor.challenge:
Auth::check()menghasilkan nilaitrue.$request->user()mengembalikan model user lengkap.- Shared props yang didefinisikan secara global (seperti profil user, role, permission, preferensi organisasi, atau notifikasi) dievaluasi dan dikirim langsung ke frontend.
Payload ini tersimpan pada window objek JavaScript (window.__page.props) dan terserialisasi di DOM, dapat diinspeksi melalui Network tab atau Inertia DevTools tanpa memasukkan kode TOTP sama sekali.
Peringatan Keamanan: Jangan gunakan guard autentikasi utama sebelum tantangan sekunder terpenuhi secara tuntas. User yang baru lolos verifikasi kredensial dasar harus dianggap sebagai tamu bersyarat (unauthenticated pending state).
Arsitektur Isolasi Pre-Auth Session State
Solusi standar industri untuk memutus celah ini adalah memisahkan siklus hidup kredensial primer dan sekunder. User tidak boleh dimasukkan ke dalam guard auth resmi (misal: guard web). Sebagai gantinya, simpan token referensi sementara di dalam session storage.
Alur kerja yang aman terdiri dari tahapan berikut:
- Validasi kredensial utama (email dan password).
- Jika kredensial valid dan 2FA aktif, simpan ID user terenkripsi dan timestamp kedaluwarsa sementara ke session khusus, lalu redirect ke endpoint tantangan.
- Gunakan middleware untuk melindungi rute tantangan dari akses publik yang tidak memiliki sesi sementara.
- Pastikan
HandleInertiaRequestsmengabaikan user jika sesi 2FA masih berstatus intermediat. - Setelah token TOTP tervalidasi, bersihkan session intermediat dan panggil
Auth::login()secara penuh.
Implementasi Backend: Controller dan Middleware
Berikut implementasi login controller yang mengisolasi session tanpa mengaktifkan sesi login resmi:
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class AuthenticatedSessionController extends Controller
{
public function store(Request $request)
{
$request->validate([
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
]);
$user = User::where('email', $request->email)->first();
if (! $user || ! Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages([
'email' => __('auth.failed'),
]);
}
if ($user->two_factor_confirmed_at) {
$request->session()->put([
'login.id' => $user->id,
'login.remember' => $request->boolean('remember'),
'login.expires_at' => now()->addMinutes(5)->timestamp,
]);
return redirect()->route('two-factor.challenge');
}
auth()->login($user, $request->boolean('remember'));
$request->session()->regenerate();
return redirect()->intended(route('dashboard'));
}
}Selanjutnya, perketat middleware HandleInertiaRequests. Pastikan prop yang memuat detail identitas hanya dieksekusi bila guard auth resmi aktif secara legal:
namespace App\Http\Middleware;
use Illuminate\Http\Request;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
{
public function share(Request $request): array
{
return array_merge(parent::share($request), [
'auth' => [
'user' => $request->user() ? [
'id' => $request->user()->id,
'name' => $request->user()->name,
'email' => $request->user()->email,
] : null,
],
]);
}
}Gunakan middleware khusus untuk memastikan user hanya bisa membuka rute 2FA jika session intermediat tersedia dan belum kedaluwarsa:
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureTwoFactorPending
{
public function handle(Request $request, Closure $next): Response
{
$expiresAt = $request->session()->get('login.expires_at');
if (! $request->session()->has('login.id') || now()->timestamp > $expiresAt) {
$request->session()->forget(['login.id', 'login.remember', 'login.expires_at']);
return redirect()->route('login');
}
return $next($request);
}
}Verifikasi TOTP dengan Rate Limiting
Endpoint verifikasi wajib mengimplementasikan rate limiting ketat untuk menahan serangan brute-force pada 6 digit TOTP (hanya 1.000.000 kombinasi). Gunakan RateLimiter internal Laravel:
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
use PragmaRX\Google2FA\Google2FA;
class TwoFactorAuthenticatedSessionController extends Controller
{
public function store(Request $request)
{
$request->validate(['code' => ['required', 'string']]);
$throttleKey = '2fa_' . $request->session()->get('login.id') . '|' . $request->ip();
if (RateLimiter::tooManyAttempts($throttleKey, 5)) {
$seconds = RateLimiter::availableIn($throttleKey);
throw ValidationException::withMessages([
'code' => __("Terlalu banyak percobaan. Coba lagi dalam :seconds detik.", ['seconds' => $seconds]),
])->status(429);
}
$user = User::findOrFail($request->session()->get('login.id'));
$google2fa = new Google2FA();
$valid = $google2fa->verifyKey(
decrypt($user->two_factor_secret),
$request->code,
0 // window toleransi 0 = hanya valid untuk frame 30 detik saat ini
);
if (! $valid) {
RateLimiter::hit($throttleKey, 300);
throw ValidationException::withMessages([
'code' => __('Kode autentikasi tidak valid.'),
]);
}
RateLimiter::clear($throttleKey);
$remember = $request->session()->pull('login.remember', false);
$request->session()->forget(['login.id', 'login.expires_at']);
auth()->login($user, $remember);
$request->session()->regenerate();
return redirect()->intended(route('dashboard'));
}
}Implementasi Frontend: Form TOTP dan Cache Invalidation
Pada sisi frontend (Vue 3 / Inertia), tangani state pengiriman form menggunakan useForm. Kode harus menangani feedback validasi, status HTTP 429, serta membersihkan input secara otomatis bila verifikasi gagal.
<script setup>
import { ref } from 'vue';
import { useForm } from '@inertiajs/vue3';
const inputRef = ref(null);
const form = useForm({
code: '',
});
const submit = () => {
form.post(route('two-factor.verify'), {
preserveScroll: true,
onFinish: () => {
form.reset('code');
if (inputRef.value) {
inputRef.value.focus();
}
},
});
};
</script>
<template>
<form @submit.prevent="submit">
<div>
<label for="code">Kode Autentikasi 6-Digit</label>
<input
id="code"
ref="inputRef"
v-model="form.code"
type="text"
inputmode="numeric"
pattern="[0-9]*"
maxlength="6"
autocomplete="one-time-code"
required
:disabled="form.processing"
/>
<p v-if="form.errors.code" class="error-text">
{{ form.errors.code }}
</p>
</div>
<button type="submit" :disabled="form.processing">
Verifikasi Masuk
</button>
</form>
</template>Mencegah Kebocoran History Browser
Ketika user me-logout akun atau proses 2FA digagalkan, browser masih dapat menyimpan snapshot DOM via Back-Forward Cache (bfcache). Pastikan header response HTTP di tingkat web server atau middleware Laravel membatasi caching pada rute autentikasi:
$response->headers->set('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
$response->headers->set('Pragma', 'no-cache');Dengan mengisolasi session ID sebelum autentikasi selesai dan memvalidasi state props secara ketat, kebocoran payload identitas pada monolit Inertia dapat dicegah tanpa mengorbankan performa Single Page Application.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!