本教程将讲解如何在您的 Web 应用中实现密钥。我们将使用 Node.js (TypeScript)、SimpleWebAuthn、原生 HTML / JavaScript 和 MySQL。
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.
在本教程中,我们将帮助您实现密钥,提供一份关于如何向您的网站添加密钥的分步指南。
当您想构建一个出色的网站或应用时,拥有一个现代化、强大且用户友好的认证方式是关键。密钥(Passkey)已成为应对这一挑战的答案。作为登录的新标准,它们承诺一个没有传统密码缺点的未来,提供真正的无密码登录体验(这不仅安全,而且非常方便)。
密钥的潜力真正体现在它们所获得的认可上。所有主流浏览器,无论是 Chrome、Firefox、Safari 还是 Edge,以及所有重要的设备制造商(Apple、Microsoft、Google)都已加入了支持。这种一致的接纳表明,密钥是登录的新标准。
是的,已经有关于将密钥集成到 Web 应用程序中的教程。无论是针对像 React、Vue.js 或 Next.js 这样的前端框架,都有大量的指南旨在减轻挑战并加速您的密钥实现。然而,目前还缺少一个保持极简和底层的端到端教程。许多开发者向我们咨询,希望有一个能清晰阐述Web 应用中密钥实现的教程。
这正是我们撰写本指南的原因。我们的目标是什么?为密钥创建一个最小可行设置,涵盖前端、后端和数据库层(后者经常被忽视,尽管它可能导致一些严重的麻烦)。
在本教程结束时,您将构建一个最小可行的 Web 应用程序,您可以在其中:
对于时间紧迫或想要参考的人,完整的代码库可在 GitHub 上找到。
想知道最终结果是什么样子吗?这里是最终项目的一瞥(我们承认它看起来很基础,但有趣的东西在表面之下):
我们完全意识到部分代码和项目可以有不同或更复杂的实现方式,但我们希望专注于本质。因此,我们有意保持简单并以密钥为中心。
如何将密钥添加到我的生产网站?
这是一个非常基础的密钥认证示例。以下内容在本教程中未被考虑/实现,或仅作了非常基础的实现:
要完全支持所有这些功能,需要付出巨大的开发努力。对于感兴趣的人,我们推荐阅读这篇关于密钥开发者误区的文章。
在深入研究密钥实现之前,让我们看看必要的技能和工具。以下是您开始所需的:
对 Web 的基石——HTML、CSS 和 JavaScript 有扎实的掌握是必不可少的。我们有意保持简单,没有使用任何现代 JavaScript 框架,而是依赖于原生 JavaScript / HTML。我们使用的唯一更复杂的东西是 WebAuthn 包装器库 @simplewebauthn/browser。
对于我们的后端,我们使用一个用 TypeScript 编写的 Node.js
(Express) 服务器。我们还决定使用
SimpleWebAuthn 的 WebAuthn 服务器实现(@simplewebauthn/server
和
@simplewebauthn/typescript-types
)。市面上有许多 WebAuthn 服务器实现,所以您当然也可以使用其中任何一个。由于我们选择了 TypeScript
WebAuthn 服务器,因此需要基本的 Node.js 和 npm 知识。
所有用户数据和密钥的公钥都存储在数据库中。我们选择了 MySQL 作为数据库技术。对 MySQL 和关系数据库有基础的了解会很有帮助,不过我们会在每个步骤中引导您。
在下文中,我们经常交替使用 WebAuthn 和密钥这两个术语,尽管它们在官方定义上可能不完全相同。为了更好地理解,尤其是在代码部分,我们做了这样的假设。
有了这些先决条件,您就可以开始探索密钥的世界了。
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 篇关于密钥的博客文章。
加入密钥社区在进入代码和配置之前,让我们先看看我们想要构建的系统的架构。以下是我们即将设置的架构的分解:
通过这个架构概览,您应该对我们应用程序的组件如何构成有了一个概念图。随着我们继续,我们将深入探讨这些组件中的每一个,详细介绍它们的设置、配置和相互作用。
下图描述了注册(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 如何编译您的代码
在实现密钥时,数据库设置是一个关键组件。我们的方法使用一个运行 MySQL 的 Docker 容器,提供一个直接且隔离的环境,这对于可靠的测试和部署至关重要。
我们的数据库模式有意保持极简,只有两个表。这种简单性有助于更清晰的理解和更容易的维护。
详细的表结构
1. Credentials 表: 这是密钥认证的核心,该表存储密钥凭证。关键列:
credential_id
一样,适当的数据类型和格式至关重要。2. Users 表: 将用户帐户链接到其相应的凭证。
请注意,我们将第一个表命名为 credentials,因为根据我们的经验和其他库的建议,这更合适(与 SimpleWebAuthn 建议将其命名为 authenticator 或 authenticator_device 相反)。
credential_id
和 public_key
的数据类型至关重要。错误通常源于不正确的数据类型、编码或格式(特别是 Base64 和 Base64URL 之间的差异是错误的常见原因),这可能会中断整个注册(sign-up)或认证(login)过程。
设置这些表所需的所有 SQL 命令都包含在 init-db.sql
文件中。此脚本确保快速且无错误的数据库初始化。
对于更复杂的情况,您可以添加 credential_device_type
或 credential_backed_up
来存储有关凭证的更多信息并改善用户体验。不过,在本教程中我们不这样做。
init-db.sqlCREATE TABLE users ( id VARCHAR(255) PRIMARY KEY, username VARCHAR(255) NOT NULL UNIQUE ); CREATE TABLE credentials ( id INT AUTO_INCREMENT PRIMARY KEY, user_id VARCHAR(255) NOT NULL, credential_id VARCHAR(255) NOT NULL, public_key TEXT NOT NULL, counter INT NOT NULL, transports VARCHAR(255), FOREIGN KEY (user_id) REFERENCES users (id) );
创建此文件后,我们在项目的根级别创建一个新的 docker-compose.yml
文件:
docker-compose.ymlversion: "3.1" services: db: image: mysql command: --default-authentication-plugin=mysql_native_password restart: always environment: MYSQL_ROOT_PASSWORD: my-secret-pw MYSQL_DATABASE: webauthn_db ports: - "3306:3306" volumes: - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql
此文件在端口 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;
此时,您应该能看到我们脚本中定义的两个表。最初,这些表将是空的,这表明我们的数据库设置已完成,并为实现密钥的下一步做好了准备。
后端是任何密钥应用程序的核心,充当处理来自前端的用户认证请求的中心枢纽。它与 WebAuthn 服务器库通信以处理注册(sign-up)和认证(login)请求,并与您的 MySQL 数据库交互以存储和检索用户凭证。下面,我们将指导您使用 Node.js (Express) 和 TypeScript 设置后端,该后端将公开一个公共 API 来处理所有请求。
首先,为您的项目创建一个新目录,并使用您的终端或命令提示符导航到该目录中。
运行命令
npx create-express-typescript-application passkeys-tutorial
这将创建一个用 TypeScript 编写的 Node.js (Express) 应用程序的基本代码骨架,我们可以用它进行进一步的调整。
您的项目需要几个关键的包,我们需要额外安装:
切换到新目录并使用以下命令安装它们(我们还安装了所需的 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
应用程序的基本设置和中间件。要集成密钥功能,您需要添加:
这些增强功能是启用应用程序后端密钥认证的关键。我们稍后会设置它们。
在我们在第 4 节中创建并启动数据库之后,我们现在需要确保我们的后端可以连接到 MySQL 数据库。因此,我们在
/src
文件夹中创建一个新的 database.ts
文件,并添加以下内容:
database.tsimport mysql from "mysql2"; // Create a MySQL pool const pool = mysql.createPool({ host: process.env.DB_HOST, user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, waitForConnections: true, connectionLimit: 10, queueLimit: 0, }); // Promisify for Node.js async/await. export const promisePool = pool.promise();
这个文件稍后将被我们的服务器用来访问数据库。
让我们简要看一下我们的
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.tsimport app from "./server"; import config from "../config.json"; // Start the application by listening to specific port const port = Number(process.env.PORT || config.PORT || 8080); app.listen(port, () => { console.info("Express application started on port: " + port); });
在 server.ts
中,我们需要调整更多的东西。此外,需要某种临时缓存(例如 redis、memcache 或
express-session)来存储用户可以进行身份验证的临时挑战码。我们决定使用
express-session
并在顶部声明 express-session
模块,以便与 express-session
协同工作。此外,我们简化了路由并暂时移除了错误处理(这将在稍后添加到中间件中):
server.tsimport express, { Express } from "express"; import morgan from "morgan"; import helmet from "helmet"; import cors from "cors"; import config from "../config.json"; import { router as passkeyRoutes } from "./routes/routes"; import session from "express-session"; const app: Express = express(); declare module "express-session" { interface SessionData { currentChallenge?: string; loggedInUserId?: string; } } /************************************************************************************ * Basic Express Middlewares ***********************************************************************************/ app.set("json spaces", 4); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use( session({ // @ts-ignore secret: process.env.SESSION_SECRET, saveUninitialized: true, resave: false, cookie: { maxAge: 86400000, httpOnly: true, // Ensure to not expose session cookies to clientside scripts }, }), ); // Handle logs in console during development if (process.env.NODE_ENV === "development" || config.NODE_ENV === "development") { app.use(morgan("dev")); app.use(cors()); } // Handle security and origin in production if (process.env.NODE_ENV === "production" || config.NODE_ENV === "production") { app.use(helmet()); } /************************************************************************************ * Register all routes ***********************************************************************************/ app.use("/api/passkey", passkeyRoutes); app.use(express.static("src/public")); export default app;
为了有效管理我们创建的两个表中的数据,我们将在一个新的 src/services
目录中开发两个不同的服务:authenticatorService.ts
和 userService.ts
。
每个服务将封装CRUD(创建、读取、更新、删除)方法,使我们能够以模块化和有组织的方式与数据库交互。这些服务将有助于在认证器和用户表中存储、检索和更新数据。以下是这些所需文件的结构布局:
userService.ts
看起来像这样:
userService.tsimport { promisePool } from "../database"; // Adjust the import path as necessary import { v4 as uuidv4 } from "uuid"; export const userService = { async getUserById(userId: string) { const [rows] = await promisePool.query("SELECT * FROM users WHERE id = ?", [ userId, ]); // @ts-ignore return rows[0]; }, async getUserByUsername(username: string) { try { const [rows] = await promisePool.query( "SELECT * FROM users WHERE username = ?", [username], ); // @ts-ignore return rows[0]; } catch (error) { return null; } }, async createUser(username: string) { const id = uuidv4(); await promisePool.query("INSERT INTO users (id, username) VALUES (?, ?)", [ id, username, ]); return { id, username }; }, };
credentialService.ts
如下所示:
credentialService.tsimport { promisePool } from "../database"; import type { AuthenticatorDevice } from "@simplewebauthn/typescript-types"; export const credentialService = { async saveNewCredential( userId: string, credentialId: string, publicKey: string, counter: number, transports: string, ) { try { await promisePool.query( "INSERT INTO credentials (user_id, credential_id, public_key, counter, transports) VALUES (?, ?, ?, ?, ?)", [userId, credentialId, publicKey, counter, transports], ); } catch (error) { console.error("Error saving new credential:", error); throw error; } }, async getCredentialByCredentialId( credentialId: string, ): Promise<AuthenticatorDevice | null> { try { const [rows] = await promisePool.query( "SELECT * FROM credentials WHERE credential_id = ? LIMIT 1", [credentialId], ); // @ts-ignore if (rows.length === 0) return null; // @ts-ignore const row = rows[0]; return { userID: row.user_id, credentialID: row.credential_id, credentialPublicKey: row.public_key, counter: row.counter, transports: row.transports ? row.transports.split(",") : [], } as AuthenticatorDevice; } catch (error) { console.error("Error retrieving credential:", error); throw error; } }, async updateCredentialCounter(credentialId: string, newCounter: number) { try { await promisePool.query( "UPDATE credentials SET counter = ? WHERE credential_id = ?", [newCounter, credentialId], ); } catch (error) { console.error("Error updating credential counter:", error); throw error; } }, };
为了集中处理错误并使调试更容易,我们添加一个 errorHandler.ts
文件:
errorHandler.tsimport { Request, Response, NextFunction } from "express"; import { CustomError } from "./customError"; interface ErrorWithStatus extends Error { statusCode?: number; } export const handleError = ( err: CustomError, req: Request, res: Response, next: NextFunction, ) => { const statusCode = err.statusCode || 500; const message = err.message || "Internal Server Error"; console.log(message); res.status(statusCode).send({ error: message }); };
此外,我们添加一个新的 customError.ts
文件,因为我们稍后希望能够创建自定义错误,以帮助我们更快地找到错误:
customError.tsexport class CustomError extends Error { statusCode: number; constructor(message: string, statusCode: number = 500) { super(message); this.statusCode = statusCode; Object.setPrototypeOf(this, CustomError.prototype); } }
在 utils
文件夹中,我们创建两个文件 constants.ts
和 utils.ts
。
constant.ts
保存一些基本的 WebAuthn 服务器信息,如信赖方名称、信赖方 ID 和来源:
constant.tsexport const rpName: string = "Passkeys Tutorial"; export const rpID: string = "localhost"; export const origin: string = `http://${rpID}:8080`;
utils.ts
保存了我们稍后需要用于编码和解码数据的两个函数:
utils.tsexport const uint8ArrayToBase64 = (uint8Array: Uint8Array): string => Buffer.from(uint8Array).toString("base64"); export const base64ToUint8Array = (base64: string): Uint8Array => new Uint8Array(Buffer.from(base64, "base64"));
现在,我们来到了我们后端的中心:控制器。我们创建两个控制器,一个用于创建新密钥(registration.ts
),另一个用于使用密钥登录(authentication.ts
)。
registration.ts
看起来像这样:
registration.tsimport { generateRegistrationOptions, verifyRegistrationResponse, } from "@simplewebauthn/server"; import { uint8ArrayToBase64 } from "../utils/utils"; import { rpName, rpID, origin } from "../utils/constants"; import { credentialService } from "../services/credentialService"; import { userService } from "../services/userService"; import { RegistrationResponseJSON } from "@simplewebauthn/typescript-types"; import { Request, Response, NextFunction } from "express"; import { CustomError } from "../middleware/customError"; export const handleRegisterStart = async ( req: Request, res: Response, next: NextFunction, ) => { const { username } = req.body; if (!username) { return next(new CustomError("Username empty", 400)); } try { let user = await userService.getUserByUsername(username); if (user) { return next(new CustomError("User already exists", 400)); } else { user = await userService.createUser(username); } const options = await generateRegistrationOptions({ rpName, rpID, userID: user.id, userName: user.username, timeout: 60000, attestationType: "direct", excludeCredentials: [], authenticatorSelection: { residentKey: "preferred", }, // Support for the two most common algorithms: ES256, and RS256 supportedAlgorithmIDs: [-7, -257], }); req.session.loggedInUserId = user.id; req.session.currentChallenge = options.challenge; res.send(options); } catch (error) { next( error instanceof CustomError ? error : new CustomError("Internal Server Error", 500), ); } }; export const handleRegisterFinish = async ( req: Request, res: Response, next: NextFunction, ) => { const { body } = req; const { currentChallenge, loggedInUserId } = req.session; if (!loggedInUserId) { return next(new CustomError("User ID is missing", 400)); } if (!currentChallenge) { return next(new CustomError("Current challenge is missing", 400)); } try { const verification = await verifyRegistrationResponse({ response: body as RegistrationResponseJSON, expectedChallenge: currentChallenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: true, }); if (verification.verified && verification.registrationInfo) { const { credentialPublicKey, credentialID, counter } = verification.registrationInfo; await credentialService.saveNewCredential( loggedInUserId, uint8ArrayToBase64(credentialID), uint8ArrayToBase64(credentialPublicKey), counter, body.response.transports, ); res.send({ verified: true }); } else { next(new CustomError("Verification failed", 400)); } } catch (error) { next( error instanceof CustomError ? error : new CustomError("Internal Server Error", 500), ); } finally { req.session.loggedInUserId = undefined; req.session.currentChallenge = undefined; } };
让我们回顾一下我们控制器的功能,它们处理 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.name
)和域名(rp.id
)。user.name
、user.id
和 user.displayName
。用户 ID 和挑战码存储在会话对象中,为教程目的简化了过程。此外,每次注册(sign-up)或认证(login)尝试后都会清除会话。
2. handleRegisterFinish 端点:
此端点检索先前设置的用户 ID 和挑战码。它使用挑战码验证
RegistrationResponse
。如果有效,它会为用户存储一个新的凭证。一旦存储在数据库中,用户 ID 和挑战码就会从会话中移除。
提示:在调试您的应用程序时,我们强烈建议使用 Chrome 浏览器及其内置功能来改善基于密钥的应用程序的开发体验,例如虚拟 WebAuthn 认证器和设备日志(有关更多信息,请参阅我们下面的给开发者的额外密钥提示)
接下来,我们转到 authentication.ts
,它具有类似的结构和功能。
authentication.ts
看起来像这样:
authentication.tsimport { Request, Response, NextFunction } from "express"; import { generateAuthenticationOptions, verifyAuthenticationResponse, } from "@simplewebauthn/server"; import { uint8ArrayToBase64, base64ToUint8Array } from "../utils/utils"; import { rpID, origin } from "../utils/constants"; import { credentialService } from "../services/credentialService"; import { userService } from "../services/userService"; import { AuthenticatorDevice } from "@simplewebauthn/typescript-types"; import { isoBase64URL } from "@simplewebauthn/server/helpers"; import { VerifiedAuthenticationResponse, VerifyAuthenticationResponseOpts, } from "@simplewebauthn/server/esm"; import { CustomError } from "../middleware/customError"; export const handleLoginStart = async ( req: Request, res: Response, next: NextFunction, ) => { const { username } = req.body; try { const user = await userService.getUserByUsername(username); if (!user) { return next(new CustomError("User not found", 404)); } req.session.loggedInUserId = user.id; // allowCredentials 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; };
2. handleLoginFinish 端点:
此端点从会话中检索 currentChallenge
和 loggedInUserId
。
它使用请求体中的凭证 ID 查询数据库以获取正确的凭证。如果找到凭证,这意味着与此凭证 ID 关联的用户现在可以被认证(登录)。然后,我们可以通过从凭证中获取的用户 ID 从用户表中查询用户,并使用挑战码和请求体验证
authenticationResponse
。如果一切成功,我们显示登录成功消息。如果未找到匹配的凭证,则发送错误。
此外,如果验证成功,凭证的计数器将被更新,使用的挑战码和 loggedInUserId 将从会话中移除。
除此之外,我们可以删除 src/app
和 src/constant
文件夹以及其中的所有文件。
注意:为简化本教程,此处省略了在实际应用中至关重要的适当会话管理和路由保护。
最后但同样重要的是,我们需要通过将适当的路由添加到 routes.ts
来确保我们的控制器是可访问的,该文件位于一个新的 src/routes
目录中:
routes.tsimport express from "express"; import { handleError } from "../middleware/errorHandler"; import { handleRegisterStart, handleRegisterFinish } from "../controllers/registration"; import { handleLoginStart, handleLoginFinish } from "../controllers/authentication"; const router = express.Router(); router.post("/registerStart", handleRegisterStart); router.post("/registerFinish", handleRegisterFinish); router.post("/loginStart", handleLoginStart); router.post("/loginFinish", handleLoginFinish); router.use(handleError); export { router };
本部分密钥教程重点介绍如何在应用程序的前端支持密钥。我们有一个非常基础的前端,由三个文件组成:index.html
、styles.css
和 script.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.jsdocument.getElementById("registerButton").addEventListener("click", register); document.getElementById("loginButton").addEventListener("click", login); function showMessage(message, isError = false) { const messageElement = document.getElementById("message"); messageElement.textContent = message; messageElement.style.color = isError ? "red" : "green"; } async function register() { // Retrieve the username from the input field const username = document.getElementById("username").value; try { // Get registration options from your server. Here, we also receive the challenge. const response = await fetch("/api/passkey/registerStart", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: username }), }); console.log(response); // Check if the registration options are ok. if (!response.ok) { throw new Error( "User already exists or failed to get registration options from server", ); } // Convert the registration options to JSON. const options = await response.json(); console.log(options); // This triggers the browser to display the passkey / WebAuthn modal (e.g. Face ID, Touch ID, Windows Hello). // A new attestation is created. This also means a new public-private-key pair is created. const attestationResponse = await SimpleWebAuthnBrowser.startRegistration(options); // Send attestationResponse back to server for verification and storage. const verificationResponse = await fetch("/api/passkey/registerFinish", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(attestationResponse), }); if (verificationResponse.ok) { showMessage("Registration successful"); } else { showMessage("Registration failed", true); } } catch (error) { showMessage("Error: " + error.message, true); } } async function login() { // Retrieve the username from the input field const username = document.getElementById("username").value; try { // Get login options from your server. Here, we also receive the challenge. const response = await fetch("/api/passkey/loginStart", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: username }), }); // Check if the login options are ok. if (!response.ok) { throw new Error("Failed to get login options from server"); } // Convert the login options to JSON. const options = await response.json(); console.log(options); // This triggers the browser to display the passkey / WebAuthn modal (e.g. Face ID, Touch ID, Windows Hello). // A new assertionResponse is created. This also means that the challenge has been signed. const assertionResponse = await SimpleWebAuthnBrowser.startAuthentication(options); // Send assertionResponse back to server for verification. const verificationResponse = await fetch("/api/passkey/loginFinish", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(assertionResponse), }); if (verificationResponse.ok) { showMessage("Login successful"); } else { showMessage("Login failed", true); } } catch (error) { showMessage("Error: " + error.message, true); } }
在 script.js
中,有三个主要函数:
1. showMessage 函数:
这是一个实用函数,主要用于显示错误消息,辅助调试。
2. Register 函数:
当用户点击“注册”时触发。它从输入字段中提取用户名并将其发送到 passkeyRegisterStart 端点。响应包括
PublicKeyCredentialCreationOptions
,这些选项被转换为 JSON 并传递给
SimpleWebAuthnBrowser.startRegistration
。此调用会激活设备认证器(如面容 ID 或触控 ID)。在本地认证成功后,签名的挑战码被发送回
passkeyRegisterFinish
端点,完成密钥创建过程。
在注册(sign-up)过程中,认证对象起着至关重要的作用,所以让我们仔细看看它。
认证对象主要由三个部分组成:fmt
、attStmt
和 authData
。fmt
元素表示认证声明的格式,而 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; }
要看到您的应用程序运行,请使用以下命令编译并运行您的 TypeScript 代码:
npm run dev
您的服务器现在应该在 http://localhost:8080 上运行。
生产环境的考虑因素:
请记住,我们所涵盖的是一个基本大纲。在生产环境中部署密钥应用程序时,您需要更深入地研究:
我们已经为我们的数据库设置了一个 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.ymlversion: "3.1" services: db: image: mysql command: --default-authentication-plugin=mysql_native_password restart: always environment: MYSQL_ROOT_PASSWORD: my-secret-pw MYSQL_DATABASE: webauthn_db ports: - "3306:3306" volumes: - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql app: build: . ports: - "8080:8080" environment: - DB_HOST=db - DB_USER=root - DB_PASSWORD=my-secret-pw - DB_NAME=webauthn_db - SESSION_SECRET=secret123 depends_on: - db
如果您现在在终端中运行 docker compose up
并访问
http://localhost:8080,您应该能看到您的密钥 Web 应用的工作版本(此处在
Windows 11 23H2 + Chrome 119 上运行):
由于我们已经从事密钥实现工作相当长一段时间,我们发现在处理实际的密钥应用时会遇到一些挑战:
此外,我们为开发者在实现部分提供以下提示:
利用密钥调试器
密钥调试器 有助于测试不同的 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,因为这是可用性和安全性之间的一个良好权衡。这意味着在合适的设备上会有安全检查,同时在没有生物识别功能的设备上保持用户友好性。
我们希望这篇密钥教程能让您清楚地了解如何有效地实现密钥。在整个教程中,我们逐步介绍了创建密钥应用程序的基本步骤,重点关注基本概念和实际实现。虽然本指南只是一个起点,但在 WebAuthn 的世界里还有更多值得探索和完善的地方。
我们鼓励开发者深入研究密钥的细微之处(例如添加多个密钥、检查设备上的密钥就绪情况或提供恢复解决方案)。这是一段值得踏上的旅程,既有挑战,也能在增强用户认证方面带来巨大的回报。通过密钥,您不仅仅是在构建一个功能;您正在为创建一个更安全、更用户友好的数字世界做出贡献。
Enjoyed this read?
🤝 Join our Passkeys Community
Share passkeys implementation tips and get support to free the world from passwords.
🚀 Subscribe to Substack
Get the latest news, strategies, and insights about passkeys sent straight to your inbox.
Related Articles
Table of Contents