2FA dengan TOTP — Auth

TOTP (Time-based One-Time Password) adalah algoritma standar (RFC 6238) yang menghasilkan kode 6 digit yang berubah setiap 30 detik. Digunakan oleh Google…

TOTP (Time-based One-Time Password, standar RFC 6238) adalah kode 6 digit yang berganti tiap 30 detik, dipakai sebagai faktor kedua setelah password. Server dan aplikasi authenticator di HP (Google Authenticator, Authy, 1Password) menghitung kode yang sama masing-masing, tanpa saling mengirim apa pun. Bayangin dua orang yang memegang buku sandi yang sama persis, satu halaman untuk tiap setengah menit. Jam menunjukkan halaman mana yang dipakai sekarang, jadi keduanya membuka halaman yang sama dan membaca angka yang sama. Buku sandinya adalah secret, dan nomor halamannya dihitung dari jam.

🧪 Tebakan cepat: jam server Portal Sekolah menunjukkan 07.00.10, tapi jam HP Siti telat 20 detik, jadi HP-nya masih di 06.59.50. Kode di HP Siti diterima server atau ditolak? Tebak dulu, jawabannya ada di bagian "Verifikasi: jendela ±1 dan kode sekali pakai".

Menyalakan 2FA: secret dibagi sekali

Siti menyalakan 2FA di halaman profil Portal Sekolah. Yang terjadi:

  1. Server membuat secret: 20 byte acak dari crypto.getRandomValues (160 bit), lalu menuliskannya dalam base32, huruf A sampai Z dan angka 2 sampai 7, supaya bisa diketik manusia.
  2. Server menampilkan secret itu sebagai URI otpauth://, biasanya digambar jadi QR code. Siti memindainya dengan aplikasi authenticator, dan aplikasinya menyimpan secret itu.
  3. Server meminta satu kode dari aplikasi. Kalau cocok, 2FA baru dinyalakan. Ini memastikan Siti benar-benar sudah menyimpan secret-nya.

Lesson dan Lab ini memakai secret uji dari RFC 6238, teks 12345678901234567890, supaya hasilnya bisa kamu cek sendiri. URI-nya:

otpauth://totp/Portal%20Sekolah:siti%40sekolah.test?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Portal%20Sekolah&algorithm=SHA1&digits=6&period=30

Secret ini dibagi sekali saja, saat setup. Server menyimpannya terenkripsi, karena siapa pun yang memegang secret bisa membuat semua kode Siti.

Dari secret dan jam ke enam digit

Di HP maupun di server, rumusnya sama:

const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";

function base32Decode(text) {
  let bits = 0, value = 0;
  const bytes = [];
  for (const ch of text.replace(/=+$/, "").toUpperCase()) {
    const idx = ALPHABET.indexOf(ch);
    if (idx < 0) throw new Error("bukan huruf base32: " + ch);
    value = (value << 5) | idx; // tiap huruf membawa 5 bit
    bits += 5;
    if (bits >= 8) {
      bytes.push((value >>> (bits - 8)) & 0xff); // keluarkan 1 byte utuh
      bits -= 8;
    }
  }
  return new Uint8Array(bytes);
}

