下载并运行一套完整的 Node.js 示例,从发出 challenge、调用浏览器 API,到服务器验签和建立会话。它创建独立的匿名学习账号,无需邮箱或密码,也不会给输入的任意用户名绑定凭据。
下载完整示例 ZIP · 查看运行说明 · 服务端源代码 · 浏览器代码 · 自动测试
这是在电脑本地运行的实验。本站 Pages 服务提供文档与源码下载;线上文档页不运行认证服务,也不保存读者的通行密钥记录。
运行条件与命令#
需要 Node.js 22 或更高版本,以及支持 WebAuthn 的浏览器和能执行本地用户验证的认证器。实际是否出现指纹、面容、PIN、安全密钥或手机选项,取决于浏览器、系统和设备配置。
ZIP 解压后,进入含有 package.json 的 examples 目录;项目源码中的目录也叫 examples:
cd examples
npm ci
npm test
npm start浏览器打开 http://localhost:3000。示例固定校验这个 origin 和 localhost RP ID,因此不要替换为 127.0.0.1、局域网 IP 或别的端口。端口被占用时先停止占用服务。Node.js 版本下限依据 SimpleWebAuthn 官方文档;安装文件锁定 @simplewebauthn/server 14.0.3 和 Express 5.3.0,便于复现。
按这四步练习#
- 点击“创建实验账号与通行密钥”,完成系统提示。页面显示匿名账号和凭据元数据。
- 点击“退出当前账号”。这会替换会话,保留服务端内存中的凭据记录。
- 点击“用通行密钥登录”,选择刚才创建的凭据并完成验证。
- 对照登录前后的账号 ID;应回到同一个实验账号。可以在浏览器开发者工具的 Network 面板查看四个协议接口。
进程结束后,账号、公钥和 challenge 全部清空;设备上的通行密钥可能仍在。重启服务后旧凭据会找不到对应账号,可在设备的密码 / 通行密钥管理器中删除该实验记录,再重新注册。
文件职责#
| 文件 | 阅读重点 |
|---|---|
server.js |
challenge 生命周期、服务端验证、账号归属、会话与计数更新 |
public/app.js |
按钮交互、两个原生 WebAuthn API、发送 JSON 响应 |
public/browser.js |
JSON 中 base64url 与 WebAuthn 二进制字段转换 |
public/index.html / style.css |
本地实验页面,无额外构建步骤 |
server.test.js |
软件生成 ES256 密钥,测试真实验签与拒绝路径 |
package-lock.json |
固定本次可复现的依赖树 |
四个接口如何串起来#
| 接口 | 服务器做什么 | 浏览器接着做什么 |
|---|---|---|
POST /api/registration/options |
分配随机账号 ID,生成并保存注册 challenge | 转换二进制字段,调用 create() |
POST /api/registration/verify |
消费 challenge,验证后保存账号与凭据 | 显示注册成功 |
POST /api/authentication/options |
生成并保存登录 challenge,不限制凭据列表 | 调用 get(),让用户选择账号 |
POST /api/authentication/verify |
验证归属与签名,更新计数并轮换会话 | 显示已登录账号 |
所有 POST 都要求本地 origin 和 JSON 内容类型。HttpOnly、SameSite=Strict 的 Cookie 保存不透明随机会话 ID;challenge 保留在服务器中。注册时每个浏览器会话只拥有自己的待创建账号,无法指定已有账号来添加公钥。
注册代码讲解#
服务端创建注册选项:
const options = await generateRegistrationOptions({
rpName: 'Passkey 中文学习实验',
rpID: 'localhost',
userID: Buffer.from(userId, 'base64url'),
userName: `learner-${userId.slice(0, 8)}`,
challenge: randomBytes(32),
attestationType: 'none',
authenticatorSelection: {
residentKey: 'required',
userVerification: 'required',
},
supportedAlgorithmIDs: [-7, -257],
});userId 是服务器产生的 32 字节随机标识。residentKey: 'required' 使示例能够采用可发现凭据的无用户名登录;userVerification: 'required' 同时在服务端验证中强制执行。库会生成适合 JSON 的结构,并补充 requireResidentKey 与 credProps 等选项;参数解释见注册详解。
浏览器把选项转换后,创建凭据并提交结果:
const options = await request('registration/options');
const credential = await navigator.credentials.create({
publicKey: registrationOptions(options),
});
if (!credential) throw new Error('没有创建凭据');
return request('registration/verify', credentialJSON(credential));request() 是示例 app.js 中的同源 POST 辅助函数;registrationOptions() 和 credentialJSON() 来自 browser.js。上面的片段来自完整程序,需要在其上下文中阅读。
无用户名登录如何确认账号#
服务端发出的请求没有 allowCredentials 限制。用户选中凭据后,服务器先根据凭据 ID 查记录,再检查 response.userHandle 是否与记录中的随机账号 ID 相等,最后使用已保存的公钥验证签名:
const credential = credentials.get(req.body.id);
if (!credential || req.body.response?.userHandle !== credential.userId) {
throw new Error('凭据与账号不匹配');
}查到记录不代表已登录。此处之后仍要验证 challenge、origin、RP ID、UP / UV、签名与计数器,成功后才轮换会话。已登录用户点注册按钮会被阻止;想创建另一个独立实验账号时先退出。
测试验证了什么#
npm test 使用 Node.js 自带测试运行器,无需额外测试框架。测试生成软件认证器响应,由真正的验证库解析 CBOR、处理 COSE 公钥并验证 ES256 签名;覆盖以下行为:
- 有效注册、退出、登录,以及成功后拒绝旧响应重放。
- 失败后 challenge 失效、操作类型约束、跨会话不可使用、五分钟过期。
- 篡改 challenge、origin、RP ID、UV 标志、签名或 user handle 均不能登录。
- 非零计数回退被拒绝,始终为零的计数器可以多次登录。
- Host / HTTP Origin 限制、请求频率上限,以及浏览器二进制转换。
软件响应测试能验证后端路径,不代替真实设备、系统 UI、同步提供方与跨设备流程测试。人工练习时仍应完成上面的四步;上线前的验证范围见生产检查与排错指南。
实验边界#
示例只监听本机,最多存放 500 个凭据、1,000 个会话;同一 IP 每分钟最多 120 个 API 请求。会话一小时过期,challenge 五分钟过期;客户端 timeout: 60000 是另一项提示。
它没有持久化、多实例事务、真实身份验证、恢复、添加第二个凭据、删除凭据或线上 HTTPS 会话配置。将它移到服务器时,不能仅把 localhost 替换成域名就视为生产接入。继续阅读生产系统设计。