Autentikasi Dua Langkah & Sesi Pending OTP
Mekanisme login dua tahap yang menggabungkan verifikasi password, pembatas percobaan login (rate limiter), pengiriman kode OTP ke email, dan penerbitan token bertahap.
Membaca sekitar 2 menit
Mekanisme login dua tahap yang menggabungkan verifikasi password, pembatas percobaan login (rate limiter), pengiriman kode OTP ke email, dan penerbitan token bertahap.
Endpoint login memvalidasi password dengan bcrypt, membatasi laju percobaan login, mengirim OTP via Nodemailer, lalu menaruh token sementara berstatus 'pending' hingga kode OTP terverifikasi.
Menyimpan status tahap verifikasi di dalam payload JWT yang ditandatangani membuat alur OTP tetap stateless tanpa perlu tabel sesi sementara di database.
Masalahnya dulu
Untuk aplikasi keuangan pribadi, mengandalkan email dan password saja terlalu berisiko jika kredensial pengguna bocor. Namun, menambahkan verifikasi OTP dua langkah (2FA) via email baik saat registrasi maupun saat login sering kali membuat alur sesi menjadi rumit: bagaimana server tahu bahwa pengguna di halaman verifikasi OTP memang baru saja berhasil memasukkan password yang benar?
Token sementara berstatus 'pending' dan pembatas laju (Rate Limit)
Solusi yang diterapkan di MyPocket adalah menerbitkan JWT sementara dengan properti role: 'pending' sesaat setelah password cocok di endpoint login. Selain itu, sebelum menyentuh database, request login disaring terlebih dahulu oleh rate limiter (checkRateLimit) untuk menahan serangan brute-force.
Jangan berikan kunci penuh sebelum langkah kedua selesai; cukup berikan tiket tunggu yang hanya berlaku di ruang verifikasi.
Implementasi di ngodingan ini
Berikut adalah alur pada endpoint login saat password tervalidasi, kode OTP dibuat dengan batas waktu kedaluwarsa, dikirim ke email, lalu disematkan bersama cookie token sementara:
export async function POST(req: NextRequest) {
if (!checkRateLimit(clientKey, MAX_ATTEMPTS, WINDOW_MS)) {
return fail("Terlalu banyak percobaan login. Silakan coba beberapa saat lagi.", 429);
}
const { email, password } = await req.json();
const user = await findUserByEmail(email.toLowerCase());
if (!user || !(await bcrypt.compare(password, user.password))) {
return fail("Email atau password salah.", 401);
}
const otp = generateSixDigitOtp();
const otpExpired = new Date(Date.now() + OTP_TTL_MS);
await saveUserOtp(user.uuid, otp, otpExpired);
await sendLoginOtpEmail(user.email, user.name, otp);
const tempToken = await signToken({
uuid: user.uuid, email: user.email, name: user.name, role: "pending",
});
const res = NextResponse.json({ success: true, data: { email: user.email, requireOtp: true } });
res.cookies.set(cookieOpts(tempToken));
return res;
}Ketika pengguna memasukkan 6 digit OTP di endpoint verifikasi, server mengecek kecocokan dan waktu kedaluwarsa, membersihkan kolom OTP menjadi NULL agar tidak bisa dipakai dua kali, membuat kantong tabungan default saat registrasi awal, lalu menimpa cookie sesi dengan JWT penuh.
Jebakan dan detail yang harus dijaga
- Menangani akun yang mendaftar ulang sebelum verifikasi OTP selesai: jika email sudah tercatat tetapi akun belum aktif, sistem tidak menolak pendaftaran melainkan memperbarui hash password dan mengirim ulang OTP baru.
- Menggunakan pustaka jose (SignJWT dan jwtVerify) alih-alih jsonwebtoken klasik karena jose berbasis Web Crypto API sehingga berjalan mulus di lingkungan Edge Runtime Next.js.