Kata sandi sekuat apa pun tetap satu rahasia tunggal: cukup satu phising yang berhasil, satu database yang bocor, atau satu keylogger di komputer umum untuk membuatnya jatuh ke tangan orang lain. Multi-Factor Authentication (MFA) menutup celah tersebut dengan menuntut bukti tambahan dari kategori yang berbeda — pengetahuan, pemilikan benda, atau karakteristik tubuh — sehingga pencuri kata sandi masih harus menguasai “sesuatu yang lain” milik korban. Pada chapter ini kita bedah taksonomi faktor, bangun inti algoritma TOTP (kode 6 digit yang berganti setiap 30 detik) langsung dengan hash_hmac(), kelola kode cadangan sebagai jala penyelamat, lalu tutup dengan konsep WebAuthn. Seluruh contoh memakai waktu dan secret beku sehingga outputnya deterministik dan dapat Anda bandingkan baris demi baris.
Tiga Kategori Faktor Autentikasi #
Faktor autentikasi dikelompokkan menjadi tiga kategori, dan kekuatan MFA justru datang dari menggabungkan kategori yang berbeda — bukan sekadar menumpuk bukti dari kategori yang sama. Kata sandi dicuri lewat phising tidak menolong penyerang bila pintu kedua menuntut ponsel korban; ponsel yang dicuri tidak cukup bila penyerang tidak tahu kata sandinya. Berikut ketiganya beserta risiko khasnya:
| Kategori | Pertanyaan yang dijawab | Contoh | Risiko utama |
|---|---|---|---|
| Knowledge (pengetahuan) | Apa yang Anda tahu? | Kata sandi, PIN, jawaban pertanyaan keamanan | Dicuri lewat phising, kebocoran database, atau ditebak |
| Possession (pemilikan) | Apa yang Anda miliki? | Ponsel dengan aplikasi autentikator, kunci hardware | Hilang, dicuri, atau diambil alih (SIM swap untuk SMS) |
| Inherence (karakteristik) | Apa yang Anda? | Sidik jari, wajah, pola ketik | Tidak bisa diganti begitu datanya bocor |
Istilah Two-Factor Authentication (2FA) merujuk kasus khusus ber dua faktor, sedangkan MFA berlaku umum untuk dua faktor atau lebih. Perhatikan bahwa dua kata sandi, atau kata sandi plus PIN, tetap berkategori knowledge — keduanya bisa dicuri dengan cara yang sama, jadi kombinasi demikian bukan MFA.
TOTP: Kode yang Berganti dari Rahasia Bersama #
TOTP (Time-based One-Time Password, didefinisikan RFC 6238) adalah faktor possession paling umum dipakai aplikasi web. Saat pendaftaran, server membuat secret acak yang dibagikan kepada aplikasi autentikator di ponsel pengguna — biasanya lewat kode QR. Kedua pihak lalu menghitung kode 6 digit dari secret tersebut dan waktu saat ini, tanpa jaringan sama sekali: server dan ponsel tidak pernah saling mengirim apa pun setelah secret dibagikan. Kode yang valid pada satu jendela waktu 30 detik otomatis usai di jendela berikutnya, sehingga kode yang sempat terlihat orang lain cepat kehilangan nilainya.
HOTP: Kode dari Counter #
Fondasi TOTP adalah HOTP (RFC 4226): kode dihitung dari hash_hmac("sha1", counter, secret), lalu hasil hash 20 byte dipadatkan (dynamic truncation) menjadi beberapa digit angka. Mari kita implementasikan intinya — deterministik penuh, sehingga cocok untuk diuji:
<?php
function hotp(string $secret, int $counter, int $digits = 6): string
{
// Counter dikemas sebagai integer 8 byte big-endian, lalu di-HMAC.
$hash = hash_hmac("sha1", pack("J", $counter), $secret, true);
$offset = ord($hash[19]) & 0x0F;
// Dynamic truncation: ambil 4 byte mulai offset, buang bit tertinggi.
$value = (
((ord($hash[$offset]) & 0x7F) << 24)
| (ord($hash[$offset + 1]) << 16)
| (ord($hash[$offset + 2]) << 8)
| ord($hash[$offset + 3])
) % (10 ** $digits);
return str_pad((string) $value, $digits, "0", STR_PAD_LEFT);
}
$secretDemo = "12345678901234567890"; // secret uji resmi dari RFC 4226
for ($counter = 0; $counter <= 2; $counter++) {
echo "counter $counter => " . hotp($secretDemo, $counter) . "\n";
}Output yang Dihasilkan:
counter 0 => 755224
counter 1 => 287082
counter 2 => 359152Mari kita bedah fungsi hotp() baris demi baris:
pack("J", $counter): counter dikemas sebagai integer 8 byte big-endian — format yang dituntut RFC — lalu menjadi data yang di-HMAC bersama secret.$offset = ord($hash[19]) & 0x0F: byte terakhir hash menentukan posisi awal (0–15) untuk memilih 4 byte hasil.- Dynamic truncation: 4 byte tersebut dirangkai menjadi integer 31 bit (bit tertinggi dibuang lewat
& 0x7Fagar selalu positif), lalu diambil modulo10^digits. str_pad(..., "0", STR_PAD_LEFT): kode diawali nol bila hasilnya lebih pendek — kode042harus tetap tiga digit.
Output di atas bukan angka acak: 755224, 287082, dan 359152 persis sama dengan vektor uji resmi di lampiran RFC 4226. Kecocokan itu membuktikan implementasi kita mengikuti standar, sehingga kode yang dihitung akan sama dengan aplikasi autentikator komersial untuk secret dan counter yang sama.
Dari Waktu Menjadi Counter dan Base32 #
TOTP tinggal menukar counter HOTP dengan nomor langkah waktu: floor(waktuUnix / 30). Pukul 08:00:00 dan 08:00:29 berada pada langkah yang sama, pukul 08:00:30 sudah pindah langkah — itulah asal “kode berganti setiap 30 detik”. Secret juga lazim dikirim sebagai string Base32 karena alfabetnya (A–Z dan 2–7) menghindari karakter yang mudah tertukar oleh mata seperti 0/O dan 1/I:
<?php
function hotp(string $secret, int $counter, int $digits = 6): string
{
$hash = hash_hmac("sha1", pack("J", $counter), $secret, true);
$offset = ord($hash[19]) & 0x0F;
$value = (
((ord($hash[$offset]) & 0x7F) << 24)
| (ord($hash[$offset + 1]) << 16)
| (ord($hash[$offset + 2]) << 8)
| ord($hash[$offset + 3])
) % (10 ** $digits);
return str_pad((string) $value, $digits, "0", STR_PAD_LEFT);
}
function base32Decode(string $base32): string
{
$alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
$bits = "";
foreach (str_split($base32) as $char) {
if ($char === "=" || $char === " ") {
continue;
}
$pos = strpos($alphabet, $char);
if ($pos === false) {
throw new InvalidArgumentException("Karakter base32 tidak valid");
}
$bits .= str_pad(decbin($pos), 5, "0", STR_PAD_LEFT);
}
$raw = "";
foreach (str_split($bits, 8) as $byte) {
if (strlen($byte) === 8) {
$raw .= chr((int) bindec($byte));
}
}
return $raw;
}
function totp(string $secret, int $unixTime, int $step = 30, int $digits = 6): string
{
return hotp($secret, intdiv($unixTime, $step), $digits);
}
$secretBase32 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ";
$secretRaw = base32Decode($secretBase32);
var_dump($secretRaw === "12345678901234567890");
// Vektor uji resmi RFC 6238 (8 digit) untuk secret yang sama:
echo totp($secretRaw, 59, 30, 8) . "\n";
echo totp($secretRaw, 1111111109, 30, 8) . "\n";
// Format 6 digit yang dipakai aplikasi autentikator pada umumnya:
echo totp($secretRaw, 1111111109, 30, 6) . "\n";Output yang Dihasilkan:
bool(true)
94287082
07081804
081804Fungsi base32Decode() memetakan tiap karakter kembali ke nomor alfabetnya (5 bit per karakter), merangkai seluruh bit, lalu memotongnya menjadi byte — fungsi ini hanya alat bantu tampilan, bukan bagian kriptografi. Hasil bool(true) membuktikan decoding benar, dan dua baris berikutnya (94287082 serta 07081804) persis sama dengan vektor uji resmi RFC 6238 pada detik ke-59 dan ke-1111111109. Dengan ini mesin penghitung kode selesai; sisanya soal memverifikasi kode yang diketik pengguna.
Memverifikasi Kode dengan Jendela Waktu #
Dunia nyata tidak serapi waktu contoh: jam ponsel bisa tertinggal beberapa detik, dan kode yang diketik pengguna bisa jadi dihitung pada akhir langkah waktu sebelumnya. Karena itu verifikasi TOTP selalu menerima jendela toleransi ±1 langkah waktu — kode dari langkah sebelumnya dan sesudahnya dianggap sah. Perbandingan memakai hash_equals() (pola yang sama dengan chapter API Token
) agar waktu eksekusi tidak membocorkan posisi byte yang cocok.
Alur Pendaftaran MFA dan Gerbang Login #
Pendaftaran MFA yang benar tidak langsung mengaktifkan fitur begitu secret dibuat. Alurnya: (1) pengguna meminta mengaktifkan MFA, (2) server membuat secret acak dan menampilkannya sebagai kode QR, (3) pengguna memindai QR sehingga aplikasi autentikator mulai menghitung kode, (4) pengguna mengetik kode yang tampil untuk mengonfirmasi — baru setelah verifikasi sukses server menyimpan secret ke kolom totp_secret, (5) server menampilkan kode cadangan sekali di layar. Langkah keempat mencegah secret yang salah tersimpan membuat pengguna terkunci di luar akunnya sendiri. Skrip berikut merangkai pendaftaran dan gerbang login dua langkah secara utuh:
<?php
function hotp(string $secret, int $counter, int $digits = 6): string
{
$hash = hash_hmac("sha1", pack("J", $counter), $secret, true);
$offset = ord($hash[19]) & 0x0F;
$value = (
((ord($hash[$offset]) & 0x7F) << 24)
| (ord($hash[$offset + 1]) << 16)
| (ord($hash[$offset + 2]) << 8)
| ord($hash[$offset + 3])
) % (10 ** $digits);
return str_pad((string) $value, $digits, "0", STR_PAD_LEFT);
}
function base32Decode(string $base32): string
{
$alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
$bits = "";
foreach (str_split($base32) as $char) {
if ($char === "=" || $char === " ") {
continue;
}
$pos = strpos($alphabet, $char);
if ($pos === false) {
throw new InvalidArgumentException("Karakter base32 tidak valid");
}
$bits .= str_pad(decbin($pos), 5, "0", STR_PAD_LEFT);
}
$raw = "";
foreach (str_split($bits, 8) as $byte) {
if (strlen($byte) === 8) {
$raw .= chr((int) bindec($byte));
}
}
return $raw;
}
function verifyTotp(string $secretBase32, string $kode, int $unixTime, int $window = 1): bool
{
$counter = intdiv($unixTime, 30);
$secret = base32Decode($secretBase32);
for ($i = -$window; $i <= $window; $i++) {
if (hash_equals(hotp($secret, $counter + $i), $kode)) {
return true;
}
}
return false;
}
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
totp_secret TEXT
)");
$stmt = $pdo->prepare("INSERT INTO users (email, password_hash) VALUES (?, ?)");
$stmt->execute(["[email protected]", password_hash("rahasia-budi", PASSWORD_DEFAULT)]);
// --- Pendaftaran MFA ---
$secretBaru = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"; // produksi: acak per user
$now = 1111111109; // waktu contoh dibekukan
$kodeDariAplikasi = "081804"; // kode TOTP pada $now (lihat contoh sebelumnya)
if (verifyTotp($secretBaru, $kodeDariAplikasi, $now)) {
$pdo->prepare("UPDATE users SET totp_secret = ? WHERE email = ?")
->execute([$secretBaru, "[email protected]"]);
echo "MFA aktif untuk [email protected]\n";
}
// --- Gerbang login dua langkah ---
function loginDenganMfa(PDO $pdo, string $email, string $password, string $kode, int $now): string
{
$stmt = $pdo->prepare("SELECT password_hash, totp_secret FROM users WHERE email = ?");
$stmt->execute([$email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if ($user === false || !password_verify($password, $user["password_hash"])) {
return "DITOLAK: email atau kata sandi salah";
}
if ($user["totp_secret"] === null) {
return "LOLOS: password sah, MFA belum aktif";
}
return verifyTotp($user["totp_secret"], $kode, $now)
? "LOLOS: password dan kode TOTP sah"
: "DITOLAK: kode TOTP salah";
}
// Kasus 1: password benar; kode dihitung pada langkah waktu sebelumnya
// (jam ponsel tertinggal) — tetap lolos berkat jendela ±1.
$kodeTertinggal = hotp(base32Decode($secretBaru), intdiv($now, 30) - 1);
echo loginDenganMfa($pdo, "[email protected]", "rahasia-budi", $kodeTertinggal, $now) . "\n";
// Kasus 2: password benar, kode salah.
echo loginDenganMfa($pdo, "[email protected]", "rahasia-budi", "000000", $now) . "\n";
// Kasus 3: password salah — kode TOTP tidak diperiksa sama sekali.
echo loginDenganMfa($pdo, "[email protected]", "password-salah", "123456", $now) . "\n";Output yang Dihasilkan:
MFA aktif untuk [email protected]
LOLOS: password dan kode TOTP sah
DITOLAK: kode TOTP salah
DITOLAK: email atau kata sandi salahBagian penting dari skrip di atas:
- Konfirmasi sebelum simpan:
totp_secretbaru ditulis ke database setelahverifyTotp()berhasil — pengguna terbukti berhasil memindai secret yang sama ke aplikasinya. - Gerbang berurutan: kata sandi diperiksa lebih dulu; hanya bila sah, kode TOTP diperiksa. Kasus 3 menunjukkan kode TOTP salah apa pun hasilnya tidak berpengaruh bila kata sandi sudah gagal.
- Jendela ±1 bekerja: Kasus 1 memakai kode dari langkah waktu sebelumnya (
intdiv($now, 30) - 1) dan tetap lolos — pengguna dengan jam ponsel melenceng sedikit tidak terkunci. hash_equals()di dalamverifyTotp()menjaga perbandingan kode tetap konstan waktunya, pola yang sudah kita kenal dari chapter API Token .
Satu catatan penyimpanan: berbeda dari kata sandi, secret TOTP tidak bisa disimpan sebagai hash lambat karena server harus membaca nilainya untuk menghitung kode setiap login. Lindungi kolom totp_secret dengan enkripsi at-rest bila tersedia, dan pastikan akses basis data dibatasi — pembahasan pengelolaan rahasia ada di chapter Secrets Management
.
Kode Cadangan untuk Pemulihan Akses #
Ponsel hilang, terganti, atau aplikasi autentikator terhapus — pengguna MFA pasti mengalaminya, dan tanpa jala penyelamat pengguna tersebut terkunci selamanya. Solusinya: saat pendaftaran MFA, server juga membuat sekitar 10 backup code sekali pakai yang ditampilkan sekali di layar untuk disimpan pengguna. Kode-kode itu diperlakukan persis seperti kata sandi — disimpan sebagai hash, dan langsung ditandai terpakai begitu dipakai:
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE backup_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
code_hash TEXT NOT NULL,
used_at TEXT
)");
function generateBackupCodes(PDO $pdo, int $userId): array
{
// Produksi: $codes = array_map(fn () => bin2hex(random_bytes(5)), range(1, 10));
// Demo memakai nilai tetap agar output contoh ini deterministik.
$codes = ["demo-satu-1111", "demo-dua-2222"];
$stmt = $pdo->prepare("INSERT INTO backup_codes (user_id, code_hash) VALUES (?, ?)");
foreach ($codes as $code) {
$stmt->execute([$userId, password_hash($code, PASSWORD_DEFAULT)]);
}
return $codes;
}
function verifyBackupCode(PDO $pdo, int $userId, string $code, string $now): bool
{
$stmt = $pdo->prepare(
"SELECT id, code_hash FROM backup_codes WHERE user_id = ? AND used_at IS NULL"
);
$stmt->execute([$userId]);
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
if (password_verify($code, $row["code_hash"])) {
$pdo->prepare("UPDATE backup_codes SET used_at = ? WHERE id = ?")
->execute([$now, $row["id"]]);
return true;
}
}
return false;
}
$codes = generateBackupCodes($pdo, 1);
echo count($codes) . " kode cadangan dibuat; nilai mentah tampil sekali saat dibuat\n";
var_dump(verifyBackupCode($pdo, 1, "demo-satu-1111", "2026-01-15 08:00:00"));
var_dump(verifyBackupCode($pdo, 1, "demo-satu-1111", "2026-01-15 08:00:05"));
var_dump(verifyBackupCode($pdo, 1, "kode-salah", "2026-01-15 08:00:10"));Output yang Dihasilkan:
2 kode cadangan dibuat; nilai mentah tampil sekali saat dibuat
bool(true)
bool(false)
bool(false)Alurnya sederhana namun ketat: generateBackupCodes() meng-hash tiap kode dengan password_hash() — pola dari chapter Password Hashing
— sebelum menyimpannya. Saat dipakai, verifyBackupCode() hanya memeriksa kode yang belum bertanda used_at; kode yang cocok langsung ditandai terpakai, sehingga percobaan kedua dengan kode yang sama gagal. Saat pengguna memakai satu kode cadangan untuk masuk, tawarkan regenerate seluruh set sekaligus — sisa kode lama dianggap tidak lagi rahasia.
SMS sebagai Faktor Kedua dan Kelemahannya #
Mengirim kode lewat SMS terasa paling mudah diadopsi — semua orang punya nomor ponsel — tetapi SMS menempatkan kepercayaan pada jaringan seluler yang tidak dirancang untuk autentikasi. Penyerang bisa memindahkan nomor korban ke SIM miliknya lewat SIM swap (menipu operator dengan data pribadi korban), mencegat pesan di celah jaringan sinyal telepon, atau memakai phishing real-time yang meminta korban membacakan kode yang baru masuk. NIST sejak 2016 menurunkan status SMS menjadi metode yang dibatasi penggunaannya, dan biaya per pesan membuatnya mahal dalam skala besar:
| Aspek | SMS OTP | TOTP | WebAuthn |
|---|---|---|---|
| Faktor possession berupa | Nomor ponsel | Secret di perangkat | Kunci privat di perangkat |
| Tahan phishing | Tidak | Tidak | Ya |
| Butuh jaringan saat login | Ya (seluler) | Tidak | Tidak |
| Biaya per verifikasi | Ya (per SMS) | Tidak | Tidak |
| Posisi yang layak | Fallback terakhir | Standar praktis saat ini | Standar yang paling kuat |
TOTP tidak butuh jaringan namun tetap rentan phishing: situs palsu bisa meminta pengguna mengetik kode 6 digit sebagaimana situs asli. Kode TOTP hanyalah “kata sandi sekali pakai” — bukti pengetahuan sesaat, bukan bukti bahwa pengguna benar-benar berada di situs yang benar.
WebAuthn: Faktor Penguat Tanpa Rahasia Bersama #
Kelemahan mendasar SMS dan TOTP adalah keduanya memindahkan nilai rahasia melalui jalur yang bisa disusupi. WebAuthn (standar di balik passkey) membalik modelnya: perangkat pengguna membuat pasangan kunci privat-publik khusus untuk satu situs, kunci privat tidak pernah keluar dari perangkat (sering dilindungi biometrik), dan yang dikirim ke server hanyalah tanda tangan atas challenge acak yang diterbitkan server untuk satu login. Tanda tangan hanya sah untuk challenge tersebut dan situs tersebut — situs phishing menerima challenge dari domainnya sendiri, sehingga tanda tangan korban tidak berlaku di sana. Inilah mengapa WebAuthn digolongkan tahan phishing.
Sisi server PHP bertugas menerbitkan challenge, menyimpan kunci publik per pengguna, dan memverifikasi tanda tangan pada respons — pekerjaan yang nyaman ditangani library khusus seperti web-auth/webauthn-lib daripada ditulis sendiri. Mekanisme pasangan kunci ini juga fondasi chapter API Token
dalam bentuk yang lain: di sana rahasia simetris acak, di sini kunci asimetris per perangkat.
Referensi #
Dokumentasi resmi untuk pendalaman lebih lanjut:
hash_hmac— php.net- RFC 6238: TOTP Algorithm — rfc-editor.org
- RFC 4226: HOTP Algorithm — rfc-editor.org
- OWASP Multifactor Authentication Cheat Sheet
Ringkasan #
MFA menuntut bukti dari kategori berbeda: knowledge, possession, dan inherence. TOTP menghitung kode 6 digit dari secret bersama dan langkah waktu floor(unix/30) — mesin intinya adalah hash_hmac() plus dynamic truncation, terbukti cocok dengan vektor uji resmi RFC — dan verifikasi menerima jendela ±1 langkah demi jam perangkat yang melenceng. Kode cadangan yang di-hash menjadi jala penyelamat; SMS sebaiknya tersisa sebagai fallback; WebAuthn menawarkan faktor kedua yang tahan phishing lewat kunci per perangkat. MFA memperkuat satu titik — pintu masuk. Bab berikutnya merangkai seluruh pengetahuan autentikasi ini menjadi satu checklist utuh: kebijakan kata sandi, pembatasan lajunya, pesan galatnya, hingga pencatatan peristiwanya.