Episode ini mengubah login menjadi dua langkah: verifikasi password yang hanya menandai pendingMfa, lalu verifikasi kode TOTP yang baru memberikan session penuh. Kalian juga mempelajari pemisahan endpoint /login dan /login/mfa serta cara mencegah bypass protected route sebelum MFA selesai.

Setelah secret tersimpan dan 2FA aktif, saatnya menutup celah terbesar yang tersisa: alur login. Episode 8 mengubah login satu langkah menjadi dua langkah: verifikasi password dulu, lalu tantangan 2FA, baru session penuh dikeluarkan.
Dua langkah ini harus benar-benar terpisah dan berurutan. Jika tidak, penyerang yang punya password — dari credential stuffing atau phishing — langsung mendapat akses tanpa kode. Seluruh nilai 2FA bergantung pada pemisahan ini, sehingga desain endpoint dan middleware menjadi bahan utama episode ini.
Pada langkah pertama, server memeriksa password. Jika valid dan pengguna mengaktifkan 2FA, server tidak langsung membuat session penuh — ia hanya menandai status pendingMfa pada session:
app.post('/login', async (req, res) => {
const user = await findUserByEmail(req.body.email);
if (!user || !(await bcrypt.compare(req.body.password, user.password_hash))) {
return res.status(401).json({ error: 'Kredensial salah' });
}
if (user.totp_enabled) {
req.session.pendingMfa = user.id;
return res.json({ mfaRequired: true });
}
req.session.userId = user.id;
res.json({ ok: true });
});Perhatikan bahwa pendingMfa berisi identitas pengguna, tapi bukan pengganti autentikasi — itu hanya tanda bahwa password sudah benar dan langkah berikutnya adalah kode TOTP. Pengguna tanpa 2FA tetap melewati alur lama.
Langkah kedua menerima kode TOTP, memverifikasinya terhadap secret, lalu menukar status pending menjadi session penuh:
app.post('/login/mfa', async (req, res) => {
if (!req.session.pendingMfa) {
return res.status(403).json({ error: 'Mulai login terlebih dahulu' });
}
const user = await findUserById(req.session.pendingMfa);
const secret = decryptSecret(user.totp_secret_encrypted);
const valid = authenticator.check(req.body.token, secret);
if (!valid) {
return res.status(400).json({ error: 'Kode tidak valid' });
}
req.session.userId = user.id;
delete req.session.pendingMfa;
res.json({ ok: true });
});Setelah baris terakhir dieksekusi, session hanya memiliki userId tanpa pendingMfa — inilah satu-satunya kondisi yang dianggap login lengkap.
Pemisahan fisik antara POST /login dan POST /login/mfa bukan sekadar keindahan kode, melainkan kontrol keamanan. Dengan dua endpoint, kalian bisa memberi rate limit yang berbeda pada masing-masing (episode 10), mencatat kegagalan tantangan MFA secara terpisah, dan menghindari satu handler yang mencampur dua tanggung jawab.
Struktur alurnya:
password benar -> pendingMfa set -> user lihat halaman kode
kode benar -> userId set -> pendingMfa dihapus -> akses penuhKalau pengguna menutup halaman di tengah, pendingMfa kedaluwarsa bersama session — proses dimulai ulang dari langkah pertama. Ini membuat alur deterministik dan mudah diuji.
Bypass adalah ancaman nomor satu di fitur ini. Penyerang bisa mencoba mengakses route yang dilindungi dengan session parsial, atau memanipulasi cookie agar seolah MFA sudah selesai. Satu middleware mematikan semua jalur itu:
function requireAuth(req, res, next) {
if (!req.session.userId || req.session.pendingMfa) {
return res.status(401).json({ error: 'Autentikasi belum selesai' });
}
next();
}
app.use('/account', requireAuth);
app.use('/dashboard', requireAuth);Middleware memeriksa dua hal sekaligus: userId harus ada, dan pendingMfa harus sudah hilang. Pasangkan requireAuth pada setiap route yang sensitif — jangan hanya pada halaman depan. Kebiasaan buruk yang sering terjadi adalah mengamankan endpoint yang terlihat, tapi lupa endpoint pengaturan akun atau API internal.
Status pendingMfa harus berumur pendek. Jika pengguna berhenti di tengah alur, tantangan harus kedaluwarsa otomatis — misalnya 5 menit — dan mengharuskan mulai dari password lagi. Batasi waktu ini di konfigurasi session agar kode yang tertinggal tidak bisa dipakai berjam-jam kemudian.
Pengguna yang belum mengaktifkan 2FA tidak boleh melewati endpoint MFA. Handler langkah pertama sudah menangani ini: hanya ketika totp_enabled bernilai true alur bercabang ke tantangan. Pastikan flag ini dibaca langsung dari database saat login, bukan dari input client.
Jika verifikasi kode gagal, pendingMfa tetap ada — pengguna tinggal mencoba kode lain. Tapi kegagalan berulang harus dibatasi oleh rate limiting dan pencatatan, karena pola gagal-berulang bisa berarti penyerang mencoba menebak kode. Episode 10 dan 16 akan menguji skenario ini.
Episode 8 mewujudkan alur login dua langkah: password hanya menandai pendingMfa, kode TOTP menukarnya menjadi session penuh, dan middleware requireAuth memblokir semua akses sebelum MFA selesai.
Inti yang harus dibawa pulang:
Di episode 9 selanjutnya kita akan membahas backup & recovery codes — membuat 10 kode single-use, menyimpannya sebagai hash seperti password, menandai kode yang sudah terpakai, dan mengganti batch kode lama saat regenerasi.