Get your free and exclusive 80-page Banking Passkey Report
passkey tutorial how to implement passkeys

Hướng dẫn Passkey: Cách triển khai Passkey trong ứng dụng Web

Hướng dẫn này giải thích cách triển khai passkey trong ứng dụng web của bạn. Chúng tôi sử dụng Node.js (TypeScript), SimpleWebAuthn, Vanilla HTML / JavaScript và MySQL.

Vincent Delitz

Vincent

Created: June 20, 2025

Updated: June 20, 2025


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.

1. Giới thiệu: Cách triển khai Passkey#

Trong hướng dẫn này, chúng tôi sẽ hỗ trợ bạn trong nỗ lực triển khai passkey, cung cấp hướng dẫn từng bước về cách thêm passkey vào trang web của bạn.

Demo Icon

Want to try passkeys yourself in a passkeys demo?

Try Passkeys

Việc có một hệ thống xác thực hiện đại, mạnh mẽ và thân thiện với người dùng là yếu tố then chốt khi bạn muốn xây dựng một trang web hoặc ứng dụng tuyệt vời. Passkey đã nổi lên như một giải pháp cho thách thức này. Đóng vai trò là tiêu chuẩn mới cho việc đăng nhập, chúng hứa hẹn một tương lai không còn những nhược điểm của mật khẩu truyền thống, mang lại trải nghiệm đăng nhập thực sự không cần mật khẩu (không chỉ an toàn mà còn rất tiện lợi).

Điều thực sự thể hiện tiềm năng của passkey là sự ủng hộ mà chúng đã nhận được. Mọi trình duyệt lớn như Chrome, Firefox, Safari, hay Edge và tất cả các nhà sản xuất thiết bị quan trọng (Apple, Microsoft, Google) đều đã tích hợp hỗ trợ. Sự đón nhận đồng lòng này cho thấy passkey chính là tiêu chuẩn mới cho việc đăng nhập.

Đúng vậy, đã có những hướng dẫn về việc tích hợp passkey vào các ứng dụng web. Dù là cho các framework frontend như React, Vue.js hay Next.js, có rất nhiều hướng dẫn được thiết kế để giảm thiểu thách thức và tăng tốc quá trình triển khai passkey của bạn. Tuy nhiên, một hướng dẫn end-to-end mà vẫn tối giản và cơ bản thì lại thiếu. Nhiều nhà phát triển đã liên hệ với chúng tôi và yêu cầu một hướng dẫn làm rõ việc triển khai passkey cho các ứng dụng web.

Đây chính là lý do tại sao chúng tôi đã tạo ra hướng dẫn này. Mục tiêu của chúng tôi? Tạo ra một thiết lập tối thiểu khả thi cho passkey, bao gồm lớp frontend, backend và cơ sở dữ liệu (lớp sau cùng thường bị bỏ qua mặc dù nó có thể gây ra một số vấn đề đau đầu nghiêm trọng).

PasskeyAssessment Icon

Get a free passkey assessment in 15 minutes.

Book free consultation

Khi kết thúc hành trình này, bạn sẽ xây dựng được một ứng dụng web tối thiểu khả thi, nơi bạn có thể:

  • Tạo một passkey
  • Sử dụng passkey để đăng nhập

Đối với những người vội vàng hoặc muốn có tài liệu tham khảo, toàn bộ mã nguồn có sẵn trên GitHub.

Tò mò về kết quả cuối cùng trông như thế nào? Đây là một cái nhìn sơ lược về dự án cuối cùng (chúng tôi thừa nhận nó trông rất cơ bản nhưng những điều thú vị nằm ở bên dưới):

Chúng tôi hoàn toàn nhận thức được rằng các phần của mã nguồn và dự án có thể được thực hiện theo cách khác hoặc tinh vi hơn nhưng chúng tôi muốn tập trung vào những điều cốt lõi. Đó là lý do tại sao chúng tôi cố tình giữ mọi thứ đơn giản và tập trung vào passkey.

StateOfPasskeys Icon

Want to find out how many people use passkeys?

View Adoption Data

Làm thế nào để thêm passkey vào trang web sản phẩm của tôi?

Đây là một ví dụ rất tối thiểu về xác thực bằng passkey. Những điều sau đây KHÔNG được xem xét / triển khai trong hướng dẫn này hoặc chỉ ở mức rất cơ bản:

  • Giao diện người dùng có điều kiện / Dàn xếp có điều kiện / tự động điền passkey
  • Quản lý thiết bị
  • Quản lý phiên
  • Thêm nhiều thiết bị vào một tài khoản một cách an toàn
  • Khả năng tương thích ngược
  • Hỗ trợ đa nền tảng và đa thiết bị đúng cách
  • Xác thực dự phòng
  • Xử lý lỗi đúng cách
  • Trang quản lý passkey

Để có được sự hỗ trợ đầy đủ cho tất cả các tính năng này đòi hỏi nỗ lực phát triển lớn hơn rất nhiều. Đối với những người quan tâm, chúng tôi khuyên bạn nên xem bài viết về những quan niệm sai lầm của nhà phát triển passkey này.

Slack Icon

Become part of our Passkeys Community for updates & support.

Join

2. Điều kiện tiên quyết để tích hợp Passkey#

Trước khi đi sâu vào việc triển khai passkey, hãy cùng xem xét các kỹ năng và công cụ cần thiết. Đây là những gì bạn cần để bắt đầu:

2.1 Frontend: Vanilla HTML & JavaScript#

