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

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

本教程将讲解如何在您的 Web 应用中实现密钥。我们将使用 Node.js (TypeScript)、SimpleWebAuthn、原生 HTML / JavaScript 和 MySQL。

Vincent Delitz

Vincent

Created: June 17, 2025

Updated: June 24, 2025


We aim to make the Internet a safer place using passkeys. That's why we want to support developers with tutorials on how to implement passkeys.

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

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

Demo Icon

Want to try passkeys yourself in a passkeys demo?

Try Passkeys

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

密钥的潜力真正体现在它们所获得的认可上。所有主流浏览器,无论是 Chrome、Firefox、Safari 还是 Edge,以及所有重要的设备制造商(Apple、Microsoft、Google)都已加入了支持。这种一致的接纳表明,密钥是登录的新标准。

是的,已经有关于将密钥集成到 Web 应用程序中的教程。无论是针对像 ReactVue.jsNext.js 这样的前端框架,都有大量的指南旨在减轻挑战并加速您的密钥实现。然而,目前还缺少一个保持极简和底层端到端教程。许多开发者向我们咨询,希望有一个能清晰阐述Web 应用中密钥实现的教程。

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

PasskeyAssessment Icon

Get a free passkey assessment in 15 minutes.

Book free consultation

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

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

对于时间紧迫或想要参考的人,完整的代码库可在 GitHub 上找到。

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

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

StateOfPasskeys Icon

Want to find out how many people use passkeys?

View Adoption Data

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

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

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

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

Slack Icon

Become part of our Passkeys Community for updates & support.

Join

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

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

2.1 前端:原生 HTML 和 JavaScript#

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

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

对于我们的后端,我们使用一个用 TypeScript 编写的 Node.js (Express) 服务器。我们还决定使用 SimpleWebAuthn 的 WebAuthn 服务器实现@simplewebauthn/server@simplewebauthn/typescript-types)。市面上有许多 WebAuthn 服务器实现,所以您当然也可以使用其中任何一个。由于我们选择了 TypeScript WebAuthn 服务器,因此需要基本的 Node.js 和 npm 知识。

2.3 数据库:MySQL#

所有用户数据和密钥的公钥都存储在数据库中。我们选择了 MySQL 作为数据库技术。对 MySQL 和关系数据库有基础的了解会很有帮助,不过我们会在每个步骤中引导您。

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

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

Ben Gould Testimonial

Ben Gould

Head of Engineering

I’ve built hundreds of integrations in my time, including quite a few with identity providers and I’ve never been so impressed with a developer experience as I have been with Corbado.

超过 10,000 名开发者信任 Corbado,并通过密钥让互联网更安全。有疑问吗?我们已经撰写了超过 150 篇关于密钥的博客文章。

加入密钥社区

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

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

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

Are your users passkey-ready?

Test Passkey-Readiness

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

下图描述了注册(sign-up)期间的流程:

下图描述了认证(login)期间的流程:

此外,您可以在这里找到项目结构(仅显示最重要的文件):

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 或 authenticator_device 相反)。

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

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

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

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 文件:

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 文件只包含这一个容器,但我们稍后会添加更多组件。要启动容器,请使用以下命令:

docker compose up -d

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

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

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

use webauthn_db; show tables;

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

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

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

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

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

运行命令

npx create-express-typescript-application passkeys-tutorial

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

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

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

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

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

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

npm run dev:nodemon

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

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

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

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

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

Debugger Icon

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

Try for Free

5.2 MySQL 数据库连接#

在我们在第 4 节中创建并启动数据库之后,我们现在需要确保我们的后端可以连接到 MySQL 数据库。因此,我们在 /src 文件夹中创建一个新的 database.ts 文件,并添加以下内容:

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,其中已经定义了两个变量:我们运行应用程序的端口和环境:

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

package.json 可以保持原样,应该看起来像这样:

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

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 协同工作。此外,我们简化了路由并暂时移除了错误处理(这将在稍后添加到中间件中):

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.tsuserService.ts

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

