WebAuthn: Fondasi Autentikasi Modern yang Aman dan Tanpa Kata Sandi
1. Pendahuluan
Selamat datang kembali di blog saya! Kali ini, kita akan menyelami topik yang sangat relevan dan krusial di dunia web development modern: autentikasi. Sejak dulu, kata sandi (password) telah menjadi tulang punggung keamanan digital kita. Namun, mari kita jujur, kata sandi itu rewel. Mudah lupa, sering diulang, dan sangat rentan terhadap berbagai serangan siber seperti phishing, brute-force, atau kebocoran data.
Sebagai developer, kita selalu mencari cara untuk membuat aplikasi kita lebih aman dan pengalaman pengguna lebih mulus. Bayangkan sebuah dunia di mana Anda tidak perlu lagi mengingat kata sandi yang rumit, dan login Anda hampir kebal terhadap serangan phishing yang licik. Kedengarannya seperti mimpi? Nah, mimpi itu kini menjadi kenyataan berkat WebAuthn (Web Authentication API).
WebAuthn adalah standar web yang memungkinkan autentikasi berbasis kunci kriptografi publik yang kuat, menggantikan ketergantungan kita pada kata sandi. Ini bukan sekadar otentikasi dua faktor (2FA) tambahan, melainkan sebuah perubahan paradigma menuju pengalaman login tanpa kata sandi yang jauh lebih aman dan nyaman. Di artikel ini, kita akan mengupas tuntas WebAuthn, cara kerjanya, dan bagaimana Anda bisa mulai mengimplementasikannya di aplikasi web Anda.
Mari kita mulai perjalanan menuju masa depan autentikasi yang lebih aman! 🚀
2. Apa Itu WebAuthn?
📌 WebAuthn (Web Authentication API) adalah standar web yang diterbitkan oleh W3C dan FIDO Alliance. Ini adalah bagian inti dari inisiatif FIDO2, yang bertujuan untuk menciptakan pengalaman autentikasi yang lebih aman, lebih mudah, dan tanpa kata sandi di seluruh web.
Secara sederhana, WebAuthn memungkinkan server (yang disebut Relying Party) untuk mengintegrasikan autentikasi berbasis kriptografi kunci publik dengan perangkat autentikator (seperti sidik jari, pengenalan wajah, PIN, atau kunci keamanan fisik seperti YubiKey) yang terhubung ke perangkat pengguna.
Mengapa ini penting?
- Anti-Phishing: Karena autentikasi WebAuthn terikat pada asal (origin) situs web dan menggunakan kriptografi kunci publik, serangan phishing tradisional di mana pengguna memasukkan kredensial mereka ke situs palsu menjadi tidak efektif. Authenticator hanya akan merespons permintaan dari situs yang benar.
- Tanpa Kata Sandi: Pengguna tidak perlu lagi mengingat atau mengetikkan kata sandi. Mereka cukup menggunakan biometrik, PIN, atau sentuhan pada kunci keamanan.
- Keamanan Kuat: Menggunakan kriptografi kunci publik yang terbukti kuat, jauh lebih aman daripada kata sandi yang dapat ditebak, dibobol, atau dicuri.
- Pengalaman Pengguna Lebih Baik: Proses login menjadi lebih cepat dan intuitif, mengurangi frustrasi pengguna akibat lupa kata sandi.
💡 WebAuthn bukan sekadar fitur tambahan, melainkan pondasi autentikasi masa depan yang dibangun di atas standar terbuka dan didukung oleh semua browser modern (Chrome, Firefox, Edge, Safari) serta berbagai sistem operasi (Windows Hello, macOS Touch ID, Android Fingerprint/Face Unlock).
3. Bagaimana WebAuthn Bekerja? Analogi & Alur Umum
Untuk memahami cara kerja WebAuthn, mari kita gunakan analogi sederhana. Bayangkan Anda memiliki sebuah kotak deposit bank yang sangat aman.
❌ Autentikasi Kata Sandi Tradisional: Anda memiliki kunci (kata sandi) yang sama untuk membuka kotak deposit Anda di semua bank. Jika seseorang mencuri kunci Anda, mereka bisa membuka kotak Anda di mana saja. Anda juga harus memberitahu kunci Anda ke bank (meskipun di-hash, tetap ada risiko).
✅ Autentikasi WebAuthn:
-
Pendaftaran (Registrasi):
- Anda pergi ke Bank A dan meminta kotak deposit.
- Bank A memberi Anda sebuah “kunci publik” yang unik untuk Bank A saja, lalu Bank A menyimpan kunci publik itu sebagai identitas Anda.
- Di sisi Anda, perangkat keamanan Anda (sidik jari, kunci fisik) secara internal membuat “kunci privat” yang berpasangan dengan kunci publik tadi. Kunci privat ini tidak pernah meninggalkan perangkat keamanan Anda dan tidak pernah dibagikan ke Bank A. Ini seperti Bank A memberi Anda sebuah gembok unik, dan Anda menyimpan satu-satunya kunci untuk gembok itu di saku Anda.
- Setiap bank (situs web) akan memiliki gembok dan kunci publik yang berbeda untuk Anda.
-
Login (Autentikasi):
- Ketika Anda ingin mengakses kotak deposit di Bank A lagi, Bank A akan meminta Anda untuk “membuktikan” bahwa Anda adalah pemilik kunci privat yang sesuai dengan gembok Bank A.
- Bank A akan memberikan sebuah “tantangan” (challenge) acak kepada perangkat keamanan Anda.
- Perangkat keamanan Anda menggunakan kunci privatnya (yang hanya Anda miliki) untuk menandatangani tantangan tersebut.
- Tanda tangan digital ini dikirim kembali ke Bank A.
- Bank A kemudian menggunakan kunci publik Anda (yang disimpannya) untuk memverifikasi tanda tangan tersebut. Jika tanda tangan valid, berarti Anda adalah pemilik kunci privat yang sah, dan Bank A akan memberi Anda akses.
Intinya, Anda tidak pernah membagikan kunci rahasia Anda. Anda hanya membuktikan bahwa Anda memilikinya dengan menandatangani sebuah “tantangan” yang diberikan oleh situs web.
Alur Kerja Umum WebAuthn:
-
Pendaftaran (Registration) - Membuat Kredensial:
- Pengguna ingin mendaftar di
situs-saya.com. situs-saya.com(Relying Party) menghasilkan “challenge” kriptografi dan mengirimkannya ke browser pengguna.- Browser pengguna meminta pengguna untuk mengaktifkan Authenticator (misalnya, sentuh sensor sidik jari, masukkan PIN).
- Authenticator menghasilkan sepasang kunci kriptografi baru (kunci publik dan privat) yang unik untuk
situs-saya.comdan pengguna ini. - Authenticator menyimpan kunci privat secara aman di dalam dirinya dan mengembalikan kunci publik bersama dengan beberapa metadata (misalnya, ID kredensial) ke browser.
- Browser mengirimkan kunci publik dan metadata ke
situs-saya.com. situs-saya.commenyimpan kunci publik dan ID kredensial yang diterima, mengaitkannya dengan akun pengguna.
- Pengguna ingin mendaftar di
-
Autentikasi (Authentication) - Login:
- Pengguna ingin login ke
situs-saya.com. situs-saya.commenghasilkan “challenge” kriptografi dan, secara opsional, daftar ID kredensial yang pernah didaftarkan pengguna, lalu mengirimkannya ke browser.- Browser meminta pengguna untuk mengaktifkan Authenticator (misalnya, sentuh sensor sidik jari).
- Authenticator menggunakan kunci privat yang sesuai (berdasarkan ID kredensial dan asal situs) untuk menandatangani “challenge” tersebut.
- Tanda tangan digital (assertion) dikirim kembali ke browser.
- Browser mengirimkan tanda tangan tersebut ke
situs-saya.com. situs-saya.commemverifikasi tanda tangan menggunakan kunci publik yang tersimpan. Jika valid, pengguna berhasil login.
- Pengguna ingin login ke
⚠️ Perlu diingat, kunci privat tidak pernah meninggalkan Authenticator. Ini adalah salah satu pilar utama keamanan WebAuthn.
4. Komponen Kunci WebAuthn
Untuk memahami WebAuthn lebih dalam, mari kita kenali beberapa istilah kunci:
-
Relying Party (RP): Ini adalah server atau aplikasi web Anda yang ingin mengautentikasi pengguna. (
situs-saya.comdalam contoh di atas). RP bertanggung jawab untuk menghasilkan challenge, menyimpan kunci publik pengguna, dan memverifikasi assertion. -
User Agent: Ini adalah browser web pengguna (Chrome, Firefox, Safari, Edge) yang bertindak sebagai perantara antara RP dan Authenticator. User Agent mengekspos WebAuthn API (
navigator.credentials.create()dannavigator.credentials.get()). -
Authenticator: Ini adalah perangkat keras atau lunak yang bertanggung jawab untuk membuat dan menyimpan kunci privat, serta menghasilkan tanda tangan digital (assertion). Contohnya:
- Platform Authenticator: Terintegrasi langsung ke perangkat pengguna (misalnya, sensor sidik jari di laptop, Face ID di smartphone, Windows Hello, Touch ID).
- Roaming Authenticator: Perangkat eksternal yang dapat dihubungkan ke berbagai perangkat (misalnya, kunci keamanan USB seperti YubiKey, Google Titan).
-
Credential: Ini adalah pasangan kunci (kunci publik dan privat) yang dibuat oleh Authenticator untuk sebuah RP dan pengguna tertentu. Kunci publik disimpan di RP, sedangkan kunci privat disimpan secara aman di Authenticator. Setiap kredensial memiliki ID unik.
-
Attestation: Ini adalah proses opsional selama pendaftaran yang memungkinkan RP memverifikasi keaslian Authenticator dan karakteristiknya (misalnya, apakah itu Authenticator yang terpercaya, apakah mendukung biometrik). Ini membantu RP membuat keputusan keamanan yang lebih baik.
-
Assertion: Ini adalah tanda tangan digital yang dibuat oleh Authenticator menggunakan kunci privatnya sebagai respons terhadap challenge dari RP. Ini digunakan untuk membuktikan kepemilikan kunci privat selama proses autentikasi.
5. Implementasi WebAuthn: Langkah Demi Langkah
Implementasi WebAuthn melibatkan dua sisi: Frontend (JavaScript di browser) dan Backend (verifikasi di server).
5.1. Persiapan Awal
Sebelum memulai, pastikan Anda memahami beberapa hal:
- WebAuthn hanya berfungsi di lingkungan HTTPS (atau localhost).
- Anda memerlukan pustaka di sisi backend untuk menangani serialisasi/deserialisasi dan verifikasi kriptografi. Pustaka seperti
@simplewebauthn/server(Node.js),web-authn/webauthn-lib(PHP),py_webauthn(Python), ataugo-webauthn(Go) sangat direkomendasikan. Jangan mencoba mengimplementasikan kriptografi sendiri!
5.2. Alur Pendaftaran (Registration)
🎯 Tujuan: Membuat kredensial WebAuthn baru untuk pengguna.
Sisi Backend (Contoh: Node.js dengan @simplewebauthn/server)
- Buat Challenge: Server Anda menghasilkan challenge kriptografi yang kuat dan acak.
- Buat Opsi Pendaftaran: Server membuat objek
PublicKeyCredentialCreationOptionsyang berisi challenge, informasi Relying Party (nama, ID), informasi pengguna (ID, nama), dan preferensi Authenticator. - Kirim Opsi ke Frontend: Server mengirimkan objek ini ke browser pengguna.
// backend/routes/auth.js (contoh sederhana)
import { generateRegistrationOptions } from '@simplewebauthn/server';
app.post('/register/start', async (req, res) => {
const { userId, userName } = req.body; // Ambil dari request setelah pengguna login/daftar awal
const rpID = 'localhost'; // Ganti dengan domain Anda
const origin = `http://${rpID}:3000`; // Ganti dengan origin Anda
const options = await generateRegistrationOptions({
rpID,
rpName: 'My Awesome App',
userID: userId,
userName: userName,
attestationType: 'none', // Atau 'direct'/'indirect' untuk verifikasi attestation
excludeCredentials: [], // Opsional: daftar kredensial yang sudah ada
authenticatorSelection: {
authenticatorAttachment: 'platform', // 'platform' (internal) atau 'cross-platform' (external)
userVerification: 'preferred', // 'required', 'preferred', 'discouraged'
residentKey: 'required', // Membuat kredensial yang bisa ditemukan tanpa username
},
timeout: 60000,
});
// Simpan challenge di sesi pengguna atau cache untuk verifikasi nanti
req.session.challenge = options.challenge;
res.json(options);
});
Sisi Frontend (JavaScript)
- Dapatkan Opsi: Browser menerima opsi pendaftaran dari server.
- Panggil
navigator.credentials.create(): Ini akan memicu OS atau Authenticator untuk meminta pengguna melakukan verifikasi (misalnya, sentuh sidik jari). - Kirim Hasil ke Backend: Hasil dari
create()adalah objekPublicKeyCredentialyang berisi kunci publik dan metadata. Kirim ini kembali ke server.
// frontend/src/Register.js (contoh sederhana)
async function startWebAuthnRegistration(userId, userName) {
try {
// 1. Dapatkan opsi dari backend
const resp = await fetch('/register/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, userName }),
});
const options = await resp.json();
// Penting: Ubah format base64url menjadi ArrayBuffer
options.challenge = Uint8Array.from(atob(options.challenge), c => c.charCodeAt(0));
options.user.id = Uint8Array.from(atob(options.user.id), c => c.charCodeAt(0));
options.rp.id = options.rpID; // Sesuaikan dengan properti yang diharapkan oleh API
if (options.excludeCredentials) {
for (let cred of options.excludeCredentials) {
cred.id = Uint8Array.from(atob(cred.id), c => c.charCodeAt(0));
}
}
// ... lakukan konversi serupa untuk properti lain yang memerlukan ArrayBuffer
// 2. Panggil WebAuthn API
const credential = await navigator.credentials.create({
publicKey: options
});
// 3. Kirim hasil kembali ke backend untuk verifikasi
const registrationResponse = {
id: credential.id,
rawId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))),
response: {
clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(credential.response.clientDataJSON))),
attestationObject: btoa(String.fromCharCode(...new Uint8Array(credential.response.attestationObject))),
},
type: credential.type,
clientExtensionResults: credential.clientExtensionResults,
authenticatorAttachment: credential.authenticatorAttachment,
};
const verifyResp = await fetch('/register/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(registrationResponse),
});
const result = await verifyResp.json();
if (result.verified) {
alert('Pendaftaran WebAuthn Berhasil!');
// Redirect atau update UI
} else {
alert('Pendaftaran WebAuthn Gagal.');
}
} catch (error) {
console.error('Error selama pendaftaran WebAuthn:', error);
alert('Pendaftaran WebAuthn dibatalkan atau gagal: ' + error.message);
}
}
Sisi Backend (Verifikasi Pendaftaran)
- Terima Hasil: Server menerima objek
PublicKeyCredentialdari frontend. - Verifikasi Kredensial: Server menggunakan pustaka WebAuthn untuk memverifikasi
attestationObjectdanclientDataJSONterhadap challenge yang disimpan sebelumnya. - Simpan Kunci Publik: Jika verifikasi berhasil, server mengekstrak kunci publik dari kredensial dan menyimpannya di database, terkait dengan pengguna.
// backend/routes/auth.js (lanjutan)
import { verifyRegistrationResponse } from '@simplewebauthn/server';
app.post('/register/finish', async (req, res) => {
const body = req.body;
const expectedChallenge = req.session.challenge; // Ambil challenge yang disimpan sebelumnya
const rpID = 'localhost';
const origin = `http://${rpID}:3000`;
let verification;
try {
verification = await verifyRegistrationResponse({
response: body,
expectedChallenge: `${expectedChallenge}`, // Pastikan format string
expectedOrigin: origin,
expectedRPID: rpID,
requireUserVerification: true, // Jika Anda ingin selalu memerlukan verifikasi pengguna
});
} catch (error) {
console.error('Verifikasi pendaftaran WebAuthn gagal:', error);
return res.status(400).json({ verified: false, error: error.message });
}
const { verified, registrationInfo } = verification;
if (verified && registrationInfo) {
const { credentialPublicKey, credentialID, counter } = registrationInfo;
// Simpan credentialID, credentialPublicKey, dan counter di database
// Kaitkan dengan userId yang sedang mendaftar
// Contoh:
// await db.saveUserCredential({
// userId: req.session.userId, // Atau dari body jika sudah login
// credentialID: btoa(String.fromCharCode(...credentialID)),
// credentialPublicKey: btoa(String.fromCharCode(...credentialPublicKey)),
// counter,
// });
console.log('Pendaftaran WebAuthn berhasil untuk user:', req.session.userId);
return res.json({ verified: true });
}
res.json({ verified: false });
});
💡 Tips: Selalu simpan counter yang diberikan oleh Authenticator dan perbarui setiap kali autentikasi berhasil. Ini adalah salah satu mekanisme perlindungan replay attack.
5.3. Alur Autentikasi (Login)
🎯 Tujuan: Memverifikasi identitas pengguna menggunakan kredensial WebAuthn yang sudah ada.
Sisi Backend (Memulai Autentikasi)
- Buat Challenge: Server menghasilkan challenge baru.
- Buat Opsi Autentikasi: Server membuat objek
PublicKeyCredentialRequestOptionsyang berisi challenge, ID Relying Party, dan daftar ID kredensial yang sebelumnya didaftarkan pengguna (jika diketahui). - Kirim Opsi ke Frontend: Server mengirimkan objek ini ke browser.
// backend/routes/auth.js (lanjutan)
import { generateAuthenticationOptions } from '@simplewebauthn/server';
app.post('/login/start', async (req, res) => {
const { userName } = req.body; // Opsional, bisa juga tanpa username jika residentKey diaktifkan
const rpID = 'localhost';
const origin = `http://${rpID}:3000`;
// Ambil kredensial yang terdaftar untuk user ini dari database
// const userCredentials = await db.getUserCredentials(userName);
// const allowCredentials = userCredentials.map(cred => ({
// id: cred.credentialID,
// type: 'public-key',
// }));
const options = await generateAuthenticationOptions({
rpID,
allowCredentials: [], // Isi dengan kredensial yang terdaftar untuk pengguna ini
userVerification: 'preferred',
timeout: 60000,
});
// Simpan challenge di sesi pengguna atau cache untuk verifikasi nanti
req.session.challenge = options.challenge;
res.json(options);
});
Sisi Frontend (JavaScript)
- Dapatkan Opsi: Browser menerima opsi autentikasi dari server.
- Panggil
navigator.credentials.get(): Ini akan memicu OS atau Authenticator untuk meminta pengguna melakukan verifikasi. - Kirim Hasil ke Backend: Hasil
get()adalah objekPublicKeyCredentialyang berisi assertion (tanda tangan digital). Kirim ini kembali ke server.
// frontend/src/Login.js (contoh sederhana)
async function startWebAuthnLogin(userName) {
try {
// 1. Dapatkan opsi dari backend
const resp = await fetch('/login/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userName }),
});
const options = await resp.json();
// Penting: Ubah format base64url menjadi ArrayBuffer
options.challenge = Uint8Array.from(atob(options.challenge), c => c.charCodeAt(0));
if (options.allowCredentials) {
for (let cred of options.allowCredentials) {
cred.id = Uint8Array.from(atob(cred.id), c => c.charCodeAt(0));
}
}
// 2. Panggil WebAuthn API
const credential = await navigator.credentials.get({
publicKey: options
});
// 3. Kirim hasil kembali ke backend untuk verifikasi
const authenticationResponse = {
id: credential.id,
rawId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))),
response: {
clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(credential.response.clientDataJSON))),
authenticatorData: btoa(String.fromCharCode(...new Uint8Array(credential.response.authenticatorData))),
signature: btoa(String.fromCharCode(...new Uint8Array(credential.response.signature))),
userHandle: credential.response.userHandle ? btoa(String.fromCharCode(...new Uint8Array(credential.response.userHandle))) : null,
},
type: credential.type,
clientExtensionResults: credential.clientExtensionResults,
authenticatorAttachment: credential.authenticatorAttachment,
};
const verifyResp = await fetch('/login/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(authenticationResponse),
});
const result = await verifyResp.json();
if (result.verified) {
alert('Login WebAuthn Berhasil!');
// Redirect atau update UI
} else {
alert('Login WebAuthn Gagal.');
}
} catch (error) {
console.error('Error selama autentikasi WebAuthn:', error);
alert('Autentikasi WebAuthn dibatalkan atau gagal: ' + error.message);
}
}
Sisi Backend (Verifikasi Autentikasi)
- Terima Hasil: Server menerima objek
PublicKeyCredentialdari frontend. - Verifikasi Assertion: Server menggunakan pustaka WebAuthn untuk memverifikasi
signature,authenticatorData, danclientDataJSONterhadap challenge yang disimpan sebelumnya dan kunci publik yang terkait dengan ID kredensial yang digunakan. - Perbarui Counter: Jika verifikasi berhasil, server memperbarui
counteryang disimpan untuk kredensial tersebut di database.
// backend/routes/auth.js (lanjutan)
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
app.post('/login/finish', async (req, res) => {
const body = req.body;
const expectedChallenge = req.session.challenge; // Ambil challenge yang disimpan sebelumnya
const rpID = 'localhost';
const origin = `http://${rpID}:3000`;
// Ambil kredensial dari database berdasarkan body.id (credentialID)
// const storedCredential = await db.getCredentialById(body.id);
// if (!storedCredential) { /* handle error */ }
// const { credentialPublicKey, counter } = storedCredential;
let verification;
try {
verification = await verifyAuthenticationResponse({
response: body,
expectedChallenge: `${expectedChallenge}`,
expectedOrigin: origin,
expectedRPID: rpID,
// credentialPublicKey: Uint8Array.from(atob(credentialPublicKey), c => c.charCodeAt(0)),
// authenticator: {
// credentialID: Uint8Array.from(atob(body.id), c => c.charCodeAt(0)),
// credentialPublicKey: Uint8Array.from(atob(credentialPublicKey), c => c.charCodeAt(0)),
// counter,
// },
requireUserVerification: true,
});
} catch (error) {
console.error('Verifikasi autentikasi WebAuthn gagal:', error);
return res.status(400).json({ verified: false, error: error.message });
}
const { verified, authenticationInfo } = verification;
if (verified && authenticationInfo) {
const { newCounter } = authenticationInfo;
// Perbarui counter di database untuk kredensial ini
// await db.updateCredentialCounter(body.id, newCounter);
console.log('Autentikasi WebAuthn berhasil!');
return res.json({ verified: true });
}
res