Kiến thức vững chắc về các thành phần xây dựng web như HTML, CSS và JavaScript là điều cần thiết. Chúng tôi đã cố tình giữ mọi thứ đơn giản, không sử dụng bất kỳ framework JavaScript hiện đại nào và dựa vào Vanilla JavaScript / HTML. Điều phức tạp hơn duy nhất chúng tôi sử dụng là thư viện bao bọc WebAuthn @simplewebauthn/browser.

2.2 Backend: Node.js (Express) trong TypeScript + SimpleWebAuthn#

Đối với backend của chúng tôi, chúng tôi sử dụng một máy chủ Node.js (Express) được viết bằng TypeScript. Chúng tôi cũng đã quyết định làm việc với triển khai máy chủ WebAuthn của SimpleWebAuthn (@simplewebauthn/server cùng với @simplewebauthn/typescript-types). Có rất nhiều triển khai máy chủ WebAuthn có sẵn, vì vậy bạn tất nhiên cũng có thể sử dụng bất kỳ cái nào trong số đó. Vì chúng tôi đã quyết định chọn máy chủ WebAuthn TypeScript, kiến thức cơ bản về Node.js và npm là bắt buộc.

2.3 Cơ sở dữ liệu: MySQL#

Tất cả dữ liệu người dùng và khóa công khai của passkey được lưu trữ trong cơ sở dữ liệu. Chúng tôi đã chọn MySQL làm công nghệ cơ sở dữ liệu. Hiểu biết cơ bản về MySQL và cơ sở dữ liệu quan hệ là một lợi thế, mặc dù chúng tôi sẽ hướng dẫn bạn qua từng bước một.

Trong phần tiếp theo, chúng tôi thường sử dụng các thuật ngữ WebAuthn và passkey thay thế cho nhau mặc dù chúng có thể không chính thức có cùng ý nghĩa. Tuy nhiên, để dễ hiểu hơn, đặc biệt là trong phần mã nguồn, chúng tôi đưa ra giả định này.

Với những điều kiện tiên quyết này, bạn đã sẵn sàng để bước vào thế giới của passkey.

Ben Gould Testimonial

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.

Hơn 10.000 nhà phát triển tin tưởng Corbado và làm cho Internet an toàn hơn với passkey. Có câu hỏi? Chúng tôi đã viết hơn 150 bài blog về passkey.

Tham gia Cộng đồng Passkey

3. Tổng quan kiến trúc: Ví dụ triển khai Passkey#

Trước khi đi vào mã nguồn và cấu hình, hãy cùng xem qua kiến trúc của hệ thống mà chúng ta muốn xây dựng. Dưới đây là phân tích về kiến trúc mà chúng ta sẽ thiết lập:

  • Frontend: Bao gồm hai nút, một cho đăng ký người dùng (tạo passkey) và nút còn lại để xác thực (đăng nhập bằng passkey).
  • Thiết bị & Trình duyệt: Khi một hành động được kích hoạt trên frontend, thiết bị và trình duyệt sẽ vào cuộc. Chúng tạo điều kiện cho việc tạo và xác minh passkey, hoạt động như những người trung gian giữa người dùng và backend.
  • Backend: Backend là nơi điều kỳ diệu thực sự diễn ra trong ứng dụng của chúng ta. Nó xử lý tất cả các yêu cầu được khởi tạo bởi frontend. Quá trình này bao gồm việc tạo và xác minh passkey. Cốt lõi của các hoạt động backend là máy chủ WebAuthn. Trái với những gì tên gọi có thể gợi ý, nó không phải là một máy chủ độc lập. Thay vào đó, nó là một thư viện hoặc gói triển khai tiêu chuẩn WebAuthn. Hai chức năng chính là: Đăng ký (Sign-up) nơi người dùng mới tạo passkey của họ và Xác thực (Login): Nơi người dùng hiện tại đăng nhập bằng passkey của họ. Ở dạng đơn giản nhất, máy chủ WebAuthn cung cấp bốn điểm cuối API công khai, được chia thành hai loại: hai cho đăng ký và hai cho xác thực. Chúng được thiết kế để nhận dữ liệu ở một định dạng cụ thể, sau đó được xử lý bởi máy chủ WebAuthn. Máy chủ WebAuthn chịu trách nhiệm cho tất cả các hoạt động mật mã cần thiết. Một khía cạnh thiết yếu cần lưu ý là các điểm cuối API này phải được phục vụ qua HTTPS.
  • Cơ sở dữ liệu MySQL: Đóng vai trò là xương sống lưu trữ của chúng tôi, cơ sở dữ liệu MySQL chịu trách nhiệm lưu giữ dữ liệu người dùng và các thông tin xác thực tương ứng của họ.
Analyzer Icon

Are your users passkey-ready?

Test Passkey-Readiness

Với tổng quan kiến trúc này, bạn sẽ có một bản đồ khái niệm về cách các thành phần của ứng dụng của chúng ta. Khi chúng ta tiếp tục, chúng ta sẽ đi sâu hơn vào từng thành phần này, chi tiết về thiết lập, cấu hình và sự tương tác của chúng.

Sơ đồ sau mô tả luồng quy trình trong quá trình đăng ký (sign-up):

Sơ đồ sau mô tả luồng quy trình trong quá trình xác thực (đăng nhập):

Ngoài ra, bạn có thể tìm thấy cấu trúc dự án ở đây (chỉ những tệp quan trọng nhất):