userService.ts 看起来像这样:

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

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 文件:

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 文件,因为我们稍后希望能够创建自定义错误,以帮助我们更快地找到错误:

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.tsutils.ts

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

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

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

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

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.nameuser.iduser.displayName
  • challenge: 由 WebAuthn 服务器创建的安全随机值,用于防止注册过程中的重放攻击。
  • pubKeyCredParams: 指定要创建的公钥凭证的类型,包括使用的加密算法。
  • timeout: 可选,设置用户完成交互的时间(以毫秒为单位)。
  • excludeCredentials: 要排除的凭证列表;用于防止为同一设备/认证器多次注册密钥。
  • authenticatorSelection: 选择认证器的标准,例如它是否必须支持用户验证或应如何鼓励使用常驻密钥。
  • attestation: 指定期望的认证证明传递偏好,如 "none"、"indirect" 或 "direct"。
  • extensions: 可选,允许额外的客户端扩展。

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

2. handleRegisterFinish 端点:

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

提示:在调试您的应用程序时,我们强烈建议使用 Chrome 浏览器及其内置功能来改善基于密钥的应用程序的开发体验,例如虚拟 WebAuthn 认证器和设备日志(有关更多信息,请参阅我们下面的给开发者的额外密钥提示

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

authentication.ts 看起来像这样:

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 端点:

此端点从会话中检索 currentChallengeloggedInUserId

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

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

除此之外,我们可以删除 src/appsrc/constant 文件夹以及其中的所有文件。

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

5.8 密钥路由#

最后但同样重要的是,我们需要通过将适当的路由添加到 routes.ts 来确保我们的控制器是可访问的,该文件位于一个新的 src/routes 目录中:

routes.ts
import express from "express"; import { handleError } from "../middleware/errorHandler"; import { handleRegisterStart, handleRegisterFinish } from "../controllers/registration"; import { handleLoginStart, handleLoginFinish } from "../controllers/authentication"; const router = express.Router(); router.post("/registerStart", handleRegisterStart); router.post("/registerFinish", handleRegisterFinish); router.post("/loginStart", handleLoginStart); router.post("/loginFinish", handleLoginFinish); router.use(handleError); export { router };
Substack Icon

Subscribe to our Passkeys Substack for the latest news.

Subscribe

6. 将密钥集成到前端#

本部分密钥教程重点介绍如何在应用程序的前端支持密钥。我们有一个非常基础的前端,由三个文件组成:index.htmlstyles.cssscript.js。这三个文件都位于一个新的 src/public 文件夹中。

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

index.html 看起来像这样:

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

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)过程中,认证对象起着至关重要的作用,所以让我们仔细看看它。

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

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

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

3. Login 函数:

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

此外,附带的 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 代码:

npm run dev

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

生产环境的考虑因素:

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

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

8. 密钥 DevOps 集成#

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

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

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 文件以启动此容器:

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,您应该能看到您的密钥 Web 应用的工作版本(此处在 Windows 11 23H2 + Chrome 119 上运行):

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

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

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

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

利用密钥调试器

密钥调试器 有助于测试不同的 WebAuthn 服务器设置和客户端响应。此外,它为认证器响应提供了一个很棒的解析器。

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

使用 Chrome 的设备日志(可通过 chrome://device-log/ 访问)来监控 FIDO/WebAuthn 调用。此功能提供认证(login)过程的实时日志,让您可以看到正在交换的数据并解决出现的任何问题。

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

使用 Chrome 虚拟 WebAuthn 认证器

为了避免在开发过程中使用触控 ID、面容 ID 或 Windows Hello 提示,Chrome 提供了一个非常方便的虚拟 WebAuthn 认证器,可以模拟真实的认证器。我们强烈建议使用它来加快速度。在此处查找更多详细信息这里

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

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

在不同设备上进行测试

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

将用户验证设置为首选

在定义 PublicKeyCredentialRequestOptions 中 userVerification 的属性时,选择将其设置为 preferred,因为这是可用性和安全性之间的一个良好权衡。这意味着在合适的设备上会有安全检查,同时在没有生物识别功能的设备上保持用户友好性。

10. 总结:密钥教程#

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

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

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

Start for free

Share this article


LinkedInTwitterFacebook

Enjoyed this read?

🤝 Join our Passkeys Community

Share passkeys implementation tips and get support to free the world from passwords.

🚀 Subscribe to Substack

Get the latest news, strategies, and insights about passkeys sent straight to your inbox.

Related Articles