async function totp(secretBase32, unixSeconds, digits = 6) {
  const counter = Math.floor(unixSeconds / 30); // nomor langkah 30 detik
  const msg = new Uint8Array(8); // counter sebagai 8 byte, big-endian
  for (let i = 7, c = counter; i >= 0; i--, c = Math.floor(c / 256)) msg[i] = c & 0xff;

  const key = await crypto.subtle.importKey("raw", base32Decode(secretBase32), { 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 % 10 ** digits).padStart(digits, "0");
}

Cek hasilnya dengan vektor uji RFC 6238

RFC 6238 memuat tabel uji (Appendix B): secret 12345678901234567890, waktu T = 59, 8 digit, harus menghasilkan 94287082. Ini jejak fungsi totp di atas, persis yang dihitung Lab:

Langkah Nilai
counter Math.floor(59 / 30) = 1
8 byte counter 00 00 00 00 00 00 00 01
HMAC-SHA1 75a48a19d4cbe100644e8ac1397eea747a2d33ab
offset byte terakhir ab, 0xab & 0x0f = 11
4 byte mulai byte ke-11 c1 39 7e ea
bit teratas dibuang 0x41397eea = 1.094.287.082
% 10 ** 8 94287082, sama dengan RFC
% 10 ** 6 287082

Kalau implementasimu menghasilkan 94287082 untuk input ini, berarti benar. Dengan secret yang sama, Jumat 9 Oktober 2026 pukul 07.00.00 WIB (Unix 1791504000) jadi counter 59716800 dan kode 911338.

Verifikasi: jendela ±1 dan kode sekali pakai

Server gak menerima kode yang dihitung untuk jamnya sendiri saja. Jam HP jarang persis sama dengan jam server, selisihnya disebut clock drift. Jadi server mencoba tiga counter: satu langkah sebelumnya, sekarang, dan satu sesudahnya.

async function verifyTotp(user, code, unixSeconds) {
  const now = Math.floor(unixSeconds / 30);
  for (const step of [-1, 0, 1]) { // toleransi jam yang meleset
    const counter = now + step;
    if ((await totp(user.secret, counter * 30)) !== code) continue;
    if (counter <= user.lastUsedCounter) return "sudah dipakai"; // kode sekali pakai
    user.lastUsedCounter = counter; // simpan di database
    return "diterima";
  }
  return "salah";
}

Di tebakan tadi, server di 07.00.10 berada di counter 59716800 (kode 911338). HP Siti di 06.59.50 masih di counter 59716799 dan menampilkan 839888. Itu cocok dengan langkah -1, jadi kodenya diterima. Itu jawaban tebakannya. Kalau jam HP telat lebih dari satu langkah, kodenya ditolak.

Kenapa cuma ±1? Tiap langkah tambahan berarti lebih banyak kode yang sah sekaligus, jadi menebak kode jadi lebih mudah. Satu langkah ke tiap arah adalah saran RFC 6238.

Baris lastUsedCounter mencegah replay: kode yang sama dikirim ulang, misalnya karena tombol ditekan dua kali atau ada yang sempat melihat layar HP. Selama jendela 90 detik itu kodenya masih cocok, jadi tanpa catatan ini kode yang sama bisa dipakai lagi. Server menyimpan counter terakhir yang diterima, dan menolak counter yang sama atau lebih lama.

Alur login dengan 2FA

POST /login {email, password}          password cocok, 2FA aktif
  -> 200 {"two_factor": true}           belum login, sesi cuma mencatat "menunggu kode"
POST /two-factor-challenge {code}       verifyTotp: "diterima"
  -> 302 Location: /dashboard           baru sekarang login, session ID di-regenerate

Batasi juga percobaan kode, misalnya 5 kali per menit, karena 6 digit cuma punya sejuta kemungkinan. Caranya dibahas di lesson Rate Limiting untuk Autentikasi. Kenapa faktor kedua penting, dibahas di lesson Multi-factor Authentication (MFA).

Latihan: HMAC-SHA1 tanpa await

crypto.subtle.sign selalu async, sedangkan latihan di bawah membaca console.log langsung. Jadi kode awalnya sudah membawa hmacSha1(kunci, pesan): SHA-1 dan HMAC yang ditulis di JavaScript biasa. Ia menerima dua array byte dan mengembalikan 20 byte yang sama persis dengan hasil crypto.subtle.sign. Kamu menulis sisanya, langkah yang sama dengan fungsi totp di atas:

function generateTotp(secret, timestamp) {
  const counter = Math.floor(timestamp / 30);
  const pesan = [];
  for (let i = 7; i >= 0; i--) pesan.push(Math.floor(counter / 2 ** (8 * i)) & 0xff); // 8 byte, big-endian
  const mac = hmacSha1(secret, pesan); // 20 byte
  // ...lalu dynamic truncation dan % 1000000, sama seperti totp()
}

secret di latihan sudah berupa byte (secret uji RFC, teks 12345678901234567890), jadi gak perlu base32Decode. Hasilnya dicek dengan vektor uji RFC 6238 dalam 6 digit: T = 59 jadi 287082, dan T = 1234567890 jadi 005924. verifyTotp(secret, code, timestamp) mencoba timestamp - 30, timestamp, dan timestamp + 30, lalu mengembalikan true kalau salah satunya sama dengan code.

💡 Recovery code dan Laravel Fortify

HP bisa hilang. Saat 2FA dinyalakan, beri 8 sampai 10 recovery code sekali pakai, simpan hash-nya, dan coret yang sudah dipakai. Di Laravel, paket resmi Fortify menyediakan semua ini: kolom two_factor_secret (terenkripsi), two_factor_recovery_codes, dan two_factor_confirmed_at di tabel users, route POST /two-factor-challenge, dan pengecekan yang menolak kode yang sama dipakai dua kali.

⚠️ Jebakan yang sering ketemu

TL;DR: TOTP (RFC 6238) = HMAC-SHA1(secret, counter), counter = Math.floor(unixDetik / 30) sebagai 8 byte big-endian, lalu dynamic truncation (offset dari 4 bit terakhir, 4 byte, buang bit teratas) dan % 10 ** 6, dengan nol di depan. Secret dibagi sekali lewat URI otpauth:// (QR), dalam base32. Server mencoba counter -1, 0, +1 untuk clock drift, dan menyimpan counter terakhir yang diterima supaya kode yang sama gak bisa dipakai dua kali. Cek implementasimu: secret 12345678901234567890, T = 59, 8 digit, harus 94287082.

Yang akan kamu pelajari