passkeys-tutorial ├── src # Chứa tất cả mã nguồn TypeScript của backend │ ├── controllers # Logic nghiệp vụ để xử lý các loại yêu cầu cụ thể │ │ ├── authentication.ts # Logic xác thực Passkey │ │ └── registration.ts # Logic đăng ký Passkey │ ├── middleware │ │ ├── customError.ts # Thêm thông báo lỗi tùy chỉnh theo cách chuẩn hóa │ │ └── errorHandler.ts # Trình xử lý lỗi chung │ ├── public │ │ ├── index.html # Tệp HTML chính ở frontend │ │ ├── css │ │ │ └── style.css # Định dạng cơ bản │ │ └── js │ │ └── script.js # Logic JavaScript (bao gồm API WebAuthn) │ ├── routes # Định nghĩa các route API và trình xử lý của chúng │ │ └── routes.ts # Các route passkey cụ thể │ ├── services │ │ ├── credentialService.ts# Tương tác với bảng credential │ │ └── userService.ts # Tương tác với bảng user │ ├── utils # Các hàm và tiện ích trợ giúp │ | ├── constants.ts # Một số hằng số (ví dụ: rpID) │ | └── utils.ts # Hàm trợ giúp │ ├── database.ts # Tạo kết nối từ Node.js đến cơ sở dữ liệu MySQL │ ├── index.ts # Điểm vào của máy chủ Node.js │ └── server.ts # Quản lý tất cả các cài đặt máy chủ ├── config.json # Một số cấu hình cho dự án Node.js ├── docker-compose.yml # Định nghĩa các dịch vụ, mạng và volume cho các container Docker ├── Dockerfile # Tạo một image Docker của dự án ├── init-db.sql # Định nghĩa lược đồ cơ sở dữ liệu MySQL của chúng tôi ├── package.json # Quản lý các phụ thuộc và script của dự án Node.js └── tsconfig.json # Cấu hình cách TypeScript biên dịch mã của bạn

4. Thiết lập cơ sở dữ liệu MySQL#

Khi triển khai passkey, việc thiết lập cơ sở dữ liệu là một thành phần quan trọng. Cách tiếp cận của chúng tôi sử dụng một container Docker chạy MySQL, cung cấp một môi trường đơn giản và cô lập, cần thiết cho việc kiểm thử và triển khai đáng tin cậy.

Lược đồ cơ sở dữ liệu của chúng tôi được cố tình giữ ở mức tối giản, chỉ có hai bảng. Sự đơn giản này giúp hiểu rõ hơn và bảo trì dễ dàng hơn.

Cấu trúc bảng chi tiết

1. Bảng Credentials: Là trung tâm của việc xác thực bằng passkey, bảng này lưu trữ thông tin xác thực passkey. Các cột quan trọng:

  • credential_id: Một định danh duy nhất cho mỗi thông tin xác thực. Việc chọn kiểu dữ liệu chính xác cho trường này là rất quan trọng để tránh lỗi định dạng.
  • public_key: Lưu trữ khóa công khai cho mỗi thông tin xác thực. Tương tự như credential_id, kiểu dữ liệu và định dạng phù hợp là rất quan trọng.

2. Bảng Users: Liên kết tài khoản người dùng với các thông tin xác thực tương ứng của họ.

Lưu ý rằng chúng tôi đã đặt tên bảng đầu tiên là credentials vì theo kinh nghiệm của chúng tôi và những gì các thư viện khác đề xuất, nó phù hợp hơn (trái với đề xuất của SimpleWebAuthn là đặt tên nó là authenticator hoặc authenticator_device).

Các kiểu dữ liệu cho credential_idpublic_key là rất quan trọng. Lỗi thường phát sinh từ các kiểu dữ liệu, mã hóa hoặc định dạng không chính xác (đặc biệt là sự khác biệt giữa Base64 và Base64URL là một nguyên nhân phổ biến gây ra lỗi), có thể làm gián đoạn toàn bộ quá trình đăng ký (sign-up) hoặc xác thực (login).

Tất cả các lệnh SQL cần thiết để thiết lập các bảng này đều nằm trong tệp init-db.sql. Script này đảm bảo việc khởi tạo cơ sở dữ liệu nhanh chóng và không có lỗi.

Đối với các trường hợp phức tạp hơn, bạn có thể thêm credential_device_type hoặc credential_backed_up để lưu trữ thêm thông tin về thông tin xác thực và cải thiện trải nghiệm người dùng. Tuy nhiên, chúng tôi sẽ không làm điều đó trong hướng dẫn này.

init-db.sql
CREATE 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) );

Sau khi chúng ta đã tạo tệp này, chúng ta tạo một tệp docker-compose.yml mới ở cấp gốc của dự án:

docker-compose.yml
version: "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

Tệp này khởi động cơ sở dữ liệu MySQL trên cổng 3306 và tạo cấu trúc cơ sở dữ liệu đã định nghĩa. Điều quan trọng cần lưu ý là tên và mật khẩu cho cơ sở dữ liệu được sử dụng ở đây được giữ đơn giản cho mục đích minh họa. Trong môi trường sản phẩm, bạn nên sử dụng thông tin xác thực phức tạp hơn để tăng cường bảo mật.

Tiếp theo, chúng ta chuyển sang chạy container Docker của mình. Tại thời điểm này, tệp docker-compose.yml của chúng ta chỉ bao gồm container duy nhất này, nhưng chúng ta sẽ thêm nhiều thành phần hơn sau. Để khởi động container, sử dụng lệnh sau:

docker compose up -d

Khi container đã hoạt động, chúng ta cần xác minh xem cơ sở dữ liệu có hoạt động như mong đợi không. Mở một terminal và thực thi lệnh sau để tương tác với cơ sở dữ liệu MySQL:

