---
url: 'https://www.corbado.com/vi/blog/huong-dan-passkey-cach-trien-khai-passkey-trong-ung-dung-web'
title: 'Hướng dẫn Passkey: Cách triển khai Passkey trong ứng dụng Web'
description: '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.'
lang: 'vi'
author: 'Vincent Delitz'
date: '2025-06-20T11:24:22.558Z'
lastModified: '2026-08-22T06:06:12.932Z'
keywords: 'hướng dẫn passkey'
category: 'Passkeys Implementation'
---

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

## 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.

Việc có một hệ thống [xác thực](https://www.corbado.com/vi/blog/trien-khai-webauthn-server) 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](https://www.corbado.com/blog/react-passkeys), [Vue.js](https://www.corbado.com/blog/vuejs-passkeys)
hay [Next.js](https://www.corbado.com/blog/nextjs-passkeys), có rất
[nhi](https://www.corbado.com/blog/agentic-non-human-identity-eic-2026)ề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](https://www.corbado.com/blog/agentic-non-human-identity-eic-2026)ê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](https://www.corbado.com/blog/agentic-non-human-identity-eic-2026)ề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).

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](https://www.corbado.com/vi/blog/thuc-hanh-tot-nhat-tao-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](https://github.com/corbado/passkey-tutorial).

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):

![Màn hình đăng nhập Hướng dẫn Passkey](https://www.corbado.com/website-assets/6572cd2ed5d547903c8ad74d_passkey_tutorial_register_login_screen_f55a0f5ae9.png)

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.

**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](https://www.corbado.com/vi/blog/trien-khai-webauthn-server) 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](https://www.corbado.com/vi/blog/webauthn-conditional-ui-passkeys-autofill)
- 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](https://www.corbado.com/vi/blog/trien-khai-webauthn-server) 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.

## 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](https://simplewebauthn.dev/docs/packages/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](https://www.corbado.com/blog/nodejs-passkeys) (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](https://simplewebauthn.dev/docs/packages/server)
(`@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](https://www.corbado.com/blog/nodejs-passkeys) 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](https://www.corbado.com/blog/passkey-webauthn-database-guide) làm công nghệ cơ sở dữ
liệu. Hiểu biết cơ bản về [MySQL](https://www.corbado.com/blog/passkey-webauthn-database-guide) 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.

## 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](https://www.corbado.com/blog/passkey-webauthn-database-guide) 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ọ.

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ơ đồ quy trình đăng ký Passkey](https://www.corbado.com/website-assets/6572cd4d3243003bb3589e88_passkey_sign_up_process_chart_b73d643b4c.png)

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

![Sơ đồ quy trình đăng nhập Passkey](https://www.corbado.com/website-assets/6572cd5f04fd73a7ff5d501b_passkey_login_process_chart_a44262b767.png)

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](https://www.corbado.com/vi/blog/webauthn-relying-party-id-rpid-passkeys). 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](https://www.corbado.com/glossary/authenticator) hoặc
authenticator_device).

Các kiểu dữ liệu cho `credential_id` và `public_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.

```sql filename="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:

```yaml filename="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](https://www.corbado.com/vi/blog/cach-bat-passkey-tren-android).

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:

```bash
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:

```bash
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:

```sql
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](https://www.corbado.com/blog/nodejs-passkeys) (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

```bash
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](https://www.corbado.com/blog/nodejs-passkeys) để 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):

```bash
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

```bash
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.

![Khởi động ứng dụng Hướng dẫn Passkey](https://www.corbado.com/website-assets/6572cd86e1fa2982352c7ca1_passkey_tutorial_start_app_b442392b4d.png)

**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.

### 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](#4-thiet-lap-co-so-du-lieu-mysql), 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:

```ts filename="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:

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

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

```json filename="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:

```ts filename="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):

```ts filename="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.ts` và
`userService.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](https://www.corbado.com/glossary/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:

```ts filename="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:

```ts filename="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`:

```ts filename="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:

```ts filename="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.ts` và `utils.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](https://www.corbado.com/glossary/relying-party), ID [relying party](https://www.corbado.com/glossary/relying-party) và
origin:

```ts filename="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:

```ts filename="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](https://www.corbado.com/vi/blog/thuc-hanh-tot-nhat-tao-passkey) mới
(`registration.ts`) và một để đăng nhập bằng passkey (`authentication.ts`).

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

```ts filename="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](https://www.corbado.com/vi/blog/thuc-hanh-tot-nhat-tao-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](https://www.corbado.com/glossary/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](https://www.corbado.com/glossary/authenticator) để có thể lưu trữ trong
cơ sở dữ liệu.

Nói chung,
[PublicKeyCredentialCreationOptions](https://www.corbado.com/glossary/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](https://www.corbado.com/glossary/relying-party) (trang web hoặc
  dịch vụ), thường bao gồm tên (`rp.name`) và
  [tên miền](https://www.corbado.com/vi/blog/webauthn-relying-party-id-rpid-passkeys) (`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](https://www.corbado.com/vi/glossary/attestation)
  (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](https://www.corbado.com/blog/webauthn-user-id-userhandle) 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](https://www.corbado.com/blog/webauthn-user-id-userhandle) 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](https://www.corbado.com/blog/webauthn-user-id-userhandle) 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:

```ts filename="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](https://www.corbado.com/glossary/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](https://www.corbado.com/glossary/publickeycredentialrequestoptions) được gửi
trở lại frontend.

[PublicKeyCredentialRequestOptions](https://www.corbado.com/glossary/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](https://www.corbado.com/vi/blog/webauthn-relying-party-id-rpid-passkeys) 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 `currentChallenge` và `loggedInUserId` 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](https://www.corbado.com/blog/webauthn-user-id-userhandle) 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](https://www.corbado.com/blog/webauthn-user-id-userhandle) 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/app` và `src/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`:

```ts filename="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 };
```

## 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.css` và `script.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:

```html filename="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:

```javascript filename="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](https://www.corbado.com/faq/is-face-id-passkey) 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](https://www.corbado.com/vi/blog/thuc-hanh-tot-nhat-tao-passkey).

Trong quá trình đăng ký (sign-up), đối tượng [attestation](https://www.corbado.com/glossary/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 của Passkey](https://www.corbado.com/website-assets/6572cda3f3869adee2c38e05_passkey_attestation_object_5bfcce1977.png)

Đối tượng [attestation](https://www.corbado.com/glossary/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](https://www.corbado.com/glossary/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](https://www.corbado.com/vi/glossary/attestation) 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](https://www.corbado.com/glossary/icloud-keychain) hoặc
[1Password](https://www.corbado.com/blog/1password-passkeys-best-practices-analysis)). Bên cạnh đó, UV (User
Verification) và UP (User Presence) cung cấp thêm thông tin hữu ích.

![Dữ liệu Authenticator của Passkey](https://www.corbado.com/website-assets/6572cf3ce1fa2982352d6c88_passkey_authenticator_data_7f61aceaea.png)

Đ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:

```css
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:

```bash
npm run dev
```

Máy chủ của bạn bây giờ sẽ hoạt động tại [http://localhost:8080](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](https://www.corbado.com/vi/blog/cach-bat-passkey-tren-android) mạnh mẽ để
  [bảo vệ dữ liệu](https://www.corbado.com/vi/blog/xu-ly-tai-lieu-bao-mat) 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 filename="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:

```yaml filename="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](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](https://www.corbado.com/blog/passkeys-windows-11)
23H2 + Chrome 119):

![Hướng dẫn Passkey trên Windows Hello](https://www.corbado.com/website-assets/6572cf57f3869adee2c49ff5_passkey_tutorial_windows_hello_5ac0585957.png)

## 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](https://www.corbado.com/glossary/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**](https://www.passkeys-debugger.io/) 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/](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](chrome://settings/passkeys).

**Sử dụng Chrome Virtual WebAuthn Authenticator**

Để tránh sử dụng lời nhắc Touch ID, [Face ID](https://www.corbado.com/faq/is-face-id-passkey) hoặc
[Windows Hello](https://www.corbado.com/glossary/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](https://developer.chrome.com/docs/devtools/webauthn/).

**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](https://www.corbado.com/glossary/user-verification) 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](https://www.corbado.com/vi/blog/cach-bat-passkey-tren-android). Đ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](https://www.corbado.com/vi/blog/sinh-trac-hoc-nhan-thuc-nguoi-thanh-toan).

## 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.
