Multi-factor authentication (MFA) meminta lebih dari satu bukti saat login, dan bukti-buktinya harus dari jenis yang berbeda. Bayangin ATM: kamu butuh kartu (sesuatu yang kamu punya) dan PIN (sesuatu yang kamu tahu). Kartu yang dicuri gak berguna tanpa PIN, dan PIN yang diintip gak berguna tanpa kartunya. MFA membawa ide itu ke login: password saja gak cukup.
🧪 Tebakan cepat: jam 07.00.10, aplikasi authenticator di HP Bu Dewi menampilkan kode
911338. Dia baru mengirim kode itu jam 07.01.40, 90 detik kemudian. Server Portal Sekolah menerimanya? Tebak dulu, jawabannya ada di bagian "TOTP: kode yang berganti tiap 30 detik".
Tiga jenis faktor
| Faktor | Artinya | Contoh |
|---|---|---|
| Sesuatu yang kamu tahu (knowledge) | rahasia di kepala | password, PIN |
| Sesuatu yang kamu punya (possession) | benda yang kamu pegang | HP dengan aplikasi authenticator, security key |
| Sesuatu yang kamu adalah (inherence) | bagian tubuhmu | sidik jari, wajah |
MFA menggabungkan minimal dua jenis yang berbeda. Password ditambah PIN itu dua hal yang sama-sama "tahu", jadi bukan MFA. Password ditambah kode dari HP itu "tahu" plus "punya": ini 2FA (two-factor authentication), bentuk MFA yang paling umum. Di Portal Sekolah, MFA wajib untuk role admin: akun Bu Dewi bisa menghapus user, jadi password-nya saja gak boleh cukup.
Login dua langkah
Password yang benar belum memberi access token. Server cuma memberi mfa_token: tanda berumur pendek bahwa langkah pertama lolos, yang cuma bisa dipakai di endpoint langkah kedua.
POST /auth/login
{"email": "[email protected]", "password": "rahasia-bu-dewi"}
HTTP/1.1 200 OK
{"mfa_required": true, "mfa_token": "mt_7Qx2", "expires_in": 300}
POST /auth/mfa
{"mfa_token": "mt_7Qx2", "code": "911338", "remember_device": false}
HTTP/1.1 200 OK
Set-Cookie: refresh_token=R1; Max-Age=2592000; Path=/auth/refresh; Secure; HttpOnly; SameSite=Strict
{"access_token": "A1", "token_type": "Bearer", "expires_in": 900}
mfa_tokenberlaku 300 detik (5 menit). Lewat dari itu, mulai lagi dari password.- Kode yang salah dijawab
401 {"message": "Kode salah."}. Batasi percobaannya, misalnya 5 kali permfa_token, supaya 1.000.000 kemungkinan kode (000000sampai999999) gak bisa dicoba satu per satu. - Setelah kode diterima, baru token biasa keluar: access token dan refresh token, seperti di lesson Token Refresh Flow.
TOTP: kode yang berganti tiap 30 detik
Faktor kedua yang paling umum adalah TOTP (Time-based One-Time Password, RFC 6238). Saat Bu Dewi mengaktifkan MFA, server membuat secret acak dan menampilkannya sebagai QR code. Isi QR-nya:
otpauth://totp/Portal%20Sekolah:[email protected]?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Portal%20Sekolah
Aplikasi authenticator (Google Authenticator, Authy, dan lain-lain) menyimpan secret itu. Sejak saat itu HP dan server menghitung kode yang sama, masing-masing sendiri, tanpa saling mengirim apa pun: counter = waktu Unix dibagi 30 lalu dibulatkan ke bawah, lalu HMAC-SHA1 dari counter itu dengan secret, lalu dipotong jadi 6 digit.
async function totp(secretBytes, counter) {
const msg = new Uint8Array(8);
new DataView(msg.buffer).setBigUint64(0, BigInt(counter)); // counter, 8 byte big-endian
const key = await crypto.subtle.importKey("raw", secretBytes, { name: "HMAC", hash: "SHA-1" }, false, ["sign"]);
const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, msg)); // 20 byte
const offset = mac[19] & 0x0f; // dynamic truncation
const bin = ((mac[offset] & 0x7f) << 24) | (mac[offset + 1] << 16) | (mac[offset + 2] << 8) | mac[offset + 3];
return String(bin % 1000000).padStart(6, "0");
}
async function verifyCode(secretBytes, code, unixSeconds) {
const counter = Math.floor(unixSeconds / 30);
for (const c of [counter - 1, counter, counter + 1]) { // window ±1
if ((await totp(secretBytes, c)) === code) return true;
}
return false;
}
- Secret contoh di Lab adalah secret uji dari RFC 6238, teks
12345678901234567890(base32-nya yang ada di QR tadi). Dengan secret itu, detik ke-59 (counter 1) menghasilkan287082, persis seperti tabel uji RFC. Di aplikasi asli, secret dibuat acak untuk tiap user. padStart(6, "0")menjaga nol di depan:81804ditulis081804.verifyCodemenerima counter sekarang dan satu di kiri-kanannya. Alasannya ada di bawah.- Implementasi lengkapnya (base32, QR, mencegah kode yang sama dipakai dua kali) dibangun di lesson TOTP Implementation.
Server gak cuma mencocokkan counter sekarang. Jam HP dan server bisa selisih sedikit, jadi dia juga menerima satu counter sebelum dan sesudahnya (window ±1). Jam 07.00.10, waktu Unix-nya 1791504010 dan counter-nya 59716800:
| Counter | Berlaku | Kode | Diterima jam 07.00.10? |
|---|---|---|---|
| 59716799 | 06.59.30 sampai 06.59.59 | 839888 |
ya, window -1 |
| 59716800 | 07.00.00 sampai 07.00.29 | 911338 |
ya, counter sekarang |
| 59716801 | 07.00.30 sampai 07.00.59 | 736890 |
ya, window +1 |
Jam 07.01.40, counter sudah 59716803, jadi yang diterima 736890, 718685, dan 914220. Kode 911338 tiga counter di belakang: ditolak. Itu jawaban tebakannya.
Kenapa TOTP, bukan SMS
Kode SMS dikirim lewat jaringan operator, dan nomor telepon bisa dipindahkan ke kartu SIM lain (SIM swapping): siapa pun yang memegang nomornya menerima kodenya. TOTP dihitung di HP itu sendiri, tanpa jaringan, dari secret yang cuma dibagikan sekali saat setup. Jadi TOTP lebih kuat dari SMS. Yang lebih kuat lagi adalah passkey dan security key, dibahas di lesson Passwordless & Passkeys.
Recovery codes
HP hilang berarti faktor "punya" ikut hilang. Supaya Bu Dewi gak terkunci selamanya, saat MFA diaktifkan server membuat recovery codes: 10 kode sekali pakai, misalnya 7f3a-91c2.
$codes = collect(range(1, 10))->map(fn () => implode('-', str_split(bin2hex(random_bytes(4)), 4)));
$user->recoveryCodes()->delete(); // regenerate: kode lama langsung gak berlaku
foreach ($codes as $code) {
$user->recoveryCodes()->create(['code_hash' => Hash::make($code)]);
}
return response()->json(['recovery_codes' => $codes]); // satu-satunya kali kode ini terlihat
random_bytes(4)memberi 8 karakter hex acak, ditulisxxxx-xxxxsupaya gampang dibaca.- Tiap kode disimpan sebagai hash bcrypt (
Hash::make), seperti password. Kalau database bocor, kodenya gak ikut terbaca. - Kode asli ditampilkan sekali, untuk dicetak atau disimpan user. Jangan dikirim lewat email atau ditulis ke log.
- Saat dipakai, kode yang cocok (
Hash::check) ditandaiused_at, jadi gak bisa dipakai lagi, dan user langsung diberi tahu lewat email: "kode pemulihan dipakai jam 07.01".
Kalau ternyata sebuah kode dipakai tanpa sepengetahuan pemiliknya, langkahnya: cabut semua sesi aktif, buat ulang semua recovery codes, dan pastikan pemberitahuan saat kode dipakai sudah ada.
Ingat perangkat ini
Meminta kode di setiap login itu melelahkan. Kalau Bu Dewi mencentang "Ingat perangkat ini" saat langkah kedua, server memasang cookie perangkat:
Set-Cookie: mfa_device=md_1; Max-Age=2592000; Path=/auth/login; Secure; HttpOnly; SameSite=Strict
Login berikutnya dari browser yang sama: password tetap diminta, tapi langkah kedua dilewati selama cookie itu sah (30 hari) dan tercatat di server. Server menyimpan hash-nya, jadi ganti password atau "keluar dari semua perangkat" bisa menghapusnya sekaligus.
⚠️ Jebakan yang sering ketemu
- Memberi access token sebelum faktor kedua. Token yang keluar setelah password saja membuat MFA cuma hiasan. Berikan
mfa_tokenyang cuma berlaku di/auth/mfa. - Recovery codes disimpan apa adanya. Hash tiap kode dengan bcrypt, tampilkan sekali.
- Kode recovery dipakai diam-diam. Selalu kirim pemberitahuan saat kode dipakai, dan buat ulang berarti kode lama gak berlaku.
- Percobaan kode tanpa batas. 6 digit cuma sejuta kemungkinan. Batasi percobaan per
mfa_token. - "Ingat perangkat" yang melewati password. Cookie perangkat cuma menggantikan faktor kedua, bukan login-nya.
TL;DR: MFA = minimal dua jenis faktor berbeda: tahu (password), punya (HP), adalah (sidik jari). Login jadi dua langkah: password memberi mfa_token 5 menit, kode memberi access token. TOTP: counter = waktu Unix / 30, HMAC-SHA1 dengan secret dari QR, 6 digit, diterima dengan window ±1 (kode 90 detik lalu ditolak). TOTP lebih kuat dari SMS (SIM swapping). Recovery codes: 10 kode, hash bcrypt, tampilkan sekali, sekali pakai, beri tahu saat dipakai. Ingat perangkat: cookie HttpOnly 30 hari yang cuma melewati faktor kedua.