docker exec -it <container ID> mysql -uroot -p

Bạn sẽ được yêu cầu nhập mật khẩu root, trong ví dụ của chúng tôi là my-secret-pw. Sau khi đăng nhập, chọn cơ sở dữ liệu webauthn_db và hiển thị các bảng bằng các lệnh sau:

use webauthn_db; show tables;

Ở giai đoạn này, bạn sẽ thấy hai bảng được định nghĩa trong script của chúng tôi. Ban đầu, các bảng này sẽ trống, cho thấy rằng việc thiết lập cơ sở dữ liệu của chúng ta đã hoàn tất và sẵn sàng cho các bước tiếp theo trong việc triển khai passkey.

5. Triển khai Passkey: Các bước tích hợp Backend#

Backend là cốt lõi của bất kỳ ứng dụng passkey nào, hoạt động như một trung tâm xử lý các yêu cầu xác thực người dùng từ frontend. Nó giao tiếp với thư viện máy chủ WebAuthn để xử lý các yêu cầu đăng ký (sign-up) và xác thực (login), và nó tương tác với cơ sở dữ liệu MySQL của bạn để lưu trữ và truy xuất thông tin xác thực người dùng. Dưới đây, chúng tôi sẽ hướng dẫn bạn thiết lập backend bằng Node.js (Express) với TypeScript, sẽ cung cấp một API công khai để xử lý tất cả các yêu cầu.

5.1 Khởi tạo máy chủ Node.js (Express)#

Đầu tiên, tạo một thư mục mới cho dự án của bạn và điều hướng vào đó bằng terminal hoặc dấu nhắc lệnh của bạn.

Chạy lệnh

npx create-express-typescript-application passkeys-tutorial

Điều này tạo ra một bộ khung mã cơ bản của một ứng dụng Node.js (Express) được viết bằng TypeScript mà chúng ta có thể sử dụng để điều chỉnh thêm.

Dự án của bạn yêu cầu một số gói chính mà chúng ta cần cài đặt thêm:

  • @simplewebauthn/server: Một thư viện phía máy chủ để hỗ trợ các hoạt động WebAuthn, chẳng hạn như đăng ký người dùng (sign-up) và xác thực (login).
  • express-session: Middleware cho Express.js để quản lý các phiên, lưu trữ dữ liệu phiên phía máy chủ và xử lý cookie.
  • uuid: Một tiện ích để tạo ra các định danh duy nhất toàn cầu (UUID), thường được sử dụng để tạo các khóa hoặc định danh duy nhất trong các ứng dụng.
  • mysql2: Một client Node.js cho MySQL, cung cấp khả năng kết nối và thực thi các truy vấn đối với cơ sở dữ liệu MySQL.

Chuyển vào thư mục mới và cài đặt chúng bằng các lệnh sau (chúng tôi cũng cài đặt các loại TypeScript cần thiết):

cd passkeys-tutorial npm install @simplewebauthn/server mysql2 uuid express-session @types/express-session @types/uuid

Để xác nhận rằng mọi thứ đã được cài đặt chính xác, hãy chạy

npm run dev:nodemon

Điều này sẽ khởi động máy chủ Node.js của bạn ở chế độ phát triển với Nodemon, tự động khởi động lại máy chủ khi có bất kỳ thay đổi tệp nào.

Mẹo khắc phục sự cố: Nếu bạn gặp lỗi, hãy thử cập nhật ts-node lên phiên bản 10.8.1 trong tệp package.json và sau đó chạy npm i để cài đặt các bản cập nhật.

Tệp server.ts của bạn có thiết lập cơ bản và middleware cho một ứng dụng Express. Để tích hợp chức năng passkey, bạn sẽ cần thêm:

  • Routes: Định nghĩa các route mới cho việc đăng ký (sign-up) và xác thực (login) bằng passkey.
  • Controllers: Tạo các controller để xử lý logic cho các route này.
  • Middleware: Tích hợp middleware để xử lý yêu cầu và lỗi.
  • Services: Xây dựng các service để truy xuất và lưu trữ dữ liệu trong cơ sở dữ liệu.
  • Utility Functions: Bao gồm các hàm tiện ích để hoạt động mã hiệu quả.

Những cải tiến này là chìa khóa để kích hoạt xác thực bằng passkey trong backend của ứng dụng của bạn. Chúng ta sẽ thiết lập chúng sau.

Debugger Icon

Want to experiment with passkey flows? Try our Passkeys Debugger.

Try for Free

5.2 Kết nối cơ sở dữ liệu MySQL#

Sau khi chúng ta đã tạo và khởi động cơ sở dữ liệu trong phần 4, bây giờ chúng ta cần đảm bảo rằng backend của chúng ta có thể kết nối với cơ sở dữ liệu MySQL. Do đó, chúng ta tạo một tệp database.ts mới trong thư mục /src và thêm nội dung sau:

database.ts
import 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();

Tệp này sẽ được máy chủ của chúng ta sử dụng sau này để truy cập cơ sở dữ liệu.

5.3 Cấu hình máy chủ ứng dụng#

Hãy xem qua tệp config.json của chúng ta, nơi hai biến đã được định nghĩa: cổng mà chúng ta chạy ứng dụng và môi trường:

config.json
{ "PORT": 8080, "NODE_ENV": "development" }

package.json có thể giữ nguyên và sẽ trông như sau:

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 trông như sau:

index.ts
import 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); });

