---
url: 'https://www.corbado.com/zh/blog/passkey-jiaocheng-shixian-web-app'
title: '密钥教程：如何在 Web 应用中实现密钥'
description: '本教程将讲解如何在您的 Web 应用中实现密钥。我们将使用 Node.js (TypeScript)、SimpleWebAuthn、原生 HTML / JavaScript 和 MySQL。'
lang: 'zh'
author: 'Vincent Delitz'
date: '2025-06-17T16:16:08.516Z'
lastModified: '2026-08-29T06:03:33.778Z'
keywords: '密钥教程, passkey 教程'
category: 'Passkeys Implementation'
---

# 密钥教程：如何在 Web 应用中实现密钥

## 1. 简介：如何实现密钥

在本教程中，我们将帮助您实现密钥，提供一份关于如何向您的网站添加密钥的分步指南。

当您想构建一个出色的网站或应用时，拥有一个现代化、强大且用户友好的认证方式是关键。密钥（Passkey）已成为应对这一挑战的答案。作为登录的新标准，它们承诺一个没有传统密码缺点的未来，提供真正的无密码登录体验（这不仅安全，而且非常方便）。

密钥的潜力真正体现在它们所获得的认可上。所有主流浏览器，无论是
[Chrome](https://www.corbado.com/zh/blog/client-hints-user-agent-chrome-safari-firefox)、Firefox、Safari 还是 Edge，以及所有重要的设备制造商（[Apple](https://www.corbado.com/zh/blog/how-to-delete-passkey-apple-windows-android)、Microsoft、Google）都已加入了支持。这种一致的接纳表明，密钥是登录的新标准。

是的，已经有关于将密钥集成到 Web 应用程序中的教程。无论是针对像
[React](https://www.corbado.com/blog/react-passkeys)、[Vue.js](https://www.corbado.com/blog/vuejs-passkeys) 或
[Next.js](https://www.corbado.com/blog/nextjs-passkeys)
这样的前端框架，都有大量的指南旨在减轻挑战并加速您的密钥实现。然而，目前还缺少一个保持**极简和底层**的**端到端教程**。许多开发者向我们咨询，希望有一个能清晰阐述**Web 应用中密钥实现**的教程。

这正是我们撰写本指南的原因。我们的目标是什么？为密钥创建一个最小可行设置，涵盖**前端、后端和数据库层**（后者经常被忽视，尽管它可能导致一些严重的麻烦）。

在本教程结束时，您将构建一个最小可行的 Web 应用程序，您可以在其中：

- 创建密钥
- 使用密钥登录

对于时间紧迫或想要参考的人，完整的代码库可在
[GitHub](https://github.com/corbado/passkey-tutorial) 上找到。

想知道最终结果是什么样子吗？这里是最终项目的一瞥（我们承认它看起来很基础，但有趣的东西在表面之下）：

![密钥教程登录屏幕](https://www.corbado.com/website-assets/6572cd2ed5d547903c8ad74d_passkey_tutorial_register_login_screen_f55a0f5ae9.png)

我们完全意识到部分代码和项目可以有不同或更复杂的实现方式，但我们希望专注于本质。因此，我们有意保持简单并以密钥为中心。

**如何将密钥添加到我的生产网站？**

这是一个非常基础的密钥认证示例。以下内容在本教程中**未**被考虑/实现，或仅作了非常基础的实现：

- 条件 UI / 条件中介 / 密钥自动填充
- 设备管理
- 会话管理
- 安全地向一个账户添加多个设备
- 向后兼容性
- 适当的跨平台和跨设备支持
- 备用认证方式
- 适当的错误处理
- 密钥管理页面

要完全支持所有这些功能，需要付出巨大的开发努力。对于感兴趣的人，我们推荐阅读这篇关于密钥开发者误区的文章。

## 2. 集成密钥的先决条件

在深入研究密钥实现之前，让我们看看必要的技能和工具。以下是您开始所需的：

### 2.1 前端：原生 HTML 和 JavaScript

对 Web 的基石——HTML、CSS 和 JavaScript 有扎实的掌握是必不可少的。我们有意保持简单，没有使用任何现代 JavaScript 框架，而是依赖于原生 JavaScript
/ HTML。我们使用的唯一更复杂的东西是 WebAuthn 包装器库
[@simplewebauthn/browser](https://simplewebauthn.dev/docs/packages/browser/)。

### 2.2 后端：Node.js (Express) in TypeScript + SimpleWebAuthn

对于我们的后端，我们使用一个用 TypeScript 编写的 [Node.js](https://www.corbado.com/blog/nodejs-passkeys)
(Express) 服务器。我们还决定使用
[SimpleWebAuthn 的 WebAuthn 服务器实现](https://simplewebauthn.dev/docs/packages/server)（`@simplewebauthn/server`
和
`@simplewebauthn/typescript-types`）。市面上有许多 WebAuthn 服务器实现，所以您当然也可以使用其中任何一个。由于我们选择了 TypeScript
WebAuthn 服务器，因此需要基本的 [Node.js](https://www.corbado.com/blog/nodejs-passkeys) 和 npm 知识。

### 2.3 数据库：MySQL

所有用户数据和密钥的公钥都存储在数据库中。我们选择了
[MySQL](https://www.corbado.com/blog/passkey-webauthn-database-guide) 作为数据库技术。对
[MySQL](https://www.corbado.com/blog/passkey-webauthn-database-guide)
和关系数据库有基础的了解会很有帮助，不过我们会在每个步骤中引导您。

在下文中，我们经常交替使用 WebAuthn 和密钥这两个术语，尽管它们在官方定义上可能不完全相同。为了更好地理解，尤其是在代码部分，我们做了这样的假设。

有了这些先决条件，您就可以开始探索密钥的世界了。

## 3. 架构概览：密钥示例实现

在进入代码和配置之前，让我们先看看我们想要构建的系统的架构。以下是我们即将设置的架构的分解：

- **前端：**
  它由两个按钮组成，一个用于用户注册（创建密钥），另一个用于认证（使用密钥登录）。
- **设备和浏览器：**
  一旦在前端触发一个动作，设备和浏览器就会介入。它们促进密钥的创建和验证，充当用户和后端之间的中介。
- **后端：**
  后端是我们应用程序中真正施展魔法的地方。它处理所有由前端发起的请求。这个过程涉及创建和验证密钥。后端操作的核心是 WebAuthn 服务器。与名称可能暗示的不同，它不是一个独立的服务器。相反，它是一个实现 WebAuthn 标准的库或包。两个主要功能是：**注册（Sign-up），新用户创建他们的密钥；和认证（Login），现有用户使用他们的密钥登录。**
  在最简单的形式中，WebAuthn 服务器提供四个公共 API 端点，分为两类：两个用于注册，两个用于认证。它们被设计为接收特定格式的数据，然后由 WebAuthn 服务器处理。WebAuthn 服务器负责所有必要的加密操作。需要注意的一个重要方面是，这些 API 端点必须通过 HTTPS 提供服务。
- **MySQL 数据库：**
  作为我们的存储骨干，[MySQL](https://www.corbado.com/blog/passkey-webauthn-database-guide)数据库负责保存用户数据及其相应的凭证。

通过这个架构概览，您应该对我们应用程序的组件如何构成有了一个概念图。随着我们继续，我们将深入探讨这些组件中的每一个，详细介绍它们的设置、配置和相互作用。

下图描述了注册（sign-up）期间的流程：

![密钥注册流程图](https://www.corbado.com/website-assets/6572cd4d3243003bb3589e88_passkey_sign_up_process_chart_b73d643b4c.png)

下图描述了认证（login）期间的流程：

![密钥登录流程图](https://www.corbado.com/website-assets/6572cd5f04fd73a7ff5d501b_passkey_login_process_chart_a44262b767.png)

此外，您可以在这里找到项目结构（仅显示最重要的文件）：

```
passkeys-tutorial
├── src                         # 包含所有后端 TypeScript 源代码
│   ├── controllers             # 处理特定类型请求的业务逻辑
│   │   ├── authentication.ts   # 密钥认证逻辑
│   │   └── registration.ts     # 密钥注册逻辑
│   ├── middleware
│   │   ├── customError.ts      # 以标准化方式添加自定义错误消息
│   │   └── errorHandler.ts     # 通用错误处理程序
│   ├── public
│   │   ├── index.html          # 前端的主 HTML 文件
│   │   ├── css
│   │   │   └── style.css       # 基本样式
│   │   └── js
│   │       └── script.js       # JavaScript 逻辑（包括 WebAuthn API）
│   ├── routes                  # API 路由及其处理程序的定义
│   │   └── routes.ts           # 特定密钥路由
│   ├── services
│   │   ├── credentialService.ts# 与凭证表交互
│   │   └── userService.ts      # 与用户表交互
│   ├── utils                   # 辅助函数和实用程序
│   |   ├── constants.ts        # 一些常量（例如 rpID）
│   |   └── utils.ts            # 辅助函数
│   ├── database.ts             # 创建从 Node.js 到 MySQL 数据库的连接
│   ├── index.ts                # Node.js 服务器的入口点
│   └── server.ts               # 管理所有服务器设置
├── config.json                 # Node.js 项目的一些配置
├── docker-compose.yml          # 为 Docker 容器定义服务、网络和卷
├── Dockerfile                  # 创建项目的 Docker 镜像
├── init-db.sql                 # 定义我们的 MySQL 数据库模式
├── package.json                # 管理 Node.js 项目的依赖项和脚本
└── tsconfig.json               # 配置 TypeScript 如何编译您的代码
```

## 4. 设置 MySQL 数据库

在实现密钥时，数据库设置是一个关键组件。我们的方法使用一个运行 MySQL 的 Docker 容器，提供一个直接且隔离的环境，这对于可靠的测试和部署至关重要。

我们的数据库模式有意保持极简，只有两个表。这种简单性有助于更清晰的理解和更容易的维护。

**详细的表结构**

**1. Credentials 表：** 这是密钥认证的核心，该表存储密钥凭证。关键列：

- **credential_id：**
  每个凭证的唯一标识符。为该字段选择正确的数据类型对于避免格式错误至关重要。
- **public_key：** 存储每个凭证的公钥。与 `credential_id`
  一样，适当的数据类型和格式至关重要。

**2. Users 表：** 将用户帐户链接到其相应的凭证。

请注意，我们将第一个表命名为 credentials，因为根据我们的经验和其他库的建议，这更合适（与 SimpleWebAuthn 建议将其命名为
[authenticator](https://www.corbado.com/glossary/authenticator) 或 authenticator_device 相反）。

`credential_id` 和 `public_key`
的数据类型至关重要。错误通常源于不正确的数据类型、编码或格式（特别是 Base64 和 Base64URL 之间的差异是错误的常见原因），这可能会中断整个注册（sign-up）或认证（login）过程。

设置这些表所需的所有 SQL 命令都包含在 `init-db.sql`
文件中。此脚本确保快速且无错误的数据库初始化。

对于更复杂的情况，您可以添加 `credential_device_type` 或 `credential_backed_up`
来存储有关凭证的更多信息并改善用户体验。不过，在本教程中我们不这样做。

```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)
);
```

创建此文件后，我们在项目的根级别创建一个新的 `docker-compose.yml` 文件：

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

此文件在端口 3306 上启动 MySQL 数据库，并创建定义的数据库结构。需要注意的是，这里使用的数据库名称和密码为了演示目的而保持简单。在生产环境中，您应该使用更复杂的凭证以增强安全性。

接下来，我们继续运行我们的 Docker 容器。此时，我们的 `docker-compose.yml`
文件只包含这一个容器，但我们稍后会添加更多组件。要启动容器，请使用以下命令：

```bash
docker compose up -d
```

容器启动并运行后，我们需要验证数据库是否按预期工作。打开一个终端并执行以下命令与 MySQL 数据库交互：

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

您将被提示输入 root 密码，在我们的示例中是 `my-secret-pw`。登录后，选择 `webauthn_db`
数据库并使用以下命令显示表：

```sql
use
webauthn_db;
show
tables;
```

此时，您应该能看到我们脚本中定义的两个表。最初，这些表将是空的，这表明我们的数据库设置已完成，并为实现密钥的下一步做好了准备。

## 5. 实现密钥：后端集成步骤

后端是任何密钥应用程序的核心，充当处理来自前端的用户认证请求的中心枢纽。它与 WebAuthn 服务器库通信以处理注册（sign-up）和认证（login）请求，并与您的 MySQL 数据库交互以存储和检索用户凭证。下面，我们将指导您使用
[Node.js](https://www.corbado.com/blog/nodejs-passkeys)
(Express) 和 TypeScript 设置后端，该后端将公开一个公共 API 来处理所有请求。

### 5.1 初始化 Node.js (Express) 服务器

首先，为您的项目创建一个新目录，并使用您的终端或命令提示符导航到该目录中。

运行命令

```bash
npx create-express-typescript-application passkeys-tutorial
```

这将创建一个用 TypeScript 编写的 Node.js
(Express) 应用程序的基本代码骨架，我们可以用它进行进一步的调整。

您的项目需要几个关键的包，我们需要额外安装：

- **@simplewebauthn/server：**
  一个服务器端库，用于促进 WebAuthn 操作，如用户注册（sign-up）和认证（login）。
- **express-session：** [Express.js](https://www.corbado.com/blog/nodejs-passkeys)
  的中间件，用于管理会话，存储服务器端会话数据和处理 cookie。
- **uuid：**
  一个用于生成通用唯一标识符（UUID）的实用程序，通常用于在应用程序中创建唯一的键或标识符。
- **mysql2：** 一个用于 MySQL 的 Node.js 客户端，提供连接和对 MySQL 数据库执行查询的能力。

切换到新目录并使用以下命令安装它们（我们还安装了所需的 TypeScript 类型）：

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

要确认一切都已正确安装，请运行

```bash
npm run dev:nodemon
```

这应该会以开发模式启动您的 Node.js 服务器，并使用 Nodemon，它会在任何文件更改时自动重启服务器。

![密钥教程启动应用](https://www.corbado.com/website-assets/6572cd86e1fa2982352c7ca1_passkey_tutorial_start_app_b442392b4d.png)

**故障排除提示：** 如果遇到错误，请尝试在 `package.json` 文件中将 `ts-node`
更新到版本 10.8.1，然后运行 `npm i` 来安装更新。

您的 `server.ts` 文件具有 Express 应用程序的基本设置和中间件。要集成密钥功能，您需要添加：

- **路由：** 为密钥注册（sign-up）和认证（login）定义新路由。
- **控制器：** 创建控制器来处理这些路由的逻辑。
- **中间件：** 集成用于请求和错误处理的中间件。
- **服务：** 构建服务以在数据库中检索和存储数据。
- **实用函数：** 包含用于高效代码操作的实用函数。

这些增强功能是启用应用程序后端密钥认证的关键。我们稍后会设置它们。

### 5.2 MySQL 数据库连接

在我们在[第 4 节](#4-setup-of-the-mysql-database)中创建并启动数据库之后，我们现在需要确保我们的后端可以连接到 MySQL 数据库。因此，我们在
`/src` 文件夹中创建一个新的 `database.ts` 文件，并添加以下内容：

```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();
```

这个文件稍后将被我们的服务器用来访问数据库。

### 5.3 应用服务器配置

让我们简要看一下我们的
`config.json`，其中已经定义了两个变量：我们运行应用程序的端口和环境：

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

`package.json` 可以保持原样，应该看起来像这样：

```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` 看起来像这样：

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

在 `server.ts`
中，我们需要调整更多的东西。此外，需要某种临时缓存（例如 redis、memcache 或 express-session）来存储用户可以进行身份验证的临时挑战码。我们决定使用
`express-session` 并在顶部声明 `express-session` 模块，以便与 `express-session`
协同工作。此外，我们简化了路由并暂时移除了错误处理（这将在稍后添加到中间件中）：

```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 凭证服务和用户服务

为了有效管理我们创建的两个表中的数据，我们将在一个新的 `src/services`
目录中开发两个不同的服务：`authenticatorService.ts` 和 `userService.ts`。

每个服务将封装CRUD（创建、读取、更新、删除）方法，使我们能够以模块化和有组织的方式与数据库交互。这些服务将有助于在认证器和用户表中存储、检索和更新数据。以下是这些所需文件的结构布局：

`userService.ts` 看起来像这样：

```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` 如下所示：

```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 中间件

为了集中处理错误并使调试更容易，我们添加一个 `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 });
};
```

此外，我们添加一个新的 `customError.ts`
文件，因为我们稍后希望能够创建自定义错误，以帮助我们更快地找到错误：

```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 实用工具

在 `utils` 文件夹中，我们创建两个文件 `constants.ts` 和 `utils.ts`。

`constant.ts` 保存一些基本的 WebAuthn 服务器信息，如信赖方名称、信赖方 ID 和来源：

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

`utils.ts` 保存了我们稍后需要用于编码和解码数据的两个函数：

```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 使用 SimpleWebAuthn 的密钥控制器

现在，我们来到了我们后端的中心：控制器。我们创建两个控制器，一个用于创建新密钥（`registration.ts`），另一个用于使用密钥登录（`authentication.ts`）。

`registration.ts` 看起来像这样：

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

让我们回顾一下我们控制器的功能，它们处理 WebAuthn 注册（sign-up）过程中的两个关键端点。这也是与基于密码的认证最大的区别之一：对于每次注册（sign-up）或认证（login）尝试，都需要两次后端 API 调用，这需要特定的前端内容穿插其中。密码通常只需要一个端点。

**1. handleRegisterStart 端点：**

这个端点由前端触发，接收一个用户名来创建一个新的密钥和账户。在这个例子中，我们只允许在没有现有账户的情况下创建新账户/密钥。在实际应用中，您需要处理这种情况，告知用户密钥已存在，并且无法从同一设备添加（但用户可以在某种形式的确认后从不同设备添加密钥）。为简单起见，我们在本教程中忽略了这一点。

`PublicKeyCredentialCreationOptions` 已准备好。`residentKey`
设置为 preferred，`attestationType` 设置为 direct，从认证器收集更多数据以备数据库存储。

通常，`PublicKeyCredentialCreationOptions` 由以下数据组成：

```
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：** 代表信赖方（网站或服务）信息，通常包括其名称（`rp.name`）和域名（`rp.id`）。
- **user：** 包含用户账户详情，如 `user.name`、`user.id` 和 `user.displayName`。
- **challenge：** 由 WebAuthn 服务器创建的安全随机值，用于防止注册过程中的重放攻击。
- **pubKeyCredParams：** 指定要创建的公钥凭证的类型，包括使用的加密算法。
- **timeout：** 可选，设置用户完成交互的时间（以毫秒为单位）。
- **excludeCredentials：** 要排除的凭证列表；用于防止为同一设备/认证器多次注册密钥。
- **authenticatorSelection：**
  选择认证器的标准，例如它是否必须支持用户验证或应如何鼓励使用常驻密钥。
- **attestation：** 指定期望的认证证明传递偏好，如 "none"、"indirect" 或 "direct"。
- **extensions：** 可选，允许额外的客户端扩展。

用户 ID 和挑战码存储在会话对象中，为教程目的简化了过程。此外，每次注册（sign-up）或认证（login）尝试后都会清除会话。

**2. handleRegisterFinish 端点：**

此端点检索先前设置的用户 ID 和挑战码。它使用挑战码验证
`RegistrationResponse`。如果有效，它会为用户存储一个新的凭证。一旦存储在数据库中，用户 ID 和挑战码就会从会话中移除。

提示：在调试您的应用程序时，我们强烈建议使用
[Chrome](https://www.corbado.com/zh/blog/client-hints-user-agent-chrome-safari-firefox)
浏览器及其内置功能来改善基于密钥的应用程序的开发体验，例如虚拟 WebAuthn 认证器和设备日志（有关更多信息，请参阅我们下面的[给开发者的额外密钥提示](#9-additional-passkey-tips-for-developers)）

接下来，我们转到 `authentication.ts`，它具有类似的结构和功能。

`authentication.ts` 看起来像这样：

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

我们的认证（login）过程涉及两个端点：

**1. handleLoginStart 端点：**

当用户尝试登录时，此端点被激活。它首先检查用户名是否存在于数据库中，如果未找到则返回错误。在实际场景中，您可能会提供创建新账户的选项。

对于现有用户，它从数据库中检索用户 ID，将其存储在会话中，并生成
`PublicKeyCredentialRequestOptions` 选项。`allowCredentials`
留空以避免限制凭证使用。因此，所有可用于此信赖方的密钥都可以在密钥模态框中选择。

生成的挑战码也存储在会话中，并且 `PublicKeyCredentialRequestOptions` 被发送回前端。

`PublicKeyCredentialRequestOptions` 由以下数据组成：

```
dictionary PublicKeyCredentialRequestOptions {
    required BufferSource                challenge;
    unsigned long                        timeout;
    USVString                            rpId;
    sequence<PublicKeyCredentialDescriptor> allowCredentials = [];
    DOMString                            userVerification = "preferred";
    AuthenticationExtensionsClientInputs extensions;
};
```

- **challenge：** 来自 WebAuthn 服务器的安全随机值，用于防止认证过程中的重放攻击。
- **timeout：** 可选，设置用户响应认证请求的时间（以毫秒为单位）。
- **rpId：** 信赖方 ID，通常是服务的域名。
- **allowCredentials：** 一个可选的凭证描述符列表，指定哪些凭证可用于此认证（login）。
- **userVerification：** 指定用户验证的要求，如 "required"、"preferred" 或 "discouraged"。
- **extensions：** 可选，允许额外的客户端扩展。

**2. handleLoginFinish 端点：**

此端点从会话中检索 `currentChallenge` 和 `loggedInUserId`。

它使用请求体中的凭证 ID 查询数据库以获取正确的凭证。如果找到凭证，这意味着与此凭证 ID 关联的用户现在可以被认证（登录）。然后，我们可以通过从凭证中获取的用户 ID 从用户表中查询用户，并使用挑战码和请求体验证
`authenticationResponse`。如果一切成功，我们显示登录成功消息。如果未找到匹配的凭证，则发送错误。

此外，如果验证成功，凭证的计数器将被更新，使用的挑战码和 loggedInUserId 将从会话中移除。

除此之外，我们可以删除 `src/app` 和 `src/constant` 文件夹以及其中的所有文件。

注意：为简化本教程，此处省略了在实际应用中至关重要的适当会话管理和路由保护。

### 5.8 密钥路由

最后但同样重要的是，我们需要通过将适当的路由添加到 `routes.ts`
来确保我们的控制器是可访问的，该文件位于一个新的 `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. 将密钥集成到前端

本部分密钥教程重点介绍如何在应用程序的前端支持密钥。我们有一个非常基础的前端，由三个文件组成：`index.html`、`styles.css`
和 `script.js`。这三个文件都位于一个新的 `src/public` 文件夹中。

`index.html` 文件包含一个用于用户名的输入字段和两个用于注册和登录的按钮。此外，我们导入了
`@simplewebauthn/browser` 脚本，它简化了在 `js/script.js` 文件中与浏览器 Web
Authentication API 的交互。

`index.html` 看起来像这样：

```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` 如下所示：

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

在 `script.js` 中，有三个主要函数：

**1. showMessage 函数：**

这是一个实用函数，主要用于显示错误消息，辅助调试。

**2. Register 函数：**

当用户点击“注册”时触发。它从输入字段中提取用户名并将其发送到 passkeyRegisterStart 端点。响应包括
**`PublicKeyCredentialCreationOptions`**，这些选项被转换为 JSON 并传递给
`SimpleWebAuthnBrowser.startRegistration`。此调用会激活设备认证器（如面容 ID 或触控 ID）。在本地认证成功后，签名的挑战码被发送回
`passkeyRegisterFinish` 端点，完成密钥创建过程。

在注册（sign-up）过程中，认证对象起着至关重要的作用，所以让我们仔细看看它。

![密钥认证对象](https://www.corbado.com/website-assets/6572cda3f3869adee2c38e05_passkey_attestation_object_5bfcce1977.png)

认证对象主要由三个部分组成：`fmt`、`attStmt` 和 `authData`。`fmt`
元素表示认证声明的格式，而 `attStmt`
代表实际的认证声明本身。在认为不需要认证的情况下，`fmt` 将被指定为“none”，导致 `attStmt`
为空。

重点是此结构中的 `authData`
部分。此部分是检索基本元素（如信赖方 ID、标志、计数器和已证明的凭证数据）的关键。关于标志，特别值得关注的是 BS（备份状态）和 BE（备份资格），它们提供了有关密钥是否已同步（例如，通过 iCloud 钥匙串或
[1Password](https://www.corbado.com/blog/1password-passkeys-best-practices-analysis)）的更多信息。此外，UV（用户验证）和 UP（用户存在）提供了更多有用的信息。

![密钥认证器数据](https://www.corbado.com/website-assets/6572cf3ce1fa2982352d6c88_passkey_authenticator_data_7f61aceaea.png)

需要注意的是，认证对象的各个部分，包括认证器数据、信赖方 ID 和认证声明，都由认证器使用其私钥进行哈希或数字签名。这个过程对于维护认证对象的整体完整性至关重要。

**3. Login 函数：**

当用户点击“登录”时激活。与注册函数类似，它提取用户名并将其发送到 `passkeyLoginStart`
端点。响应包含 **`PublicKeyCredentialRequestOptions`**，被转换为 JSON 并与
`SimpleWebAuthnBrowser.startAuthentication`
一起使用。这会触发设备上的本地认证。然后，签名的挑战码被发送回 `passkeyLoginFinish`
端点。来自此端点的成功响应表示用户已成功登录应用程序。

此外，附带的 CSS 文件为应用程序提供了简单的样式：

```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. 运行密钥示例应用

要看到您的应用程序运行，请使用以下命令编译并运行您的 TypeScript 代码：

```bash
npm run dev
```

您的服务器现在应该在 [http://localhost:8080](http://localhost:8080) 上运行。

**生产环境的考虑因素：**

请记住，我们所涵盖的是一个基本大纲。在生产环境中部署密钥应用程序时，您需要更深入地研究：

- **安全措施：** 实施强大的安全实践以保护用户数据。
- **错误处理：** 确保您的应用程序能够优雅地处理和记录错误。
- **数据库管理：** 优化数据库操作以实现可伸缩性和可靠性。

## 8. 密钥 DevOps 集成

我们已经为我们的数据库设置了一个 Docker 容器。接下来，我们将扩展我们的 Docker
Compose 设置，以包括包含后端和前端的服务器。您的 `docker-compose.yml` 文件应相应更新。

为了容器化我们的应用程序，我们创建一个新的 Dockerfile，它会安装所需的包并启动开发服务器：

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

然后，我们还扩展 `docker-compose.yml` 文件以启动此容器：

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

如果您现在在终端中运行 `docker compose up` 并访问
[http://localhost:8080](http://localhost:8080)，您应该能看到您的密钥 Web 应用的工作版本（此处在
[Windows 11](https://www.corbado.com/blog/passkeys-windows-11) 23H2 +
[Chrome](https://www.corbado.com/zh/blog/client-hints-user-agent-chrome-safari-firefox) 119 上运行）：

![密钥教程 Windows Hello](https://www.corbado.com/website-assets/6572cf57f3869adee2c49ff5_passkey_tutorial_windows_hello_5ac0585957.png)

## 9. 给开发者的额外密钥提示

由于我们已经从事密钥实现工作相当长一段时间，我们发现在处理实际的密钥应用时会遇到一些挑战：

- 设备/平台兼容性和支持
- 用户引导和教育
- 处理设备丢失或更换
- 跨平台认证
- 备用机制
- 编码复杂性：编码通常是最困难的部分，因为您必须处理 JSON、[CBOR](https://www.corbado.com/glossary/cbor)、uint8arrays、buffers、blobs、不同的数据库、base64 和 base64url，其中可能会出现很多错误
- 密钥管理（例如添加、删除或重命名密钥）

此外，我们为开发者在实现部分提供以下提示：

**利用密钥调试器**

[**密钥调试器**](https://www.passkeys-debugger.io/)
有助于测试不同的 WebAuthn 服务器设置和客户端响应。此外，它为认证器响应提供了一个很棒的解析器。

**使用 Chrome 设备日志功能进行调试**

使用 Chrome 的设备日志（可通过 [chrome://device-log/](chrome://device-log/) 访问）来监控
[FIDO](https://www.corbado.com/zh/blog/passkey-shishi-chengben-fte)/WebAuthn 调用。此功能提供认证（login）过程的实时日志，让您可以看到正在交换的数据并解决出现的任何问题。

另一个非常有用的快捷方式是在 Chrome 中获取所有密钥，即使用
[chrome://settings/passkeys](chrome://settings/passkeys)。

**使用 Chrome 虚拟 WebAuthn 认证器**

为了避免在开发过程中使用触控 ID、面容 ID 或 [Windows Hello](https://www.corbado.com/glossary/windows-hello)
提示，Chrome 提供了一个非常方便的虚拟 WebAuthn 认证器，可以模拟真实的认证器。我们强烈建议使用它来加快速度。在此处查找更多详细信息[这里](https://developer.chrome.com/docs/devtools/webauthn/)。

**在不同平台和浏览器上进行测试**

确保在各种浏览器和平台上的兼容性和功能性。WebAuthn 在不同浏览器上的行为不同，因此彻底的测试是关键。

**在不同设备上进行测试**

在这里，使用像 ngrok 这样的工具特别有用，您可以通过它使您的本地应用程序在其他（移动）设备上可访问。

**将用户验证设置为首选**

在定义 **`PublicKeyCredentialRequestOptions`** 中
[userVerification](https://www.corbado.com/glossary/user-verification)
的属性时，选择将其设置为 preferred，因为这是可用性和安全性之间的一个良好权衡。这意味着在合适的设备上会有安全检查，同时在没有生物识别功能的设备上保持用户友好性。

## 10. 总结：密钥教程

我们希望这篇密钥教程能让您清楚地了解如何有效地实现密钥。在整个教程中，我们逐步介绍了创建密钥应用程序的基本步骤，重点关注基本概念和实际实现。虽然本指南只是一个起点，但在 WebAuthn 的世界里还有更多值得探索和完善的地方。

我们鼓励开发者深入研究密钥的细微之处（例如添加多个密钥、检查设备上的密钥就绪情况或提供恢复解决方案）。这是一段值得踏上的旅程，既有挑战，也能在增强用户认证方面带来巨大的回报。通过密钥，您不仅仅是在构建一个功能；您正在为创建一个更安全、更用户友好的数字世界做出贡献。
