Tutorial ini menjelaskan cara mengimplementasikan passkey di aplikasi web Anda. Kami menggunakan Node.js (TypeScript), SimpleWebAuthn, Vanilla HTML / JavaScript, dan MySQL.
Vincent
Created: June 17, 2025
Updated: July 8, 2025
See the original blog version in English here.
We aim to make the Internet a safer place using passkeys. That's why we want to support developers with tutorials on how to implement passkeys.
Dalam tutorial ini, kami membantu Anda dalam upaya implementasi passkey, dengan menawarkan panduan langkah demi langkah tentang cara menambahkan passkey ke situs web Anda.
Memiliki autentikasi yang modern, kuat, dan ramah pengguna adalah kunci ketika Anda ingin membangun situs web atau aplikasi yang hebat. Passkey telah muncul sebagai jawaban atas tantangan ini. Berfungsi sebagai standar baru untuk login, passkey menjanjikan masa depan tanpa kerugian dari kata sandi tradisional, menyediakan pengalaman login yang benar-benar tanpa kata sandi (yang tidak hanya aman tetapi juga sangat nyaman).
Yang benar-benar mengekspresikan potensi passkey adalah dukungan yang telah mereka peroleh. Setiap peramban signifikan baik itu Chrome, Firefox, Safari, atau Edge dan semua produsen perangkat penting (Apple, Microsoft, Google) telah memasukkan dukungan. Penerimaan serentak ini menunjukkan bahwa passkey adalah standar baru untuk login.
Ya, sudah ada tutorial tentang mengintegrasikan passkey ke dalam aplikasi web. Baik itu untuk kerangka kerja frontend seperti React, Vue.js, atau Next.js, ada banyak panduan yang dirancang untuk mengurangi tantangan dan mempercepat implementasi passkey Anda. Namun, tutorial end-to-end yang tetap minimalis dan bare-metal masih kurang. Banyak pengembang telah mendekati kami dan meminta tutorial yang memberikan kejelasan tentang implementasi passkey untuk aplikasi web.
Inilah tepatnya mengapa kami membuat panduan ini. Tujuan kami? Untuk membuat pengaturan minimal yang layak untuk passkey, mencakup lapisan frontend, backend, dan database (yang terakhir sering diabaikan meskipun dapat menyebabkan beberapa sakit kepala serius).
Di akhir perjalanan ini, Anda akan telah membangun aplikasi web minimal yang layak, di mana Anda dapat:
Bagi mereka yang terburu-buru atau menginginkan referensi, seluruh basis kode tersedia di GitHub.
Penasaran bagaimana hasil akhirnya? Berikut adalah cuplikan dari proyek akhir (kami akui terlihat sangat dasar tetapi hal-hal menarik ada di bawah permukaan):
Kami sepenuhnya sadar bahwa bagian dari kode dan proyek dapat dilakukan secara berbeda atau lebih canggih tetapi kami ingin fokus pada hal-hal penting. Itulah mengapa kami sengaja menjaga hal-hal tetap sederhana dan berpusat pada passkey.
Bagaimana cara menambahkan passkey ke situs web produksi saya?
Ini adalah contoh yang sangat minimal untuk autentikasi passkey. Hal-hal berikut TIDAK dipertimbangkan / diimplementasikan dalam tutorial ini atau hanya sangat dasar:
Mendapatkan dukungan penuh untuk semua fitur ini membutuhkan upaya pengembangan yang jauh lebih besar. Bagi mereka yang tertarik, kami merekomendasikan untuk melihat artikel kesalahpahaman pengembang passkey ini.
Sebelum menyelam lebih dalam ke implementasi passkey, mari kita lihat keterampilan dan alat yang diperlukan. Inilah yang Anda butuhkan untuk memulai:
Pemahaman yang kuat tentang blok bangunan web HTML, CSS, dan JavaScript sangat penting. Kami sengaja menjaga hal-hal tetap sederhana, menahan diri dari kerangka kerja JavaScript modern apa pun dan mengandalkan Vanilla JavaScript / HTML. Satu-satunya hal yang lebih canggih yang kami gunakan adalah pustaka pembungkus WebAuthn @simplewebauthn/browser.
Untuk backend kami, kami menggunakan server Node.js (Express)
yang ditulis dalam TypeScript. Kami juga telah memutuskan untuk bekerja dengan
implementasi server WebAuthn dari SimpleWebAuthn (@simplewebauthn/server
bersama dengan
@simplewebauthn/typescript-types
). Ada banyak implementasi server WebAuthn yang
tersedia, jadi Anda tentu saja juga dapat menggunakan salah satunya. Karena kami telah
memutuskan untuk server WebAuthn TypeScript, pengetahuan dasar
Node.js dan npm diperlukan.
Semua data pengguna dan kunci publik dari passkey disimpan dalam database. Kami telah memilih MySQL sebagai teknologi database. Pemahaman dasar tentang MySQL dan database relasional bermanfaat, meskipun kami akan memandu Anda melalui langkah-langkah tunggal.
Berikut ini, kami sering menggunakan istilah WebAuthn dan passkey secara bergantian meskipun secara resmi mungkin tidak berarti sama. Untuk pemahaman yang lebih baik, terutama di bagian kode, kami membuat asumsi ini.
Dengan prasyarat ini, Anda siap untuk menyelami dunia passkey.
Ben Gould
Head of Engineering
I’ve built hundreds of integrations in my time, including quite a few with identity providers and I’ve never been so impressed with a developer experience as I have been with Corbado.
10,000+ devs trust Corbado & make the Internet safer with passkeys. Got questions? We’ve written 150+ blog posts on passkeys.
Join Passkeys CommunitySebelum masuk ke kode dan konfigurasi, mari kita lihat arsitektur sistem yang ingin kita bangun. Berikut adalah rincian arsitektur yang akan kita siapkan:
Dengan tinjauan arsitektur ini, Anda seharusnya memiliki peta konseptual tentang bagaimana komponen aplikasi kita. Seiring kita melanjutkan, kita akan menyelam lebih dalam ke setiap komponen ini, merinci pengaturan, konfigurasi, dan interaksinya.
Bagan berikut menjelaskan alur proses selama pendaftaran (sign-up):
Bagan berikut menjelaskan alur proses selama autentikasi (login):
Selain itu, Anda dapat menemukan struktur proyek di sini (hanya file yang paling penting):
passkeys-tutorial ├── src # Berisi semua kode sumber TypeScript backend │ ├── controllers # Logika bisnis untuk menangani jenis permintaan tertentu │ │ ├── authentication.ts # Logika autentikasi passkey │ │ └── registration.ts # Logika pendaftaran passkey │ ├── middleware │ │ ├── customError.ts # Tambahkan pesan kesalahan kustom secara terstandarisasi │ │ └── errorHandler.ts # Penangan kesalahan umum │ ├── public │ │ ├── index.html # File HTML utama di frontend │ │ ├── css │ │ │ └── style.css # Gaya dasar │ │ └── js │ │ └── script.js # Logika JavaScript (termasuk API WebAuthn) │ ├── routes # Definisi rute API dan penangannya │ │ └── routes.ts # Rute passkey spesifik │ ├── services │ │ ├── credentialService.ts# Berinteraksi dengan tabel kredensial │ │ └── userService.ts # Berinteraksi dengan tabel pengguna │ ├── utils # Fungsi pembantu dan utilitas │ | ├── constants.ts # Beberapa konstanta (misalnya rpID) │ | └── utils.ts # Fungsi pembantu │ ├── database.ts # Membuat koneksi dari Node.js ke database MySQL │ ├── index.ts # Titik masuk dari server Node.js │ └── server.ts # Mengelola semua pengaturan server ├── config.json # Beberapa konfigurasi untuk proyek Node.js ├── docker-compose.yml # Mendefinisikan layanan, jaringan, dan volume untuk kontainer Docker ├── Dockerfile # Membuat gambar Docker dari proyek ├── init-db.sql # Mendefinisikan skema database MySQL kami ├── package.json # Mengelola dependensi dan skrip proyek Node.js └── tsconfig.json # Mengonfigurasi bagaimana TypeScript mengkompilasi kode Anda
Saat mengimplementasikan passkey, pengaturan database adalah komponen kunci. Pendekatan kami menggunakan kontainer Docker yang menjalankan MySQL, menawarkan lingkungan yang sederhana dan terisolasi yang penting untuk pengujian dan penerapan yang andal.
Skema database kami sengaja dibuat minimalis, hanya menampilkan dua tabel. Kesederhanaan ini membantu dalam pemahaman yang lebih jelas dan pemeliharaan yang lebih mudah.
Struktur Tabel Rinci
1. Tabel Kredensial: Pusat dari autentikasi passkey, tabel ini menyimpan kredensial passkey. Kolom Kritis:
credential_id
, tipe data dan format yang sesuai sangat penting.2. Tabel Pengguna: Menghubungkan akun pengguna dengan kredensial mereka yang sesuai.
Perhatikan bahwa kami menamai tabel pertama credentials
karena ini sesuai dengan
pengalaman kami dan apa yang direkomendasikan oleh pustaka lain lebih cocok (berlawanan
dengan saran SimpleWebAuthn untuk menamainya authenticator
atau authenticator_device
).
Jenis data untuk credential_id
dan public_key
sangat penting. Kesalahan sering muncul
dari tipe data, pengkodean, atau format yang salah (terutama perbedaan antara Base64 dan
Base64URL adalah penyebab umum kesalahan), yang dapat mengganggu seluruh proses
pendaftaran (sign-up) atau autentikasi (login).
Semua perintah SQL yang diperlukan untuk menyiapkan tabel-tabel ini terdapat dalam file
init-db.sql
. Skrip ini memastikan inisialisasi database yang cepat dan bebas kesalahan.
Untuk kasus yang lebih canggih, Anda dapat menambahkan credential_device_type
atau
credential_backed_up
untuk menyimpan lebih banyak informasi tentang kredensial dan
meningkatkan pengalaman pengguna. Namun, kami tidak melakukannya dalam tutorial ini.
init-db.sqlCREATE TABLE users ( id VARCHAR(255) PRIMARY KEY, username VARCHAR(255) NOT NULL UNIQUE ); CREATE TABLE credentials ( id INT AUTO_INCREMENT PRIMARY KEY, user_id VARCHAR(255) NOT NULL, credential_id VARCHAR(255) NOT NULL, public_key TEXT NOT NULL, counter INT NOT NULL, transports VARCHAR(255), FOREIGN KEY (user_id) REFERENCES users (id) );
Setelah kita membuat file ini, kita membuat file docker-compose.yml
baru di tingkat root
proyek:
docker-compose.ymlversion: "3.1" services: db: image: mysql command: --default-authentication-plugin=mysql_native_password restart: always environment: MYSQL_ROOT_PASSWORD: my-secret-pw MYSQL_DATABASE: webauthn_db ports: - "3306:3306" volumes: - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql
File ini memulai database MySQL pada port 3306 dan membuat struktur database yang ditentukan. Penting untuk dicatat bahwa nama dan kata sandi untuk database yang digunakan di sini dibuat sederhana untuk tujuan demonstrasi. Di lingkungan produksi, Anda harus menggunakan kredensial yang lebih kompleks untuk keamanan yang ditingkatkan.
Selanjutnya, kita beralih ke menjalankan kontainer Docker kita. Pada titik ini, file
docker-compose.yml
kita hanya menyertakan kontainer tunggal ini, tetapi kita akan
menambahkan lebih banyak komponen nanti. Untuk memulai kontainer, gunakan perintah
berikut:
docker compose up -d
Setelah kontainer berjalan, kita perlu memverifikasi apakah database berfungsi seperti yang diharapkan. Buka terminal dan jalankan perintah berikut untuk berinteraksi dengan database MySQL:
docker exec -it <container ID> mysql -uroot -p
Anda akan diminta untuk memasukkan kata sandi root, yaitu my-secret-pw
dalam contoh
kami. Setelah login, pilih database webauthn_db
dan tampilkan tabel menggunakan perintah
ini:
use webauthn_db; show tables;
Pada tahap ini, Anda seharusnya melihat dua tabel yang didefinisikan dalam skrip kami. Awalnya, tabel-tabel ini akan kosong, menunjukkan bahwa pengaturan database kami selesai dan siap untuk langkah-langkah selanjutnya dalam mengimplementasikan passkey.
Backend adalah inti dari setiap aplikasi passkey, bertindak sebagai pusat untuk memproses permintaan autentikasi pengguna dari frontend. Ini berkomunikasi dengan pustaka server WebAuthn untuk menangani permintaan pendaftaran (sign-up) dan autentikasi (login), dan berinteraksi dengan database MySQL Anda untuk menyimpan dan mengambil kredensial pengguna. Di bawah ini, kami akan memandu Anda melalui pengaturan backend Anda menggunakan Node.js (Express) dengan TypeScript yang akan mengekspos API publik untuk menangani semua permintaan.
Pertama, buat direktori baru untuk proyek Anda dan navigasikan ke dalamnya menggunakan terminal atau command prompt Anda.
Jalankan perintah
npx create-express-typescript-application passkeys-tutorial
Ini membuat kerangka kode dasar dari aplikasi Node.js (Express) yang ditulis dalam TypeScript yang dapat kita gunakan untuk adaptasi lebih lanjut.
Proyek Anda memerlukan beberapa paket kunci yang perlu kita instal di atasnya:
Pindah ke direktori baru dan instal dengan perintah berikut (kami juga menginstal tipe TypeScript yang diperlukan):
cd passkeys-tutorial npm install @simplewebauthn/server mysql2 uuid express-session @types/express-session @types/uuid
Untuk mengonfirmasi bahwa semuanya terinstal dengan benar, jalankan
npm run dev:nodemon
Ini akan memulai server Node.js Anda dalam mode pengembangan dengan Nodemon, yang secara otomatis me-restart server setiap kali ada perubahan file.
Tips pemecahan masalah: Jika Anda mengalami kesalahan, coba perbarui ts-node
ke
versi 10.8.1 di file package.json
lalu jalankan npm i
untuk menginstal pembaruan.
File server.ts
Anda memiliki pengaturan dasar dan middleware untuk aplikasi
Express. Untuk mengintegrasikan fungsionalitas passkey, Anda
perlu menambahkan:
Peningkatan ini adalah kunci untuk mengaktifkan autentikasi passkey di backend aplikasi Anda. Kita akan mengaturnya nanti.
Setelah kita membuat dan memulai database di bagian 4,
sekarang kita perlu memastikan bahwa backend kita dapat terhubung ke database MySQL. Oleh
karena itu, kita membuat file database.ts
baru di folder /src
dan menambahkan konten
berikut:
database.tsimport mysql from "mysql2"; // Create a MySQL pool const pool = mysql.createPool({ host: process.env.DB_HOST, user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, waitForConnections: true, connectionLimit: 10, queueLimit: 0, }); // Promisify for Node.js async/await. export const promisePool = pool.promise();
File ini nantinya akan digunakan oleh server kita untuk mengakses database.
Mari kita lihat sekilas config.json
kita, di mana dua variabel sudah didefinisikan: port
tempat kita menjalankan aplikasi dan lingkungannya:
config.json{ "PORT": 8080, "NODE_ENV": "development" }
package.json
dapat dibiarkan apa adanya dan seharusnya terlihat seperti:
package.json{ "name": "passkeys-tutorial", "version": "0.0.1", "description": "passkeys-tutorial initialised with create-express-typescript-application.", "main": "src/index.ts", "scripts": { "build": "tsc", "start": "node ./build/src/index.js", "dev": "ts-node ./src/index.ts", "dev:nodemon": "nodemon -w src -e ts,json -x ts-node ./src/index.ts", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": ["express", "typescript"], "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/morgan": "^1.9.9", "@types/node": "^14.18.63", "@typescript-eslint/eslint-plugin": "^4.33.0", "@typescript-eslint/parser": "^4.33.0", "eslint": "^7.32.0", "nodemon": "^2.0.22", "ts-node": "^10.8.1", "typescript": "^4.9.5" }, "dependencies": { "@simplewebauthn/server": "^8.3.5", "@types/express-session": "^1.17.10", "@types/uuid": "^9.0.7", "cors": "^2.8.5", "env-cmd": "^10.1.0", "express": "^4.18.2", "express-session": "^1.17.3", "fs": "^0.0.1-security", "helmet": "^4.6.0", "morgan": "^1.10.0", "mysql2": "^3.6.5", "uuid": "^9.0.1" } }
index.ts
terlihat seperti:
index.tsimport app from "./server"; import config from "../config.json"; // Start the application by listening to specific port const port = Number(process.env.PORT || config.PORT || 8080); app.listen(port, () => { console.info("Express application started on port: " + port); });
Di server.ts
, kita perlu menyesuaikan beberapa hal lagi. Selain itu, cache sementara
dari beberapa jenis (misalnya redis, memcache atau
express-session) diperlukan untuk menyimpan tantangan sementara
yang dapat digunakan pengguna untuk autentikasi. Kami memutuskan untuk menggunakan
express-session
dan mendeklarasikan modul express-session
di atas agar berfungsi
dengan express-session
. Selain itu, kami merampingkan perutean dan menghapus penanganan
kesalahan untuk saat ini (ini akan ditambahkan ke middleware nanti):
server.tsimport express, { Express } from "express"; import morgan from "morgan"; import helmet from "helmet"; import cors from "cors"; import config from "../config.json"; import { router as passkeyRoutes } from "./routes/routes"; import session from "express-session"; const app: Express = express(); declare module "express-session" { interface SessionData { currentChallenge?: string; loggedInUserId?: string; } } /************************************************************************************ * Basic Express Middlewares ***********************************************************************************/ app.set("json spaces", 4); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use( session({ // @ts-ignore secret: process.env.SESSION_SECRET, saveUninitialized: true, resave: false, cookie: { maxAge: 86400000, httpOnly: true, // Ensure to not expose session cookies to clientside scripts }, }), ); // Handle logs in console during development if (process.env.NODE_ENV === "development" || config.NODE_ENV === "development") { app.use(morgan("dev")); app.use(cors()); } // Handle security and origin in production if (process.env.NODE_ENV === "production" || config.NODE_ENV === "production") { app.use(helmet()); } /************************************************************************************ * Register all routes ***********************************************************************************/ app.use("/api/passkey", passkeyRoutes); app.use(express.static("src/public")); export default app;
Untuk mengelola data secara efektif di dua tabel yang telah kita buat, kita akan
mengembangkan dua layanan berbeda di direktori src/services
baru:
authenticatorService.ts
dan userService.ts
.
Setiap layanan akan mengenkapsulasi metode CRUD (Create, Read, Update, Delete), memungkinkan kita untuk berinteraksi dengan database secara modular dan terorganisir. Layanan-layanan ini akan memfasilitasi penyimpanan, pengambilan, dan pembaruan data di tabel authenticator dan pengguna. Berikut adalah bagaimana struktur file-file yang diperlukan ini harus ditata:
userService.ts
terlihat seperti ini:
userService.tsimport { promisePool } from "../database"; // Adjust the import path as necessary import { v4 as uuidv4 } from "uuid"; export const userService = { async getUserById(userId: string) { const [rows] = await promisePool.query("SELECT * FROM users WHERE id = ?", [ userId, ]); // @ts-ignore return rows[0]; }, async getUserByUsername(username: string) { try { const [rows] = await promisePool.query( "SELECT * FROM users WHERE username = ?", [username], ); // @ts-ignore return rows[0]; } catch (error) { return null; } }, async createUser(username: string) { const id = uuidv4(); await promisePool.query("INSERT INTO users (id, username) VALUES (?, ?)", [ id, username, ]); return { id, username }; }, };
credentialService.ts
terlihat sebagai berikut:
credentialService.tsimport { promisePool } from "../database"; import type { AuthenticatorDevice } from "@simplewebauthn/typescript-types"; export const credentialService = { async saveNewCredential( userId: string, credentialId: string, publicKey: string, counter: number, transports: string, ) { try { await promisePool.query( "INSERT INTO credentials (user_id, credential_id, public_key, counter, transports) VALUES (?, ?, ?, ?, ?)", [userId, credentialId, publicKey, counter, transports], ); } catch (error) { console.error("Error saving new credential:", error); throw error; } }, async getCredentialByCredentialId( credentialId: string, ): Promise<AuthenticatorDevice | null> { try { const [rows] = await promisePool.query( "SELECT * FROM credentials WHERE credential_id = ? LIMIT 1", [credentialId], ); // @ts-ignore if (rows.length === 0) return null; // @ts-ignore const row = rows[0]; return { userID: row.user_id, credentialID: row.credential_id, credentialPublicKey: row.public_key, counter: row.counter, transports: row.transports ? row.transports.split(",") : [], } as AuthenticatorDevice; } catch (error) { console.error("Error retrieving credential:", error); throw error; } }, async updateCredentialCounter(credentialId: string, newCounter: number) { try { await promisePool.query( "UPDATE credentials SET counter = ? WHERE credential_id = ?", [newCounter, credentialId], ); } catch (error) { console.error("Error updating credential counter:", error); throw error; } }, };
Untuk menangani kesalahan secara terpusat dan juga mempermudah debugging, kami menambahkan
file errorHandler.ts
:
errorHandler.tsimport { Request, Response, NextFunction } from "express"; import { CustomError } from "./customError"; interface ErrorWithStatus extends Error { statusCode?: number; } export const handleError = ( err: CustomError, req: Request, res: Response, next: NextFunction, ) => { const statusCode = err.statusCode || 500; const message = err.message || "Internal Server Error"; console.log(message); res.status(statusCode).send({ error: message }); };
Selain itu, kami menambahkan file customError.ts
baru karena nanti kami ingin dapat
membuat kesalahan kustom untuk membantu kami menemukan bug lebih cepat:
customError.tsexport class CustomError extends Error { statusCode: number; constructor(message: string, statusCode: number = 500) { super(message); this.statusCode = statusCode; Object.setPrototypeOf(this, CustomError.prototype); } }
Di folder utils
, kami membuat dua file constants.ts
dan utils.ts
.
constant.ts
menyimpan beberapa informasi dasar server WebAuthn, seperti nama
relying party, ID relying party, dan
origin:
constant.tsexport const rpName: string = "Passkeys Tutorial"; export const rpID: string = "localhost"; export const origin: string = `http://${rpID}:8080`;
utils.ts
menyimpan dua fungsi yang nantinya kita perlukan untuk mengkodekan dan
mendekodekan data:
utils.tsexport const uint8ArrayToBase64 = (uint8Array: Uint8Array): string => Buffer.from(uint8Array).toString("base64"); export const base64ToUint8Array = (base64: string): Uint8Array => new Uint8Array(Buffer.from(base64, "base64"));
Sekarang, kita sampai pada inti dari backend kita: controller. Kami membuat dua
controller, satu untuk membuat passkey baru (registration.ts
) dan satu untuk login
dengan passkey (authentication.ts
).
registration.ts
terlihat seperti ini:
registration.tsimport { generateRegistrationOptions, verifyRegistrationResponse, } from "@simplewebauthn/server"; import { uint8ArrayToBase64 } from "../utils/utils"; import { rpName, rpID, origin } from "../utils/constants"; import { credentialService } from "../services/credentialService"; import { userService } from "../services/userService"; import { RegistrationResponseJSON } from "@simplewebauthn/typescript-types"; import { Request, Response, NextFunction } from "express"; import { CustomError } from "../middleware/customError"; export const handleRegisterStart = async ( req: Request, res: Response, next: NextFunction, ) => { const { username } = req.body; if (!username) { return next(new CustomError("Username empty", 400)); } try { let user = await userService.getUserByUsername(username); if (user) { return next(new CustomError("User already exists", 400)); } else { user = await userService.createUser(username); } const options = await generateRegistrationOptions({ rpName, rpID, userID: user.id, userName: user.username, timeout: 60000, attestationType: "direct", excludeCredentials: [], authenticatorSelection: { residentKey: "preferred", }, // Support for the two most common algorithms: ES256, and RS256 supportedAlgorithmIDs: [-7, -257], }); req.session.loggedInUserId = user.id; req.session.currentChallenge = options.challenge; res.send(options); } catch (error) { next( error instanceof CustomError ? error : new CustomError("Internal Server Error", 500), ); } }; export const handleRegisterFinish = async ( req: Request, res: Response, next: NextFunction, ) => { const { body } = req; const { currentChallenge, loggedInUserId } = req.session; if (!loggedInUserId) { return next(new CustomError("User ID is missing", 400)); } if (!currentChallenge) { return next(new CustomError("Current challenge is missing", 400)); } try { const verification = await verifyRegistrationResponse({ response: body as RegistrationResponseJSON, expectedChallenge: currentChallenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: true, }); if (verification.verified && verification.registrationInfo) { const { credentialPublicKey, credentialID, counter } = verification.registrationInfo; await credentialService.saveNewCredential( loggedInUserId, uint8ArrayToBase64(credentialID), uint8ArrayToBase64(credentialPublicKey), counter, body.response.transports, ); res.send({ verified: true }); } else { next(new CustomError("Verification failed", 400)); } } catch (error) { next( error instanceof CustomError ? error : new CustomError("Internal Server Error", 500), ); } finally { req.session.loggedInUserId = undefined; req.session.currentChallenge = undefined; } };
Mari kita tinjau fungsionalitas controller kita, yang menangani dua titik akhir kunci dalam proses pendaftaran (sign-up) WebAuthn. Di sinilah juga letak salah satu perbedaan terbesar dengan autentikasi berbasis kata sandi: Untuk setiap upaya pendaftaran (sign-up) atau autentikasi (login), diperlukan dua panggilan API backend, yang memerlukan konten frontend spesifik di antaranya. Kata sandi biasanya hanya memerlukan satu titik akhir.
1. Titik Akhir handleRegisterStart:
Titik akhir ini dipicu oleh frontend, menerima nama pengguna untuk membuat passkey dan akun baru. Dalam contoh ini, kami hanya mengizinkan pembuatan akun/passkey baru jika belum ada akun yang ada. Dalam aplikasi dunia nyata, Anda perlu menangani ini dengan cara memberi tahu pengguna bahwa passkey sudah ada dan menambahkan dari perangkat yang sama tidak mungkin (tetapi pengguna dapat menambahkan passkey dari perangkat yang berbeda setelah beberapa bentuk konfirmasi). Untuk kesederhanaan, kami mengabaikan ini dalam tutorial ini.
PublicKeyCredentialCreationOptions
disiapkan. residentKey
diatur ke preferred
, dan
attestationType
ke direct
, mengumpulkan lebih banyak data dari
authenticator untuk potensi penyimpanan database.
Secara umum, PublicKeyCredentialCreationOptions
terdiri dari data berikut:
dictionary PublicKeyCredentialCreationOptions { required PublicKeyCredentialRpEntity rp; required PublicKeyCredentialUserEntity user; required BufferSource challenge; required sequence<PublicKeyCredentialParameters> pubKeyCredParams; unsigned long timeout; sequence<PublicKeyCredentialDescriptor> excludeCredentials = []; AuthenticatorSelectionCriteria authenticatorSelection; DOMString attestation = "none"; AuthenticationExtensionsClientInputs extensions; };
rp.name
) dan domain (rp.id
).user.name
, user.id
, dan
user.displayName
.ID Pengguna dan challenge disimpan dalam objek sesi, menyederhanakan proses untuk tujuan tutorial. Selain itu, sesi dibersihkan setelah setiap upaya pendaftaran (sign-up) atau autentikasi (login).
2. Titik Akhir handleRegisterFinish:
Titik akhir ini mengambil ID pengguna dan challenge yang ditetapkan sebelumnya. Ini
memverifikasi RegistrationResponse
dengan challenge. Jika valid, ia menyimpan kredensial
baru untuk pengguna. Setelah disimpan di database, ID pengguna dan challenge dihapus dari
sesi.
Tips: Saat men-debug aplikasi Anda, kami sangat menyarankan untuk menggunakan Chrome sebagai peramban dan fitur bawaannya untuk meningkatkan pengalaman pengembang aplikasi berbasis passkey, misalnya, authenticator WebAuthn virtual dan log perangkat (lihat tips kami untuk pengembang di bawah untuk informasi lebih lanjut)
Selanjutnya, kita beralih ke authentication.ts
, yang memiliki struktur dan
fungsionalitas serupa.
authentication.ts
terlihat seperti ini:
authentication.tsimport { Request, Response, NextFunction } from "express"; import { generateAuthenticationOptions, verifyAuthenticationResponse, } from "@simplewebauthn/server"; import { uint8ArrayToBase64, base64ToUint8Array } from "../utils/utils"; import { rpID, origin } from "../utils/constants"; import { credentialService } from "../services/credentialService"; import { userService } from "../services/userService"; import { AuthenticatorDevice } from "@simplewebauthn/typescript-types"; import { isoBase64URL } from "@simplewebauthn/server/helpers"; import { VerifiedAuthenticationResponse, VerifyAuthenticationResponseOpts, } from "@simplewebauthn/server/esm"; import { CustomError } from "../middleware/customError"; export const handleLoginStart = async ( req: Request, res: Response, next: NextFunction, ) => { const { username } = req.body; try { const user = await userService.getUserByUsername(username); if (!user) { return next(new CustomError("User not found", 404)); } req.session.loggedInUserId = user.id; // allowCredentials sengaja untuk demo ini dibiarkan kosong. Ini menyebabkan semua kredensial lokal yang ada // ditampilkan untuk layanan alih-alih hanya yang telah didaftarkan oleh nama pengguna. const options = await generateAuthenticationOptions({ timeout: 60000, allowCredentials: [], userVerification: "required", rpID, }); req.session.currentChallenge = options.challenge; res.send(options); } catch (error) { next( error instanceof CustomError ? error : new CustomError("Internal Server Error", 500), ); } }; export const handleLoginFinish = async ( req: Request, res: Response, next: NextFunction, ) => { const { body } = req; const { currentChallenge, loggedInUserId } = req.session; if (!loggedInUserId) { return next(new CustomError("User ID is missing", 400)); } if (!currentChallenge) { return next(new CustomError("Current challenge is missing", 400)); } try { const credentialID = isoBase64URL.toBase64(body.rawId); const bodyCredIDBuffer = isoBase64URL.toBuffer(body.rawId); const dbCredential: AuthenticatorDevice | null = await credentialService.getCredentialByCredentialId(credentialID); if (!dbCredential) { return next(new CustomError("Credential not registered with this site", 404)); } // @ts-ignore const user = await userService.getUserById(dbCredential.userID); if (!user) { return next(new CustomError("User not found", 404)); } // @ts-ignore dbCredential.credentialID = base64ToUint8Array(dbCredential.credentialID); // @ts-ignore dbCredential.credentialPublicKey = base64ToUint8Array( dbCredential.credentialPublicKey, ); let verification: VerifiedAuthenticationResponse; const opts: VerifyAuthenticationResponseOpts = { response: body, expectedChallenge: currentChallenge, expectedOrigin: origin, expectedRPID: rpID, authenticator: dbCredential, }; verification = await verifyAuthenticationResponse(opts); const { verified, authenticationInfo } = verification; if (verified) { await credentialService.updateCredentialCounter( uint8ArrayToBase64(bodyCredIDBuffer), authenticationInfo.newCounter, ); res.send({ verified: true }); } else { next(new CustomError("Verification failed", 400)); } } catch (error) { next( error instanceof CustomError ? error : new CustomError("Internal Server Error", 500), ); } finally { req.session.currentChallenge = undefined; req.session.loggedInUserId = undefined; } };
Proses autentikasi (login) kami melibatkan dua titik akhir:
1. Titik Akhir handleLoginStart:
Titik akhir ini diaktifkan ketika pengguna mencoba untuk login. Ini pertama-tama memeriksa apakah nama pengguna ada di database, mengembalikan kesalahan jika tidak ditemukan. Dalam skenario dunia nyata, Anda mungkin menawarkan untuk membuat akun baru sebagai gantinya.
Untuk pengguna yang sudah ada, ini mengambil ID pengguna dari database, menyimpannya di
sesi, dan menghasilkan opsi PublicKeyCredentialRequestOptions
. allowCredentials
dibiarkan kosong untuk menghindari pembatasan penggunaan kredensial. Itulah mengapa semua
passkey yang tersedia untuk relying party ini dapat dipilih di modal passkey.
Tantangan yang dihasilkan juga disimpan di sesi dan PublicKeyCredentialRequestOptions
dikirim kembali ke frontend.
PublicKeyCredentialRequestOptions
terdiri dari data berikut:
dictionary PublicKeyCredentialRequestOptions { required BufferSource challenge; unsigned long timeout; USVString rpId; sequence<PublicKeyCredentialDescriptor> allowCredentials = []; DOMString userVerification = "preferred"; AuthenticationExtensionsClientInputs extensions; };
2. Titik Akhir handleLoginFinish:
Titik akhir ini mengambil currentChallenge
dan loggedInUserId
dari sesi.
Ini menanyakan database untuk kredensial yang tepat menggunakan ID kredensial dari body.
Jika kredensial ditemukan, ini berarti bahwa pengguna yang terkait dengan ID kredensial
ini sekarang dapat diautentikasi (login). Kemudian, kita dapat menanyakan pengguna dari
tabel pengguna melalui ID pengguna yang kita dapatkan dari kredensial dan memverifikasi
authenticationResponse
menggunakan challenge dan body permintaan. Jika semuanya
berhasil, kita menampilkan pesan sukses login. Jika tidak ada kredensial yang cocok
ditemukan, kesalahan dikirim.
Selain itu, jika verifikasi berhasil, penghitung kredensial diperbarui, challenge yang digunakan dan loggedInUserId dihapus dari sesi.
Di atas semua itu, kita dapat menghapus folder src/app
dan src/constant
bersama dengan
semua file di dalamnya.
Catatan: Manajemen sesi yang tepat dan perlindungan rute, yang krusial dalam aplikasi kehidupan nyata, dihilangkan di sini untuk kesederhanaan dalam tutorial ini.
Terakhir, kita perlu memastikan bahwa controller kita dapat dijangkau dengan menambahkan
rute yang sesuai ke routes.ts
yang berada di direktori baru src/routes
:
routes.tsimport express from "express"; import { handleError } from "../middleware/errorHandler"; import { handleRegisterStart, handleRegisterFinish } from "../controllers/registration"; import { handleLoginStart, handleLoginFinish } from "../controllers/authentication"; const router = express.Router(); router.post("/registerStart", handleRegisterStart); router.post("/registerFinish", handleRegisterFinish); router.post("/loginStart", handleLoginStart); router.post("/loginFinish", handleLoginFinish); router.use(handleError); export { router };
Bagian dari tutorial passkey ini berfokus pada cara mendukung passkey di frontend aplikasi
Anda. Kami memiliki frontend yang sangat dasar yang terdiri dari tiga file: index.html
,
styles.css
, dan script.js
. Ketiga file tersebut berada di folder src/public
baru.
File index.html
berisi bidang input untuk nama pengguna dan dua tombol untuk mendaftar
dan login. Selain itu, kami mengimpor skrip @simplewebauthn/browser
yang menyederhanakan
interaksi dengan API Web Authentication peramban di file js/script.js
.
index.html
terlihat seperti ini:
index.html<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Passkey Tutorial</title> <link rel="stylesheet" href="css/style.css" /> </head> <body> <div class="container"> <h1>Passkey Tutorial</h1> <div id="message"></div> <div class="input-group"> <input type="text" id="username" placeholder="Enter username" /> <button id="registerButton">Register</button> <button id="loginButton">Login</button> </div> </div> <script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.es5.umd.min.js"></script> <script src="js/script.js"></script> </body> </html>
script.js
terlihat sebagai berikut:
script.jsdocument.getElementById("registerButton").addEventListener("click", register); document.getElementById("loginButton").addEventListener("click", login); function showMessage(message, isError = false) { const messageElement = document.getElementById("message"); messageElement.textContent = message; messageElement.style.color = isError ? "red" : "green"; } async function register() { // Retrieve the username from the input field const username = document.getElementById("username").value; try { // Get registration options from your server. Here, we also receive the challenge. const response = await fetch("/api/passkey/registerStart", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: username }), }); console.log(response); // Check if the registration options are ok. if (!response.ok) { throw new Error( "User already exists or failed to get registration options from server", ); } // Convert the registration options to JSON. const options = await response.json(); console.log(options); // This triggers the browser to display the passkey / WebAuthn modal (e.g. Face ID, Touch ID, Windows Hello). // A new attestation is created. This also means a new public-private-key pair is created. const attestationResponse = await SimpleWebAuthnBrowser.startRegistration(options); // Send attestationResponse back to server for verification and storage. const verificationResponse = await fetch("/api/passkey/registerFinish", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(attestationResponse), }); if (verificationResponse.ok) { showMessage("Registration successful"); } else { showMessage("Registration failed", true); } } catch (error) { showMessage("Error: " + error.message, true); } } async function login() { // Retrieve the username from the input field const username = document.getElementById("username").value; try { // Get login options from your server. Here, we also receive the challenge. const response = await fetch("/api/passkey/loginStart", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: username }), }); // Check if the login options are ok. if (!response.ok) { throw new Error("Failed to get login options from server"); } // Convert the login options to JSON. const options = await response.json(); console.log(options); // This triggers the browser to display the passkey / WebAuthn modal (e.g. Face ID, Touch ID, Windows Hello). // A new assertionResponse is created. This also means that the challenge has been signed. const assertionResponse = await SimpleWebAuthnBrowser.startAuthentication(options); // Send assertionResponse back to server for verification. const verificationResponse = await fetch("/api/passkey/loginFinish", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(assertionResponse), }); if (verificationResponse.ok) { showMessage("Login successful"); } else { showMessage("Login failed", true); } } catch (error) { showMessage("Error: " + error.message, true); } }
Di script.js
, ada tiga fungsi utama:
1. Fungsi showMessage:
Ini adalah fungsi utilitas yang digunakan terutama untuk menampilkan pesan kesalahan, membantu dalam debugging.
2. Fungsi Register:
Dipicu ketika pengguna mengklik "Register". Ini mengekstrak nama pengguna dari bidang
input dan mengirimkannya ke titik akhir passkeyRegisterStart. Responsnya mencakup
PublicKeyCredentialCreationOptions
, yang dikonversi ke JSON dan diteruskan ke
SimpleWebAuthnBrowser.startRegistration
. Panggilan ini mengaktifkan authenticator
perangkat (seperti Face ID atau Touch ID). Setelah autentikasi lokal berhasil, challenge
yang ditandatangani dikirim kembali ke titik akhir passkeyRegisterFinish
, menyelesaikan
proses pembuatan passkey.
Selama proses pendaftaran (sign-up), objek atestasi memainkan peran penting, jadi mari kita lihat lebih dekat.
Objek atestasi terutama terdiri dari tiga komponen: fmt
,
attStmt
, dan authData
. Elemen fmt
menandakan format pernyataan atestasi, sementara
attStmt
mewakili pernyataan atestasi itu sendiri. Dalam skenario di mana atestasi
dianggap tidak perlu, fmt
akan ditetapkan sebagai "none," yang mengarah ke attStmt
yang kosong.
Fokusnya adalah pada segmen authData
dalam struktur ini. Segmen ini adalah kunci untuk
mengambil elemen penting seperti ID
pihak yang mengandalkan, flag, penghitung, dan
data kredensial yang dibuktikan di server kami. Mengenai flag, yang menarik adalah BS
(Backup State) dan BE (Backup Eligibility) yang memberikan lebih banyak informasi jika
passkey disinkronkan (misalnya melalui iCloud Keychain atau
1Password). Selain itu, UV (User
Verification) dan UP (User Presence) memberikan informasi yang lebih berguna.
Sangat penting untuk dicatat bahwa berbagai bagian dari objek atestasi, termasuk data authenticator, ID pihak yang mengandalkan, dan pernyataan atestasi, baik di-hash atau ditandatangani secara digital oleh authenticator menggunakan kunci pribadinya. Proses ini merupakan bagian integral untuk menjaga integritas keseluruhan objek atestasi.
3. Fungsi Login:
Diaktifkan ketika pengguna mengklik "Login". Mirip dengan fungsi register, ini mengekstrak
nama pengguna dan mengirimkannya ke titik akhir passkeyLoginStart
. Responsnya, yang
berisi PublicKeyCredentialRequestOptions
, dikonversi ke JSON dan digunakan dengan
SimpleWebAuthnBrowser.startAuthentication
. Ini memicu autentikasi lokal pada perangkat.
Challenge yang ditandatangani kemudian dikirim kembali ke titik akhir
passkeyLoginFinish
. Respons yang berhasil dari titik akhir ini menunjukkan bahwa
pengguna telah berhasil login ke aplikasi.
Selain itu, file CSS yang menyertainya menyediakan gaya sederhana untuk aplikasi:
body { font-family: "Helvetica Neue", Arial, sans-serif; text-align: center; padding: 40px; background-color: #f3f4f6; color: #333; } .container { max-width: 400px; margin: auto; background: white; padding: 20px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); border-radius: 8px; } h1 { color: #007bff; font-size: 24px; margin-bottom: 20px; } .input-group { margin-bottom: 20px; } input[type="text"] { padding: 10px; margin-bottom: 10px; border: 1px solid #ced4da; border-radius: 4px; width: calc(100% - 22px); } button { width: calc(50% - 20px); padding: 10px 0; margin: 5px; font-size: 16px; cursor: pointer; border: none; border-radius: 4px; background-color: #007bff; color: white; } button:hover { background-color: #0056b3; } #message { color: #dc3545; margin: 20px; }
Untuk melihat aplikasi Anda beraksi, kompilasi dan jalankan kode TypeScript Anda dengan:
npm run dev
Server Anda sekarang seharusnya sudah berjalan di http://localhost:8080.
Pertimbangan untuk Produksi:
Ingat, apa yang telah kita bahas adalah garis besar dasar. Saat menerapkan aplikasi passkey di lingkungan produksi, Anda perlu mendalami lebih lanjut tentang:
Kami telah menyiapkan kontainer Docker untuk database kami. Selanjutnya, kami akan
memperluas pengaturan Docker Compose kami untuk menyertakan server dengan backend dan
frontend. File docker-compose.yml
Anda harus diperbarui sesuai.
Untuk mengemas aplikasi kami dalam kontainer, kami membuat Dockerfile baru yang menginstal paket yang diperlukan dan memulai server pengembangan:
Docker# Use an official Node runtime as a parent image FROM node:20-alpine # Set the working directory in the container WORKDIR /usr/src/app # Copy package.json and package-lock.json COPY package*.json ./ # Install any needed packages RUN npm install # Bundle your app's source code inside the Docker image COPY . . # Make port 8080 available to the world outside this container EXPOSE 8080 # Define the command to run your app CMD ["npm", "run", "dev"]
Kemudian, kami juga memperluas file docker-compose.yml
untuk memulai kontainer ini:
docker-compose.ymlversion: "3.1" services: db: image: mysql command: --default-authentication-plugin=mysql_native_password restart: always environment: MYSQL_ROOT_PASSWORD: my-secret-pw MYSQL_DATABASE: webauthn_db ports: - "3306:3306" volumes: - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql app: build: . ports: - "8080:8080" environment: - DB_HOST=db - DB_USER=root - DB_PASSWORD=my-secret-pw - DB_NAME=webauthn_db - SESSION_SECRET=secret123 depends_on: - db
Jika Anda sekarang menjalankan docker compose up
di terminal Anda dan mengakses
http://localhost:8080, Anda akan melihat versi kerja dari
aplikasi web passkey Anda (di sini berjalan
di Windows 11 23H2 + Chrome 119):
Karena kami telah bekerja cukup lama dengan implementasi passkey, kami menemukan beberapa tantangan jika Anda bekerja pada aplikasi passkey di dunia nyata:
Selain itu, kami memiliki tips berikut untuk pengembang ketika datang ke bagian implementasi:
Manfaatkan Passkeys Debugger
Passkeys debugger membantu menguji berbagai pengaturan server WebAuthn dan respons klien. Selain itu, ini menyediakan parser yang hebat untuk respons authenticator.
Debug dengan Fitur Log Perangkat Chrome
Gunakan log perangkat Chrome (dapat diakses melalui chrome://device-log/) untuk memantau panggilan FIDO/WebAuthn. Fitur ini menyediakan log real-time dari proses autentikasi (login), memungkinkan Anda untuk melihat data yang dipertukarkan dan memecahkan masalah apa pun yang muncul.
Jalan pintas lain yang sangat berguna untuk mendapatkan semua passkey Anda di Chrome adalah dengan menggunakan chrome://settings/passkeys.
Gunakan Authenticator WebAuthn Virtual Chrome
Untuk menghindari penggunaan prompt Touch ID, Face ID, atau Windows Hello selama pengembangan, Chrome dilengkapi dengan authenticator WebAuthn virtual yang sangat praktis yang meniru authenticator nyata. Kami sangat merekomendasikan untuk menggunakannya untuk mempercepat pekerjaan. Temukan detail lebih lanjut di sini.
Uji di Berbagai Platform dan Peramban
Pastikan kompatibilitas dan fungsionalitas di berbagai peramban dan platform. WebAuthn berperilaku berbeda di peramban yang berbeda, jadi pengujian menyeluruh adalah kuncinya.
Uji di Perangkat yang Berbeda
Di sini sangat berguna untuk bekerja dengan alat seperti ngrok, di mana Anda dapat membuat aplikasi lokal Anda dapat dijangkau di perangkat (seluler) lain.
Atur Verifikasi Pengguna ke preferred
Saat mendefinisikan properti untuk userVerification
di
PublicKeyCredentialRequestOptions
, pilih untuk mengaturnya ke preferred
karena ini
adalah trade-off yang baik antara kegunaan dan
keamanan. Ini berarti bahwa pemeriksaan
keamanan ada di perangkat yang sesuai sementara keramahan pengguna tetap terjaga di
perangkat tanpa kemampuan biometrik.
Kami harap tutorial passkey ini memberikan pemahaman yang jelas tentang cara mengimplementasikan passkey secara efektif. Sepanjang tutorial, kami telah memandu Anda melalui langkah-langkah penting untuk membuat aplikasi passkey, dengan fokus pada konsep dasar dan implementasi praktis. Meskipun panduan ini berfungsi sebagai titik awal, masih banyak lagi yang bisa dijelajahi dan disempurnakan di dunia WebAuthn.
Kami mendorong para pengembang untuk mendalami nuansa passkey (misalnya menambahkan beberapa passkey, memeriksa kesiapan passkey pada perangkat, atau menawarkan solusi pemulihan). Ini adalah perjalanan yang layak untuk dimulai, menawarkan tantangan dan imbalan besar dalam meningkatkan autentikasi pengguna. Dengan passkey, Anda tidak hanya membangun sebuah fitur; Anda berkontribusi pada dunia digital yang lebih aman dan ramah pengguna.
Enjoyed this read?
🤝 Join our Passkeys Community
Share passkeys implementation tips and get support to free the world from passwords.
🚀 Subscribe to Substack
Get the latest news, strategies, and insights about passkeys sent straight to your inbox.
Related Articles
Table of Contents