Trong server.ts, chúng ta cần điều chỉnh thêm một số thứ. Hơn nữa, cần có một bộ đệm tạm thời nào đó (ví dụ: redis, memcache hoặc express-session) để lưu trữ các challenge tạm thời mà người dùng có thể xác thực. Chúng tôi quyết định sử dụng express-session và khai báo module express-session ở trên cùng để mọi thứ hoạt động với express-session. Ngoài ra, chúng tôi sắp xếp hợp lý việc định tuyến và loại bỏ việc xử lý lỗi hiện tại (điều này sẽ được thêm vào middleware sau):

server.ts
import 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;

5.4 Service cho Credential & User#

Để quản lý hiệu quả dữ liệu trong hai bảng đã tạo của chúng ta, chúng ta sẽ phát triển hai service riêng biệt trong một thư mục src/services mới: authenticatorService.tsuserService.ts.

Mỗi service sẽ đóng gói các phương thức CRUD (Create, Read, Update, Delete), cho phép chúng ta tương tác với cơ sở dữ liệu một cách mô-đun và có tổ chức. Các service này sẽ tạo điều kiện thuận lợi cho việc lưu trữ, truy xuất và cập nhật dữ liệu trong các bảng authenticator và user. Dưới đây là cách bố trí cấu trúc của các tệp cần thiết này:

userService.ts trông như thế này:

userService.ts
import { 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 trông như sau:

credentialService.ts
import { 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; } }, };

5.5 Middleware#

Để xử lý lỗi một cách tập trung và cũng giúp việc gỡ lỗi dễ dàng hơn, chúng tôi thêm một tệp errorHandler.ts:

errorHandler.ts
import { 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 }); };

Bên cạnh đó, chúng tôi thêm một tệp customError.ts mới vì sau này chúng tôi muốn có thể tạo ra các lỗi tùy chỉnh để giúp chúng tôi tìm ra lỗi nhanh hơn:

customError.ts
export class CustomError extends Error { statusCode: number; constructor(message: string, statusCode: number = 500) { super(message); this.statusCode = statusCode; Object.setPrototypeOf(this, CustomError.prototype); } }

5.6 Tiện ích#

Trong thư mục utils, chúng tôi tạo hai tệp constants.tsutils.ts.

constant.ts chứa một số thông tin cơ bản của máy chủ WebAuthn, như tên relying party, ID relying party và origin:

constant.ts
export const rpName: string = "Passkeys Tutorial"; export const rpID: string = "localhost"; export const origin: string = `http://${rpID}:8080`;

utils.ts chứa hai hàm mà chúng ta sẽ cần sau này để mã hóa và giải mã dữ liệu:

utils.ts
export const uint8ArrayToBase64 = (uint8Array: Uint8Array): string => Buffer.from(uint8Array).toString("base64"); export const base64ToUint8Array = (base64: string): Uint8Array => new Uint8Array(Buffer.from(base64, "base64"));

5.7 Controller Passkey với SimpleWebAuthn#

Bây giờ, chúng ta đến với phần cốt lõi của backend: các controller. Chúng tôi tạo hai controller, một để tạo passkey mới (registration.ts) và một để đăng nhập bằng passkey (authentication.ts).

registration.ts trông như thế này:

registration.ts
import { 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; } };

Hãy xem xét các chức năng của các controller của chúng ta, xử lý hai điểm cuối chính trong quy trình đăng ký (sign-up) WebAuthn. Đây cũng là nơi có một trong những khác biệt lớn nhất so với xác thực dựa trên mật khẩu: Đối với mỗi lần đăng ký (sign-up) hoặc xác thực (login), cần có hai lệnh gọi API backend, yêu cầu nội dung frontend cụ thể ở giữa. Mật khẩu thường chỉ cần một điểm cuối.

1. Điểm cuối handleRegisterStart:

Điểm cuối này được kích hoạt bởi frontend, nhận một tên người dùng để tạo một passkey và tài khoản mới. Trong ví dụ này, chúng tôi chỉ cho phép tạo tài khoản / passkey mới nếu chưa có tài khoản nào tồn tại. Trong các ứng dụng thực tế, bạn sẽ cần xử lý điều này theo cách thông báo cho người dùng rằng một passkey đã tồn tại và việc thêm từ cùng một thiết bị là không thể (nhưng người dùng có thể thêm passkey từ một thiết bị khác sau một hình thức xác nhận nào đó). Để đơn giản, chúng tôi bỏ qua điều này trong hướng dẫn này.

Các tùy chọn PublicKeyCredentialCreationOptions được chuẩn bị. residentKey được đặt thành preferred, và attestationType thành direct, thu thập thêm dữ liệu từ authenticator để có thể lưu trữ trong cơ sở dữ liệu.

Nói chung, PublicKeyCredentialCreationOptions bao gồm các dữ liệu sau:

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: Đại diện cho thông tin relying party (trang web hoặc dịch vụ), thường bao gồm tên (rp.name) và tên miền (rp.id).
  • user: Chứa chi tiết tài khoản người dùng như user.name, user.id, và user.displayName.
  • challenge: Một giá trị ngẫu nhiên, an toàn được tạo bởi máy chủ WebAuthn để ngăn chặn các cuộc tấn công phát lại (replay attack) trong quá trình đăng ký.
  • pubKeyCredParams: Chỉ định loại thông tin xác thực khóa công khai sẽ được tạo, bao gồm thuật toán mật mã được sử dụng.
  • timeout: Tùy chọn, đặt thời gian tính bằng mili giây mà người dùng có để hoàn thành tương tác.
  • excludeCredentials: Một danh sách các thông tin xác thực cần loại trừ; được sử dụng để ngăn chặn việc đăng ký passkey cho cùng một thiết bị / authenticator nhiều lần.
  • authenticatorSelection: Tiêu chí để chọn authenticator, chẳng hạn như liệu nó có phải hỗ trợ xác minh người dùng hay cách khuyến khích các khóa thường trú (resident key).
  • attestation: Chỉ định tùy chọn truyền đạt chứng thực (attestation) mong muốn, như "none", "indirect", hoặc "direct".
  • extensions: Tùy chọn, cho phép các tiện ích mở rộng phía client bổ sung.

User ID và challenge được lưu trữ trong một đối tượng phiên, đơn giản hóa quy trình cho mục đích hướng dẫn. Hơn nữa, phiên được xóa sau mỗi lần đăng ký (sign-up) hoặc xác thực (login).

2. Điểm cuối handleRegisterFinish:

Điểm cuối này truy xuất user ID và challenge đã được đặt trước đó. Nó xác minh RegistrationResponse với challenge. Nếu hợp lệ, nó sẽ lưu trữ một thông tin xác thực mới cho người dùng. Sau khi được lưu trữ trong cơ sở dữ liệu, user ID và challenge sẽ bị xóa khỏi phiên.

Mẹo: Khi gỡ lỗi ứng dụng của bạn, chúng tôi thực sự khuyên bạn nên sử dụng Chrome làm trình duyệt và các tính năng tích hợp sẵn của nó để cải thiện trải nghiệm của nhà phát triển với các ứng dụng dựa trên passkey, ví dụ: authenticator WebAuthn ảo và nhật ký thiết bị (xem các mẹo của chúng tôi dành cho nhà phát triển bên dưới để biết thêm thông tin)

Tiếp theo, chúng ta chuyển sang authentication.ts, có cấu trúc và chức năng tương tự.

authentication.ts trông như thế này:

authentication.ts
import { 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 is purposely for this demo left empty. This causes all existing local credentials // to be displayed for the service instead only the ones the username has registered. 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; } };

Quy trình xác thực (đăng nhập) của chúng tôi bao gồm hai điểm cuối:

1. Điểm cuối handleLoginStart:

Điểm cuối này được kích hoạt khi người dùng cố gắng đăng nhập. Đầu tiên, nó kiểm tra xem tên người dùng có tồn tại trong cơ sở dữ liệu hay không, trả về lỗi nếu không tìm thấy. Trong một kịch bản thực tế, bạn có thể đề nghị tạo một tài khoản mới thay thế.

Đối với người dùng hiện tại, nó truy xuất ID người dùng từ cơ sở dữ liệu, lưu trữ nó trong phiên và tạo các tùy chọn PublicKeyCredentialRequestOptions. allowCredentials được để trống để tránh hạn chế việc sử dụng thông tin xác thực. Đó là lý do tại sao tất cả các passkey có sẵn cho relying party này có thể được chọn trong cửa sổ passkey.

Challenge được tạo ra cũng được lưu trữ trong phiên và PublicKeyCredentialRequestOptions được gửi trở lại frontend.

PublicKeyCredentialRequestOptions bao gồm các dữ liệu sau:

dictionary PublicKeyCredentialRequestOptions { required BufferSource challenge; unsigned long timeout; USVString rpId; sequence<PublicKeyCredentialDescriptor> allowCredentials = []; DOMString userVerification = "preferred"; AuthenticationExtensionsClientInputs extensions; };
  • challenge: Một giá trị ngẫu nhiên, an toàn từ máy chủ WebAuthn được sử dụng để ngăn chặn các cuộc tấn công phát lại trong quá trình xác thực.
  • timeout: Tùy chọn, đặt thời gian tính bằng mili giây mà người dùng có để phản hồi yêu cầu xác thực.
  • rpId: ID của relying party, thường là tên miền của dịch vụ.
  • allowCredentials: Một danh sách tùy chọn các mô tả thông tin xác thực, chỉ định thông tin xác thực nào có thể được sử dụng cho lần xác thực (đăng nhập) này.
  • userVerification: Chỉ định yêu cầu xác minh người dùng, như "required", "preferred", hoặc "discouraged".
  • extensions: Tùy chọn, cho phép các tiện ích mở rộng phía client bổ sung.

2. Điểm cuối handleLoginFinish:

Điểm cuối này truy xuất currentChallengeloggedInUserId từ phiên.

Nó truy vấn cơ sở dữ liệu để tìm thông tin xác thực phù hợp bằng cách sử dụng credential ID từ body. Nếu thông tin xác thực được tìm thấy, điều này có nghĩa là người dùng được liên kết với credential ID này bây giờ có thể được xác thực (đăng nhập). Sau đó, chúng ta có thể truy vấn người dùng từ bảng người dùng thông qua ID người dùng mà chúng ta nhận được từ thông tin xác thực và xác minh authenticationResponse bằng cách sử dụng challenge và body của yêu cầu. Nếu mọi thứ thành công, chúng ta hiển thị thông báo đăng nhập thành công. Nếu không tìm thấy thông tin xác thực phù hợp, một lỗi sẽ được gửi đi.

Ngoài ra, nếu xác minh thành công, bộ đếm của thông tin xác thực sẽ được cập nhật, challenge đã sử dụng và loggedInUserId sẽ bị xóa khỏi phiên.

Trên hết, chúng ta có thể xóa thư mục src/appsrc/constant cùng với tất cả các tệp trong đó.

Lưu ý: Quản lý phiên và bảo vệ route đúng cách, rất quan trọng trong các ứng dụng thực tế, được bỏ qua ở đây để đơn giản hóa trong hướng dẫn này.

5.8 Các Route Passkey#

Cuối cùng nhưng không kém phần quan trọng, chúng ta cần đảm bảo rằng các controller của chúng ta có thể truy cập được bằng cách thêm các route thích hợp vào routes.ts trong một thư mục mới src/routes:

routes.ts
import 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 };
Substack Icon

Subscribe to our Passkeys Substack for the latest news.

Subscribe

6. Tích hợp Passkey vào Frontend#

Phần này của hướng dẫn passkey tập trung vào cách hỗ trợ passkey trong frontend của ứng dụng của bạn. Chúng ta có một frontend rất cơ bản bao gồm ba tệp: index.html, styles.cssscript.js. Cả ba tệp đều nằm trong một thư mục src/public mới.

Tệp index.html chứa một trường nhập cho tên người dùng và hai nút để đăng ký và đăng nhập. Hơn nữa, chúng tôi nhập script @simplewebauthn/browser giúp đơn giản hóa việc tương tác với API Xác thực Web của trình duyệt trong tệp js/script.js.

index.html trông như thế này:

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 trông như sau:

script.js
document.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); } }

Trong script.js, có ba chức năng chính:

1. Hàm showMessage:

Đây là một hàm tiện ích được sử dụng chủ yếu để hiển thị thông báo lỗi, hỗ trợ việc gỡ lỗi.

2. Hàm Register:

Được kích hoạt khi người dùng nhấp vào "Register". Nó trích xuất tên người dùng từ trường nhập và gửi nó đến điểm cuối passkeyRegisterStart. Phản hồi bao gồm PublicKeyCredentialCreationOptions, được chuyển đổi thành JSON và chuyển cho SimpleWebAuthnBrowser.startRegistration. Lệnh gọi này kích hoạt authenticator của thiết bị (như Face ID hoặc Touch ID). Sau khi xác thực cục bộ thành công, challenge đã được ký sẽ được gửi trở lại điểm cuối passkeyRegisterFinish, hoàn tất quá trình tạo passkey.

Trong quá trình đăng ký (sign-up), đối tượng attestation đóng một vai trò quan trọng, vì vậy hãy xem xét kỹ hơn về nó.

Đối tượng attestation chủ yếu bao gồm ba thành phần: fmt, attStmt, và authData. Phần tử fmt biểu thị định dạng của câu lệnh attestation, trong khi attStmt đại diện cho chính câu lệnh attestation. Trong các trường hợp mà attestation được coi là không cần thiết, fmt sẽ được chỉ định là "none", dẫn đến một attStmt trống.

Trọng tâm là phân đoạn authData trong cấu trúc này. Phân đoạn này là chìa khóa để truy xuất các yếu tố cần thiết như ID relying party, các cờ, bộ đếm và dữ liệu thông tin xác thực đã được chứng thực trên máy chủ của chúng ta. Về các cờ, điều đặc biệt quan tâm là BS (Backup State) và BE (Backup Eligibility) cung cấp thêm thông tin nếu một passkey được đồng bộ hóa (ví dụ: qua iCloud Keychain hoặc 1Password). Bên cạnh đó, UV (User Verification) và UP (User Presence) cung cấp thêm thông tin hữu ích.

Điều quan trọng cần lưu ý là các phần khác nhau của đối tượng attestation, bao gồm dữ liệu authenticator, ID relying party, và câu lệnh attestation, đều được băm hoặc ký điện tử bởi authenticator bằng khóa riêng của nó. Quá trình này là không thể thiếu để duy trì tính toàn vẹn tổng thể của đối tượng attestation.

3. Hàm Login:

Được kích hoạt khi người dùng nhấp vào "Login". Tương tự như hàm đăng ký, nó trích xuất tên người dùng và gửi nó đến điểm cuối passkeyLoginStart. Phản hồi, chứa PublicKeyCredentialRequestOptions, được chuyển đổi thành JSON và được sử dụng với SimpleWebAuthnBrowser.startAuthentication. Điều này kích hoạt xác thực cục bộ trên thiết bị. Challenge đã được ký sau đó được gửi trở lại điểm cuối passkeyLoginFinish. Một phản hồi thành công từ điểm cuối này cho biết người dùng đã đăng nhập thành công vào ứng dụng.

Ngoài ra, tệp CSS đi kèm cung cấp kiểu dáng đơn giản cho ứng dụng:

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; }

7. Chạy ứng dụng ví dụ Passkey#

Để xem ứng dụng của bạn hoạt động, hãy biên dịch và chạy mã TypeScript của bạn với:

npm run dev

Máy chủ của bạn bây giờ sẽ hoạt động tại http://localhost:8080.

Những lưu ý cho môi trường Production:

Hãy nhớ rằng, những gì chúng ta đã đề cập là một phác thảo cơ bản. Khi triển khai một ứng dụng passkey trong môi trường sản phẩm, bạn cần đi sâu hơn vào:

  • Các biện pháp bảo mật: Triển khai các thực hành bảo mật mạnh mẽ để bảo vệ dữ liệu người dùng.
  • Xử lý lỗi: Đảm bảo ứng dụng của bạn xử lý và ghi lại lỗi một cách mượt mà.
  • Quản lý cơ sở dữ liệu: Tối ưu hóa các hoạt động cơ sở dữ liệu để có khả năng mở rộng và độ tin cậy.

8. Tích hợp DevOps cho Passkey#

Chúng ta đã thiết lập một container Docker cho cơ sở dữ liệu của mình. Tiếp theo, chúng ta sẽ mở rộng thiết lập Docker Compose để bao gồm cả máy chủ với cả backend và frontend. Tệp docker-compose.yml của bạn nên được cập nhật tương ứng.

Để container hóa ứng dụng của chúng ta, chúng ta tạo một Dockerfile mới để cài đặt các gói cần thiết và khởi động máy chủ phát triển:

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"]

Sau đó, chúng ta cũng mở rộng tệp docker-compose.yml để khởi động container này:

docker-compose.yml
version: "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

Nếu bây giờ bạn chạy docker compose up trong terminal của mình và truy cập http://localhost:8080, bạn sẽ thấy phiên bản hoạt động của ứng dụng web passkey của mình (ở đây đang chạy trên Windows 11 23H2 + Chrome 119):

9. Mẹo bổ sung về Passkey cho nhà phát triển#

Vì chúng tôi đã làm việc khá lâu với việc triển khai passkey, chúng tôi đã gặp phải một vài thách thức khi bạn làm việc trên các ứng dụng passkey thực tế:

  • Khả năng tương thích và hỗ trợ của thiết bị / nền tảng
  • Hướng dẫn và giáo dục người dùng
  • Xử lý các thiết bị bị mất hoặc thay đổi
  • Xác thực đa nền tảng
  • Cơ chế dự phòng
  • Độ phức tạp của mã hóa: Mã hóa thường là phần khó nhất vì bạn phải xử lý JSON, CBOR, uint8arrays, buffer, blob, các cơ sở dữ liệu khác nhau, base64 và base64url, nơi có thể xảy ra rất nhiều lỗi
  • Quản lý passkey (ví dụ: để thêm, xóa hoặc đổi tên passkey)

Hơn nữa, chúng tôi có những mẹo sau đây cho các nhà phát triển khi nói đến phần triển khai:

Sử dụng Passkeys Debugger

Passkeys debugger giúp kiểm tra các cài đặt máy chủ WebAuthn và phản hồi của client khác nhau. Hơn nữa, nó cung cấp một trình phân tích tuyệt vời cho các phản hồi của authenticator.

Gỡ lỗi với tính năng Device Log của Chrome

Sử dụng nhật ký thiết bị của Chrome (có thể truy cập qua chrome://device-log/) để theo dõi các lệnh gọi FIDO/WebAuthn. Tính năng này cung cấp nhật ký thời gian thực của quá trình xác thực (đăng nhập), cho phép bạn xem dữ liệu đang được trao đổi và khắc phục mọi sự cố phát sinh.

Một phím tắt rất hữu ích khác để xem tất cả các passkey của bạn trong Chrome là sử dụng chrome://settings/passkeys.

Sử dụng Chrome Virtual WebAuthn Authenticator

Để tránh sử dụng lời nhắc Touch ID, Face ID hoặc Windows Hello trong quá trình phát triển, Chrome đi kèm với một authenticator WebAuthn ảo rất tiện dụng mô phỏng một authenticator thực. Chúng tôi thực sự khuyên bạn nên sử dụng nó để tăng tốc mọi thứ. Tìm thêm chi tiết tại đây.

Kiểm tra trên các nền tảng và trình duyệt khác nhau

Đảm bảo khả năng tương thích và chức năng trên các trình duyệt và nền tảng khác nhau. WebAuthn hoạt động khác nhau trên các trình duyệt khác nhau, vì vậy việc kiểm tra kỹ lưỡng là chìa khóa.

Kiểm tra trên các thiết bị khác nhau

Ở đây, việc làm việc với các công cụ như ngrok là đặc biệt hữu ích, nơi bạn có thể làm cho ứng dụng cục bộ của mình có thể truy cập được trên các thiết bị (di động) khác.

Đặt User Verification thành Preferred

Khi xác định các thuộc tính cho userVerification trong PublicKeyCredentialRequestOptions, hãy chọn đặt chúng thành preferred vì đây là một sự cân bằng tốt giữa khả năng sử dụng và bảo mật. Điều này có nghĩa là các kiểm tra bảo mật được áp dụng trên các thiết bị phù hợp trong khi vẫn giữ được sự thân thiện với người dùng trên các thiết bị không có khả năng sinh trắc học.

10. Kết luận: Hướng dẫn Passkey#

Chúng tôi hy vọng hướng dẫn passkey này cung cấp một sự hiểu biết rõ ràng về cách triển khai passkey một cách hiệu quả. Trong suốt hướng dẫn, chúng tôi đã đi qua các bước cần thiết để tạo một ứng dụng passkey, tập trung vào các khái niệm cơ bản và triển khai thực tế. Mặc dù hướng dẫn này đóng vai trò là điểm khởi đầu, nhưng còn rất nhiều điều để khám phá và hoàn thiện trong thế giới WebAuthn.

Chúng tôi khuyến khích các nhà phát triển đi sâu hơn vào các sắc thái của passkey (ví dụ: thêm nhiều passkey, kiểm tra tính sẵn sàng của passkey trên các thiết bị hoặc cung cấp các giải pháp khôi phục). Đó là một hành trình đáng để bắt đầu, mang lại cả thách thức và phần thưởng to lớn trong việc tăng cường xác thực người dùng. Với passkey, bạn không chỉ xây dựng một tính năng; bạn đang góp phần vào một thế giới kỹ thuật số an toàn và thân thiện hơn với người dùng.

Add passkeys to your app in <1 hour with our UI components, SDKs & guides.

Start for free

Share this article


LinkedInTwitterFacebook

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